import { createHash } from "node:crypto";
import type { Page } from "playwright";
import {
  DISABLED_CURSORS,
  DISABLED_OPACITY_THRESHOLD,
  LARGE_EMPTY_MIN_AREA,
  LARGE_EMPTY_MIN_DIMENSION,
} from "./constants.js";
import { capturePageScreenshot } from "../../../playwright/src/screenshot.js";
import type {
  AssetsArtifactRef,
  AssetsProbe,
  ContainerObservation,
  DisabledStylingObservation,
  ImageObservation,
  Rect,
} from "./types.js";

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null;
}

function parseRect(value: unknown, field: string): Rect {
  if (!isRecord(value)) {
    throw new Error(`${field} must be an object`);
  }
  const x = value.x;
  const y = value.y;
  const width = value.width;
  const height = value.height;
  if (
    typeof x !== "number" ||
    typeof y !== "number" ||
    typeof width !== "number" ||
    typeof height !== "number"
  ) {
    throw new Error(`${field} must contain numeric x, y, width, height`);
  }
  return { x, y, width, height };
}

function parseImageObservation(value: unknown, index: number): ImageObservation {
  if (!isRecord(value)) {
    throw new Error(`images[${String(index)}] must be an object`);
  }
  return {
    locator: String(value.locator),
    src: String(value.src),
    currentSrc: String(value.currentSrc),
    naturalWidth: Number(value.naturalWidth),
    naturalHeight: Number(value.naturalHeight),
    complete: Boolean(value.complete),
    rect: parseRect(value.rect, `images[${String(index)}].rect`),
    visible: Boolean(value.visible),
    alt: String(value.alt),
    decorative: Boolean(value.decorative),
  };
}

function parseContainerObservation(value: unknown, index: number): ContainerObservation {
  if (!isRecord(value)) {
    throw new Error(`containers[${String(index)}] must be an object`);
  }
  const visibleDescendantLocators = value.visibleDescendantLocators;
  const associatedFailures = value.associatedFailures;
  if (!Array.isArray(visibleDescendantLocators) || !Array.isArray(associatedFailures)) {
    throw new Error(`containers[${String(index)}] descendant/failure arrays must be arrays`);
  }
  return {
    locator: String(value.locator),
    rect: parseRect(value.rect, `containers[${String(index)}].rect`),
    visible: Boolean(value.visible),
    area: Number(value.area),
    visibleDescendantCount: Number(value.visibleDescendantCount),
    visibleDescendantLocators: visibleDescendantLocators.map(String),
    backgroundColor: String(value.backgroundColor),
    backgroundImage: String(value.backgroundImage),
    hasBackgroundMedia: Boolean(value.hasBackgroundMedia),
    associatedFailures: associatedFailures.map(String),
  };
}

function parseDisabledStylingObservation(
  value: unknown,
  index: number,
): DisabledStylingObservation {
  if (!isRecord(value)) {
    throw new Error(`disabledStyling[${String(index)}] must be an object`);
  }
  if (!Array.isArray(value.siblingNorms)) {
    throw new Error(`disabledStyling[${String(index)}].siblingNorms must be an array`);
  }
  return {
    locator: String(value.locator),
    opacity: Number(value.opacity),
    cursor: String(value.cursor),
    pointerEvents: String(value.pointerEvents),
    semanticallyDisabled: Boolean(value.semanticallyDisabled),
    accessibleExplanation:
      value.accessibleExplanation === null
        ? null
        : typeof value.accessibleExplanation === "string"
          ? value.accessibleExplanation
          : null,
    siblingNorms: value.siblingNorms.map((sibling, siblingIndex) => {
      if (!isRecord(sibling)) {
        throw new Error(
          `disabledStyling[${String(index)}].siblingNorms[${String(siblingIndex)}] must be an object`,
        );
      }
      return {
        locator: String(sibling.locator),
        opacity: Number(sibling.opacity),
        cursor: String(sibling.cursor),
        pointerEvents: String(sibling.pointerEvents),
        semanticallyDisabled: Boolean(sibling.semanticallyDisabled),
      };
    }),
    looksDisabled: Boolean(value.looksDisabled),
    unexplained: Boolean(value.unexplained),
  };
}

export function parseAssetsProbe(input: unknown): AssetsProbe {
  if (!isRecord(input)) {
    throw new Error("assets probe must be an object");
  }
  if (!Array.isArray(input.images)) {
    throw new Error("images must be an array");
  }
  if (!Array.isArray(input.containers)) {
    throw new Error("containers must be an array");
  }
  if (!Array.isArray(input.disabledStyling)) {
    throw new Error("disabledStyling must be an array");
  }

  return {
    images: input.images.map((image, index) => parseImageObservation(image, index)),
    containers: input.containers.map((container, index) =>
      parseContainerObservation(container, index),
    ),
    disabledStyling: input.disabledStyling.map((observation, index) =>
      parseDisabledStylingObservation(observation, index),
    ),
  };
}

export async function captureAssetsProbe(page: Page): Promise<AssetsProbe> {
  const raw = await page.evaluate(
    ({
      minArea,
      minDimension,
      opacityThreshold,
      disabledCursors,
    }: {
      minArea: number;
      minDimension: number;
      opacityThreshold: number;
      disabledCursors: string[];
    }) => {
      const disabledCursorSet = new Set(disabledCursors);

      function testIdOf(element: Element): string | null {
        const testId = element.getAttribute("data-testid");
        return testId === null || testId.length === 0 ? null : testId;
      }

      // A test id names a kind of element, not one element: a card list repeats
      // it on every row. Siblings sharing a segment are separated by position —
      // by type for a tag, by child index for a test id, which is not a type.
      function locatorSegment(element: Element): string {
        const testId = testIdOf(element);
        const base = testId === null ? element.tagName.toLowerCase() : `[data-testid="${testId}"]`;
        const parent = element.parentElement;
        if (parent === null) {
          return base;
        }
        const siblings = Array.from(parent.children);
        if (testId === null) {
          const twins = siblings.filter(
            (sibling) => testIdOf(sibling) === null && sibling.tagName === element.tagName,
          );
          return twins.length < 2
            ? base
            : `${base}:nth-of-type(${String(twins.indexOf(element) + 1)})`;
        }
        const twins = siblings.filter((sibling) => testIdOf(sibling) === testId);
        return twins.length < 2
          ? base
          : `${base}:nth-child(${String(siblings.indexOf(element) + 1)})`;
      }

      // A bare tag name is shared by every anonymous element of that kind on the
      // page, and finding identity is derived from it, so two broken images
      // collapse into one identity and the run aborts on the collision.
      function buildLocator(element: Element): string {
        const segments: string[] = [];
        let current: Element | null = element;
        while (current !== null) {
          if (current.id.length > 0) {
            segments.unshift(`#${current.id}`);
            break;
          }
          segments.unshift(locatorSegment(current));
          current = current.parentElement;
        }
        return segments.join(" > ");
      }

      function isElementVisible(element: Element): boolean {
        const style = window.getComputedStyle(element);
        if (style.display === "none" || style.visibility === "hidden") {
          return false;
        }
        const rect = element.getBoundingClientRect();
        return rect.width > 0 && rect.height > 0;
      }

      function isDecorativeImage(img: HTMLImageElement): boolean {
        if (img.getAttribute("role") === "presentation") {
          return true;
        }
        if (img.getAttribute("aria-hidden") === "true") {
          return true;
        }
        const alt = img.getAttribute("alt");
        return alt !== null && alt.trim().length === 0;
      }

      function collectVisibleDescendants(container: Element): string[] {
        const locators: string[] = [];
        const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT);
        while (walker.nextNode()) {
          const element = walker.currentNode;
          if (!(element instanceof Element) || element === container) {
            continue;
          }
          if (!isElementVisible(element)) {
            continue;
          }
          const text = element.textContent.replace(/\s+/g, "").trim();
          const hasText = text.length > 0;
          const isInteractive =
            element.matches("button, a[href], input, select, textarea, [role='button']") ||
            element.getAttribute("tabindex") !== null;
          const isMedia = element.matches("img, video, canvas, svg");
          if (hasText || isInteractive || isMedia) {
            locators.push(buildLocator(element));
          }
        }
        return locators;
      }

      function hasBackgroundMedia(style: CSSStyleDeclaration): boolean {
        const image = style.backgroundImage;
        return image !== "none" && image.length > 0;
      }

      function readAccessibleExplanation(element: Element): string | null {
        const describedBy = element.getAttribute("aria-describedby");
        if (describedBy !== null && describedBy.trim().length > 0) {
          const parts = describedBy
            .split(/\s+/)
            .map((id) => {
              const node = document.getElementById(id);
              if (node === null) {
                return "";
              }
              return node.textContent.trim();
            })
            .filter((part) => part.length > 0);
          if (parts.length > 0) {
            return parts.join(" ");
          }
        }
        const title = element.getAttribute("title");
        if (title !== null && title.trim().length > 0) {
          return title.trim();
        }
        const ariaLabel = element.getAttribute("aria-label");
        if (ariaLabel !== null && ariaLabel.trim().length > 0) {
          return ariaLabel.trim();
        }
        return null;
      }

      function isSemanticallyDisabled(element: Element): boolean {
        if (element.matches(":disabled")) {
          return true;
        }
        if (element.getAttribute("aria-disabled") === "true") {
          return true;
        }
        return false;
      }

      function looksDisabled(
        opacity: number,
        cursor: string,
        pointerEvents: string,
      ): boolean {
        const lowOpacity = opacity <= opacityThreshold;
        const blockedPointer = pointerEvents === "none";
        const disabledCursor =
          cursor === "not-allowed" || disabledCursorSet.has(cursor);
        return (lowOpacity && disabledCursor) || (lowOpacity && blockedPointer);
      }

      function readSiblingNorms(element: Element) {
        const group = element.closest("[data-disabled-sibling-group]");
        if (group === null) {
          return [];
        }
        const siblings = Array.from(
          group.querySelectorAll("[data-disabled-candidate], button, a[role='button']"),
        ).filter((candidate) => candidate !== element);
        return siblings.map((sibling) => {
          const style = window.getComputedStyle(sibling);
          return {
            locator: buildLocator(sibling),
            opacity: Number(style.opacity),
            cursor: style.cursor,
            pointerEvents: style.pointerEvents,
            semanticallyDisabled: isSemanticallyDisabled(sibling),
          };
        });
      }

      const images = Array.from(document.querySelectorAll("img")).map((img) => {
        const rect = img.getBoundingClientRect();
        return {
          locator: buildLocator(img),
          src: img.getAttribute("src") ?? "",
          currentSrc: img.currentSrc,
          naturalWidth: img.naturalWidth,
          naturalHeight: img.naturalHeight,
          complete: img.complete,
          rect: {
            x: rect.x,
            y: rect.y,
            width: rect.width,
            height: rect.height,
          },
          visible: isElementVisible(img),
          alt: img.getAttribute("alt") ?? "",
          decorative: isDecorativeImage(img),
        };
      });

      const containers = Array.from(
        document.querySelectorAll("[data-asset-container], [data-empty-container]"),
      ).map((container) => {
        const style = window.getComputedStyle(container);
        const rect = container.getBoundingClientRect();
        const visibleDescendantLocators = collectVisibleDescendants(container);
        const failedImages = Array.from(container.querySelectorAll("img"))
          .filter((img) => img.complete && img.naturalWidth === 0)
          .map((img) => buildLocator(img));
        return {
          locator: buildLocator(container),
          rect: {
            x: rect.x,
            y: rect.y,
            width: rect.width,
            height: rect.height,
          },
          visible: isElementVisible(container),
          area: rect.width * rect.height,
          visibleDescendantCount: visibleDescendantLocators.length,
          visibleDescendantLocators,
          backgroundColor: style.backgroundColor,
          backgroundImage: style.backgroundImage,
          hasBackgroundMedia: hasBackgroundMedia(style),
          associatedFailures: failedImages,
        };
      });

      const disabledStyling = Array.from(
        document.querySelectorAll("[data-disabled-candidate], button, a[role='button']"),
      )
        .filter((element) => isElementVisible(element))
        .map((element) => {
          const style = window.getComputedStyle(element);
          const opacity = Number(style.opacity);
          const cursor = style.cursor;
          const pointerEvents = style.pointerEvents;
          const semanticallyDisabled = isSemanticallyDisabled(element);
          const accessibleExplanation = readAccessibleExplanation(element);
          const siblingNorms = readSiblingNorms(element);
          const disabledLooking = looksDisabled(opacity, cursor, pointerEvents);
          const explained =
            semanticallyDisabled ||
            (accessibleExplanation !== null && accessibleExplanation.length > 0);
          const siblingContrast =
            siblingNorms.length === 0
              ? disabledLooking
              : siblingNorms.some((sibling) => {
                  if (sibling.semanticallyDisabled) {
                    return false;
                  }
                  const opacityDelta = Math.abs(sibling.opacity - opacity);
                  const cursorDiffers =
                    sibling.cursor !== cursor &&
                    (cursor === "not-allowed" || disabledCursorSet.has(cursor));
                  const pointerDiffers =
                    sibling.pointerEvents === "auto" && pointerEvents === "none";
                  return opacityDelta >= 0.2 || cursorDiffers || pointerDiffers;
                });
          const unexplained = disabledLooking && !explained && siblingContrast;

          return {
            locator: buildLocator(element),
            opacity,
            cursor,
            pointerEvents,
            semanticallyDisabled,
            accessibleExplanation,
            siblingNorms,
            looksDisabled: disabledLooking,
            unexplained,
          };
        });

      return {
        images,
        containers: containers.filter(
          (container) =>
            container.visible &&
            container.rect.width >= minDimension &&
            container.rect.height >= minDimension &&
            container.area >= minArea,
        ),
        disabledStyling,
      };
    },
    {
      minArea: LARGE_EMPTY_MIN_AREA,
      minDimension: LARGE_EMPTY_MIN_DIMENSION,
      opacityThreshold: DISABLED_OPACITY_THRESHOLD,
      disabledCursors: [...DISABLED_CURSORS],
    },
  );

  return parseAssetsProbe(raw);
}

function sha256Hex(bytes: Buffer): string {
  return createHash("sha256").update(bytes).digest("hex");
}

export async function captureAssetsArtifacts(
  page: Page,
  cellId: string,
): Promise<{ fullScreenshot: AssetsArtifactRef }> {
  const fullScreenshotBytes = await capturePageScreenshot(page);
  const viewport = page.viewportSize();
  return {
    fullScreenshot: {
      relativePath: `cells/${cellId}/assets-full.png`,
      contentHash: sha256Hex(fullScreenshotBytes),
      mediaType: "image/png",
      ...(viewport === null
        ? {}
        : { dimensions: { width: viewport.width, height: viewport.height } }),
    },
  };
}
