import { createHash } from "node:crypto";
import type { Page } from "playwright";
import type { GeometryArtifactRef, GeometryProbe, Rect } from "./types.js";
import { persistArtifactAtPath } from "../../../playwright/src/artifacts.js";
import type { RedactionRule } from "../../../playwright/src/redaction.js";
import { captureCellOriginal } from "../../../playwright/src/cell-original.js";

const CROP_MARGIN_PX = 8;

export async function captureGeometryProbe(page: Page): Promise<GeometryProbe> {
  return page.evaluate(() => {
    type DomRect = { x: number; y: number; width: number; height: number };
    type ClipEntry = {
      locator: string;
      rect: DomRect;
      overflow: string;
      overflowClipMargin: string;
      borderRadius: string;
      contain: string;
    };
    type Occluder = {
      locator: string;
      rect: DomRect;
      pointerEvents: string;
      opacity: number;
      position: string;
      viewportPinned: boolean;
      scrollContainerLocator: string | null;
      scrollContainerLocators: string[];
    };
    type Transform = { locator: string; matrix: string };

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

    function locatorSegment(element: Element, indexed: boolean): string {
      const testId = testIdOf(element);
      if (testId !== null) {
        const base = `[data-testid="${testId}"]`;
        const parent = element.parentElement;
        if (parent === null || !indexed) {
          return base;
        }
        // A test id repeats across rows, so it narrows a segment without
        // identifying an element; position separates siblings that share it.
        const siblings = Array.from(parent.children);
        const twins = siblings.filter((sibling) => testIdOf(sibling) === testId);
        return twins.length < 2
          ? base
          : `${base}:nth-child(${String(siblings.indexOf(element) + 1)})`;
      }
      const tag = element.tagName.toLowerCase();
      const role = element.getAttribute("role");
      const base = role !== null && role.length > 0 ? `${tag}[role="${role}"]` : tag;
      const parent = element.parentElement;
      if (parent === null || !indexed) {
        return base;
      }
      const twins = Array.from(parent.children).filter(
        (sibling) => testIdOf(sibling) === null && sibling.tagName === element.tagName,
      );
      if (twins.length < 2) {
        return base;
      }
      return `${base}:nth-of-type(${String(twins.indexOf(element) + 1)})`;
    }

    function buildLocatorPath(element: Element, indexed: boolean): string {
      const segments: string[] = [];
      let current: Element | null = element;
      while (current !== null) {
        if (current.id.length > 0) {
          segments.unshift(`#${current.id}`);
          break;
        }
        // Only `id` terminates the walk.
        segments.unshift(locatorSegment(current, indexed));
        current = current.parentElement;
      }
      return segments.join(" > ");
    }

    function buildLocator(element: Element): string {
      return buildLocatorPath(element, true);
    }

    function buildStructuralLocator(element: Element): string {
      return buildLocatorPath(element, false);
    }

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

    function rectFromDomRect(rect: DOMRect): DomRect {
      return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
    }

    function intersectionArea(a: DomRect, b: DomRect): number {
      const left = Math.max(a.x, b.x);
      const top = Math.max(a.y, b.y);
      const right = Math.min(a.x + a.width, b.x + b.width);
      const bottom = Math.min(a.y + a.height, b.y + b.height);
      const width = right - left;
      const height = bottom - top;
      if (width <= 0 || height <= 0) {
        return 0;
      }
      return width * height;
    }

    function viewportRect(): DomRect {
      return {
        x: 0,
        y: 0,
        width: window.innerWidth,
        height: window.innerHeight,
      };
    }

    function isClippingOverflow(overflow: string): boolean {
      return (
        overflow === "hidden" ||
        overflow === "clip" ||
        overflow === "scroll" ||
        overflow === "auto"
      );
    }

    function hasClipRadius(style: CSSStyleDeclaration): boolean {
      const corners = [
        style.borderTopLeftRadius,
        style.borderTopRightRadius,
        style.borderBottomLeftRadius,
        style.borderBottomRightRadius,
      ];
      return corners.some((value) => {
        const parsed = Number.parseFloat(value);
        return Number.isFinite(parsed) && parsed > 0;
      });
    }

    function isClipAncestor(element: Element, style: CSSStyleDeclaration): boolean {
      if (isClippingOverflow(style.overflow) || isClippingOverflow(style.overflowX) || isClippingOverflow(style.overflowY)) {
        return true;
      }
      if (hasClipRadius(style)) {
        return true;
      }
      if (style.contain.includes("paint") || style.contain.includes("strict") || style.contain.includes("content")) {
        return true;
      }
      return element === document.documentElement;
    }

    function clipChainFor(element: Element): ClipEntry[] {
      const chain: ClipEntry[] = [];
      let current: Element | null = element;
      while (current !== null) {
        const style = window.getComputedStyle(current);
        if (isClipAncestor(current, style)) {
          const overflowClipMargin: unknown = Reflect.get(style, "overflowClipMargin");
          chain.push({
            locator: buildLocator(current),
            rect: rectFromDomRect(current.getBoundingClientRect()),
            overflow: style.overflow,
            overflowClipMargin: typeof overflowClipMargin === "string" ? overflowClipMargin : "",
            borderRadius: style.borderRadius,
            contain: style.contain,
          });
        }
        current = current.parentElement;
      }
      chain.push({
        locator: "viewport",
        rect: viewportRect(),
        overflow: "hidden",
        overflowClipMargin: "0px",
        borderRadius: "0px",
        contain: "none",
      });
      return chain;
    }

    function transformMatricesFor(element: Element): Transform[] {
      const matrices: Transform[] = [];
      let current: Element | null = element;
      while (current !== null) {
        const style = window.getComputedStyle(current);
        if (style.transform !== "none") {
          matrices.push({
            locator: buildLocator(current),
            matrix: style.transform,
          });
        }
        current = current.parentElement;
      }
      return matrices;
    }

    function extensionBeyondClip(
      glyph: DomRect,
      clip: DomRect,
    ): { extensionPx: number; side: "left" | "right" | "top" | "bottom" } | null {
      const leftOverflow = Math.max(0, clip.x - glyph.x);
      const topOverflow = Math.max(0, clip.y - glyph.y);
      const rightOverflow = Math.max(0, glyph.x + glyph.width - (clip.x + clip.width));
      const bottomOverflow = Math.max(0, glyph.y + glyph.height - (clip.y + clip.height));
      const maxOverflow = Math.max(leftOverflow, topOverflow, rightOverflow, bottomOverflow);
      if (maxOverflow <= 0.5) {
        return null;
      }
      if (maxOverflow === leftOverflow) {
        return { extensionPx: leftOverflow, side: "left" };
      }
      if (maxOverflow === topOverflow) {
        return { extensionPx: topOverflow, side: "top" };
      }
      if (maxOverflow === rightOverflow) {
        return { extensionPx: rightOverflow, side: "right" };
      }
      return { extensionPx: bottomOverflow, side: "bottom" };
    }

    function nearestClippingAncestor(element: Element): Element | null {
      let current: Element | null = element.parentElement;
      while (current !== null) {
        if (current === document.documentElement || current === document.body) {
          current = current.parentElement;
          continue;
        }
        const style = window.getComputedStyle(current);
        if (isClipAncestor(current, style)) {
          return current;
        }
        current = current.parentElement;
      }
      return null;
    }

    function collectGlyphClippings() {
      const observations: GeometryProbe["glyphClippings"] = [];
      const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);

      while (walker.nextNode()) {
        const node = walker.currentNode;
        const parent = node.parentElement;
        if (parent === null || !isVisible(parent)) {
          continue;
        }
        if (parent.closest("[data-invariantum-code-example], [data-code-example], pre, code") !== null) {
          continue;
        }
        if (parent.closest("[data-invariantum-carousel-clip]") !== null) {
          continue;
        }

        const text = (node.textContent ?? "").replace(/\s+/g, " ").trim();
        if (text.length === 0) {
          continue;
        }

        const range = document.createRange();
        range.selectNodeContents(node);
        const glyphRects = Array.from(range.getClientRects()).map(rectFromDomRect);
        if (glyphRects.length === 0) {
          continue;
        }

        const clipAncestor = nearestClippingAncestor(parent);
        if (clipAncestor === null) {
          continue;
        }
        const clipAncestorRect = rectFromDomRect(clipAncestor.getBoundingClientRect());
        if (clipAncestorRect.width <= 1 || clipAncestorRect.height <= 1) {
          continue;
        }
        const clipStyle = window.getComputedStyle(clipAncestor);
        for (const glyphRect of glyphRects) {
          const extension = extensionBeyondClip(glyphRect, clipAncestorRect);
          if (extension === null) {
            continue;
          }
          observations.push({
            locator: buildLocator(parent),
            text,
            glyphRects: [glyphRect],
            clipAncestorLocator: buildLocator(clipAncestor),
            clipAncestorStructuralLocator: buildStructuralLocator(clipAncestor),
            clipAncestorRect: clipAncestorRect,
            extensionPx: Math.round(extension.extensionPx * 10) / 10,
            extensionSide: extension.side,
            clipChain: clipChainFor(parent),
            computedOverflow: clipStyle.overflow,
            direction: window.getComputedStyle(parent).direction || document.dir || "ltr",
            transformMatrices: transformMatricesFor(parent),
            targetRect: glyphRect,
            occluderRects: [],
          });
        }
      }

      return observations;
    }

    function isInteractive(element: Element): boolean {
      const tag = element.tagName.toLowerCase();
      if (tag === "button" || tag === "a" || tag === "input" || tag === "select" || tag === "textarea") {
        return true;
      }
      const role = element.getAttribute("role");
      return role === "button" || role === "link" || role === "menuitem";
    }

    function sampleInteriorPoints(element: Element): Array<{ x: number; y: number; role: "center" | "edge" }> {
      const points: Array<{ x: number; y: number; role: "center" | "edge" }> = [];
      // An inline element that wraps has one box per line, and its bounding rect
      // spans the gaps between them; sampling each fragment avoids false hits in
      // those gaps while retaining edge coverage for partial occlusion evidence.
      for (const box of Array.from(element.getClientRects())) {
        const inset = Math.min(4, Math.max(1, Math.min(box.width, box.height) * 0.2));
        points.push(
          { x: box.x + box.width / 2, y: box.y + box.height / 2, role: "center" },
          { x: box.x + inset, y: box.y + inset, role: "edge" },
          { x: box.x + box.width - inset, y: box.y + inset, role: "edge" },
          { x: box.x + inset, y: box.y + box.height - inset, role: "edge" },
          { x: box.x + box.width - inset, y: box.y + box.height - inset, role: "edge" },
        );
      }
      return points;
    }

    function isLegitimateHit(target: Element, interactive: Element): boolean {
      return target === interactive || interactive.contains(target);
    }

    function targetParticipatesInStack(stack: Element[], interactive: Element): boolean {
      return stack.some((element) => element === interactive || interactive.contains(element));
    }

    // An ancestor paints below its descendants, so reaching one means the sample
    // point lies outside the target's painted geometry — a rounded corner, a
    // clipped fragment — rather than something covering it.
    function isOutsideTargetHit(target: Element, interactive: Element): boolean {
      return target !== interactive && target.contains(interactive);
    }

    function isAncestor(ancestor: Element, descendant: Element): boolean {
      return ancestor.contains(descendant) && ancestor !== descendant;
    }

    function strictXOverlapRects(a: DomRect, b: DomRect): boolean {
      return a.x < b.x + b.width && a.x + a.width > b.x;
    }

    function constrainsVerticalScroll(overflowY: string): boolean {
      return (
        overflowY === "auto" ||
        overflowY === "scroll" ||
        overflowY === "overlay" ||
        overflowY === "hidden" ||
        overflowY === "clip"
      );
    }

    function establishesFixedContainingBlock(style: CSSStyleDeclaration): boolean {
      if (style.transform !== "none") {
        return true;
      }
      if (style.perspective !== "none") {
        return true;
      }
      if (style.filter !== "none") {
        return true;
      }
      if (style.backdropFilter !== "none") {
        return true;
      }
      const willChange = style.willChange;
      if (willChange !== "auto" && willChange !== "none") {
        for (const token of willChange.split(",")) {
          const trimmed = token.trim();
          if (trimmed === "transform" || trimmed === "perspective" || trimmed === "filter") {
            return true;
          }
        }
      }
      const contain = style.contain;
      return (
        contain.includes("layout") ||
        contain.includes("paint") ||
        contain.includes("strict") ||
        contain.includes("content")
      );
    }

    function isViewportPinned(element: Element): boolean {
      let current: Element | null = element;
      while (current !== null) {
        const style = window.getComputedStyle(current);
        if (style.position === "sticky") {
          return false;
        }
        current = current.parentElement;
      }

      current = element;
      let fixedElement: Element | null = null;
      while (current !== null) {
        const style = window.getComputedStyle(current);
        if (style.position === "fixed") {
          fixedElement = current;
          break;
        }
        current = current.parentElement;
      }
      if (fixedElement === null) {
        return false;
      }

      let ancestor: Element | null = fixedElement.parentElement;
      while (ancestor !== null) {
        if (establishesFixedContainingBlock(window.getComputedStyle(ancestor))) {
          return false;
        }
        ancestor = ancestor.parentElement;
      }
      return true;
    }

    function verticalScrollContainers(element: Element): Element[] {
      const containers: Element[] = [];
      let current: Element | null = element.parentElement;
      while (
        current !== null &&
        current !== document.body &&
        current !== document.documentElement
      ) {
        const overflowY = window.getComputedStyle(current).overflowY;
        if (constrainsVerticalScroll(overflowY)) {
          containers.push(current);
        }
        current = current.parentElement;
      }
      return containers;
    }

    function verticalScrollContainer(element: Element): Element | null {
      return verticalScrollContainers(element)[0] ?? null;
    }

    function occluderFromElement(element: Element): Occluder {
      const style = window.getComputedStyle(element);
      const scrollContainers = verticalScrollContainers(element);
      return {
        locator: buildLocator(element),
        rect: rectFromDomRect(element.getBoundingClientRect()),
        pointerEvents: style.pointerEvents,
        opacity: Number.parseFloat(style.opacity),
        position: style.position,
        viewportPinned: isViewportPinned(element),
        scrollContainerLocator: scrollContainers[0] === undefined ? null : buildLocator(scrollContainers[0]),
        scrollContainerLocators: scrollContainers.map(buildLocator),
      };
    }

    function targetInRootScroller(element: Element): boolean {
      return verticalScrollContainer(element) === null;
    }

    function intersectRects(a: DomRect, b: DomRect): DomRect {
      const left = Math.max(a.x, b.x);
      const top = Math.max(a.y, b.y);
      const right = Math.min(a.x + a.width, b.x + b.width);
      const bottom = Math.min(a.y + a.height, b.y + b.height);
      return {
        x: left,
        y: top,
        width: Math.max(0, right - left),
        height: Math.max(0, bottom - top),
      };
    }

    function hasStickyAncestor(element: Element): boolean {
      let current: Element | null = element.parentElement;
      while (current !== null) {
        if (window.getComputedStyle(current).position === "sticky") return true;
        current = current.parentElement;
      }
      return false;
    }

    function nestedScrollExtent(element: Element) {
      const container = verticalScrollContainer(element);
      if (container === null) return null;
      const rect = rectFromDomRect(container.getBoundingClientRect());
      const visibleRect = clipChainFor(container).reduce(
        (visible, entry) => intersectRects(visible, entry.rect),
        rect,
      );
      return {
        locator: buildLocator(container),
        rect,
        visibleRect,
        scrollY: container.scrollTop,
        maxScrollY: Math.max(0, container.scrollHeight - container.clientHeight),
      };
    }

    function collectViewportFixedOccluders(
      target: Element,
      targetRect: DomRect,
      hitOccluders: Occluder[],
    ): Occluder[] {
      const byLocator = new Map<string, Occluder>();
      for (const element of Array.from(document.querySelectorAll("body *"))) {
        if (element === target || isAncestor(element, target) || isAncestor(target, element)) {
          continue;
        }
        if (!isVisible(element)) {
          continue;
        }
        const style = window.getComputedStyle(element);
        if (style.position !== "fixed" || style.pointerEvents === "none") {
          continue;
        }
        const rect = rectFromDomRect(element.getBoundingClientRect());
        if (!strictXOverlapRects(targetRect, rect)) {
          continue;
        }
        const occluder = occluderFromElement(element);
        byLocator.set(occluder.locator, occluder);
      }
      for (const hit of hitOccluders) {
        if (!hit.viewportPinned) {
          continue;
        }
        if (!byLocator.has(hit.locator)) {
          byLocator.set(hit.locator, hit);
        }
      }
      return Array.from(byLocator.values());
    }

    function scrollExtentFromRoot(): GeometryProbe["scrollExtent"] {
      const root = document.documentElement;
      return {
        scrollX: root.scrollLeft,
        scrollY: root.scrollTop,
        maxScrollX: Math.max(0, root.scrollWidth - root.clientWidth),
        maxScrollY: Math.max(0, root.scrollHeight - root.clientHeight),
        viewportWidth: window.innerWidth,
        viewportHeight: window.innerHeight,
      };
    }

    function collectOcclusions() {
      const observations: GeometryProbe["occlusions"] = [];
      const candidates = Array.from(
        document.querySelectorAll("button, a[href], input, select, textarea, [role='button'], [role='link']"),
      ).filter((element) => isVisible(element) && isInteractive(element));

      for (const interactive of candidates) {
        const style = window.getComputedStyle(interactive);
        if (style.pointerEvents === "none") {
          continue;
        }
        const rect = rectFromDomRect(interactive.getBoundingClientRect());
        const samplePoints = sampleInteriorPoints(interactive)
          .map((point) => {
            const stack = document.elementsFromPoint(point.x, point.y);
            const top = stack[0] ?? interactive;
            return {
              x: point.x,
              y: point.y,
              hitLocator: buildLocator(top),
              legitimate: isLegitimateHit(top, interactive),
              role: point.role,
              outsideTarget: !targetParticipatesInStack(stack, interactive),
            };
          })
          .filter((point) => !point.outsideTarget)
          .map(({ x, y, hitLocator, legitimate, role }) => ({ x, y, hitLocator, legitimate, role }));

        if (samplePoints.every((point) => point.legitimate)) {
          continue;
        }

        const occluders: Occluder[] = [];
        for (const point of samplePoints) {
          if (point.legitimate) {
            continue;
          }
          const stack = document.elementsFromPoint(point.x, point.y);
          for (const element of stack) {
            if (isLegitimateHit(element, interactive) || isOutsideTargetHit(element, interactive)) {
              break;
            }
            const elementStyle = window.getComputedStyle(element);
            if (elementStyle.display === "none" || elementStyle.visibility === "hidden" || elementStyle.pointerEvents === "none") {
              continue;
            }
            occluders.push(occluderFromElement(element));
            break;
          }
        }

        observations.push({
          locator: buildLocator(interactive),
          samplePoints,
          occluderRects: occluders,
          fixedOccluders: collectViewportFixedOccluders(interactive, rect, occluders),
          clipChain: clipChainFor(interactive),
          computedOverflow: style.overflow,
          direction: style.direction || document.dir || "ltr",
          transformMatrices: transformMatricesFor(interactive),
          targetRect: rect,
          targetPosition: style.position,
          targetInRootScroller: targetInRootScroller(interactive),
          targetScrollContainer: nestedScrollExtent(interactive),
          targetHasStickyAncestor: hasStickyAncestor(interactive),
        });
      }

      return observations;
    }

    function nearestSvgAncestor(element: Element): Element | null {
      return element.closest("svg");
    }

    type OverlapElementEvidence = {
      hasRenderedText: boolean;
      zIndex: string;
      ownOpaquePaint: boolean;
      textRects: DomRect[];
    };

    const overlapElementEvidence = new WeakMap<Element, OverlapElementEvidence>();

    function colorHasVisibleAlpha(value: string): boolean {
      if (value === "transparent" || value === "rgba(0, 0, 0, 0)") {
        return false;
      }
      const rgba = value.match(/^rgba\\((?:[^,]+,){3}\\s*([0-9.]+)\\)$/);
      return rgba === null || Number(rgba[1]) > 0;
    }

    function renderedTextRects(element: Element): DomRect[] {
      const rects: DomRect[] = [];
      const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
      while (walker.nextNode()) {
        const node = walker.currentNode;
        const parent = node.parentElement;
        if (parent === null || !isVisible(parent) || (node.textContent ?? "").trim().length === 0) {
          continue;
        }
        const range = document.createRange();
        range.selectNodeContents(node);
        for (const rect of Array.from(range.getClientRects())) {
          if (rect.width > 0 && rect.height > 0) {
            rects.push(rectFromDomRect(rect));
          }
        }
      }
      return rects;
    }

    function overlapEvidenceFor(element: Element): OverlapElementEvidence {
      const cached = overlapElementEvidence.get(element);
      if (cached !== undefined) {
        return cached;
      }
      const style = window.getComputedStyle(element);
      const hasVisibleBorder = [
        [style.borderTopWidth, style.borderTopColor],
        [style.borderRightWidth, style.borderRightColor],
        [style.borderBottomWidth, style.borderBottomColor],
        [style.borderLeftWidth, style.borderLeftColor],
      ].some(
        ([width, color]) =>
          Number.parseFloat(width ?? "") > 0 && colorHasVisibleAlpha(color ?? ""),
      );
      const textRects = renderedTextRects(element);
      const evidence = {
        hasRenderedText: textRects.length > 0,
        zIndex: style.zIndex,
        ownOpaquePaint:
          Number.parseFloat(style.opacity) > 0 &&
          (colorHasVisibleAlpha(style.backgroundColor) ||
            style.backgroundImage !== "none" ||
            hasVisibleBorder),
        textRects,
      };
      overlapElementEvidence.set(element, evidence);
      return evidence;
    }

    function intersectionSamplePoints(intersection: DomRect): Array<{ x: number; y: number }> {
      const fractions = [0.2, 0.5, 0.8];
      return fractions.flatMap((xFraction) =>
        fractions.map((yFraction) => ({
          x: intersection.x + intersection.width * xFraction,
          y: intersection.y + intersection.height * yFraction,
        })),
      );
    }

    function containsPoint(rect: DomRect, point: { x: number; y: number }): boolean {
      return (
        point.x >= rect.x &&
        point.x <= rect.x + rect.width &&
        point.y >= rect.y &&
        point.y <= rect.y + rect.height
      );
    }

    function intersectionCenter(a: DomRect, intersection: DomRect): { x: number; y: number } {
      const left = Math.max(intersection.x, a.x);
      const right = Math.min(intersection.x + intersection.width, a.x + a.width);
      const top = Math.max(intersection.y, a.y);
      const bottom = Math.min(intersection.y + intersection.height, a.y + a.height);
      return { x: (left + right) / 2, y: (top + bottom) / 2 };
    }

    function hitBelongsTo(hit: Element | null, element: Element): boolean {
      return hit !== null && (hit === element || element.contains(hit));
    }

    function provesTextOcclusion(
      top: Element,
      topEvidence: OverlapElementEvidence,
      bottomEvidence: OverlapElementEvidence,
      intersection: DomRect,
    ): boolean {
      for (const bottomTextRect of bottomEvidence.textRects) {
        if (intersectionArea(bottomTextRect, intersection) <= 0) {
          continue;
        }
        const point = intersectionCenter(bottomTextRect, intersection);
        const hit = document.elementFromPoint(point.x, point.y);
        if (!hitBelongsTo(hit, top)) {
          continue;
        }
        if (topEvidence.ownOpaquePaint) {
          return true;
        }
        if (topEvidence.textRects.some((textRect) => containsPoint(textRect, point))) {
          return true;
        }
      }
      return false;
    }

    function collectOverlaps() {
      const observations: GeometryProbe["overlaps"] = [];
      const parents = [document.body, ...Array.from(document.querySelectorAll("body *"))];

      for (const parent of parents) {
        const elements = Array.from(parent.children).filter(isVisible);
        for (let leftIndex = 0; leftIndex < elements.length; leftIndex += 1) {
          for (let rightIndex = leftIndex + 1; rightIndex < elements.length; rightIndex += 1) {
            const left = elements[leftIndex];
            const right = elements[rightIndex];
            if (left === undefined || right === undefined) {
              continue;
            }
            const leftEvidence = overlapEvidenceFor(left);
            const rightEvidence = overlapEvidenceFor(right);
            if (!leftEvidence.hasRenderedText || !rightEvidence.hasRenderedText) {
              continue;
            }
            const leftRect = rectFromDomRect(left.getBoundingClientRect());
            const rightRect = rectFromDomRect(right.getBoundingClientRect());
            const area = intersectionArea(leftRect, rightRect);
            if (area <= 1) {
              continue;
            }
            const intersection = {
            x: Math.max(leftRect.x, rightRect.x),
            y: Math.max(leftRect.y, rightRect.y),
            width: Math.min(leftRect.x + leftRect.width, rightRect.x + rightRect.width) - Math.max(leftRect.x, rightRect.x),
            height: Math.min(leftRect.y + leftRect.height, rightRect.y + rightRect.height) - Math.max(leftRect.y, rightRect.y),
            };
            const leftStyle = window.getComputedStyle(left);
            const rightStyle = window.getComputedStyle(right);
            const leftSvg = nearestSvgAncestor(left);
            const rightSvg = nearestSvgAncestor(right);
            const intersectionHitSamples = intersectionSamplePoints(intersection).map((point) => {
              const hit = document.elementFromPoint(point.x, point.y);
              return { x: point.x, y: point.y, hitLocator: hit === null ? "" : buildLocator(hit) };
            });
            observations.push({
            locatorA: buildLocator(left),
            locatorB: buildLocator(right),
            intersection,
            area,
            targetRect: intersection,
            clipChain: clipChainFor(left),
            occluderRects: [],
            computedOverflow: leftStyle.overflow,
            direction: document.dir || "ltr",
            transformMatrices: [...transformMatricesFor(left), ...transformMatricesFor(right)],
            positionA: leftStyle.position,
            positionB: rightStyle.position,
            viewportPinnedA: isViewportPinned(left),
            viewportPinnedB: isViewportPinned(right),
            sharedSvgRoot: leftSvg !== null && leftSvg === rightSvg,
            hasRenderedTextA: leftEvidence.hasRenderedText,
            hasRenderedTextB: rightEvidence.hasRenderedText,
            zIndexA: leftEvidence.zIndex,
            zIndexB: rightEvidence.zIndex,
            ownOpaquePaintA: leftEvidence.ownOpaquePaint,
            ownOpaquePaintB: rightEvidence.ownOpaquePaint,
            intersectionHitSamples,
            textOcclusionProven:
              provesTextOcclusion(left, leftEvidence, rightEvidence, intersection) ||
              provesTextOcclusion(right, rightEvidence, leftEvidence, intersection),
            });
          }
        }
      }

      return observations;
    }

    function collectClipEscapes() {
      const observations: GeometryProbe["clipEscapes"] = [];
      const candidates = Array.from(document.querySelectorAll("[data-clip-target], .clip-target")).filter(isVisible);

      for (const element of candidates) {
        if (element.closest("[data-invariantum-carousel-clip]") !== null) {
          continue;
        }
        const style = window.getComputedStyle(element);
        const contentRect = rectFromDomRect(element.getBoundingClientRect());
        const clipAncestor = nearestClippingAncestor(element);
        if (clipAncestor === null) {
          continue;
        }
        const clipRect = rectFromDomRect(clipAncestor.getBoundingClientRect());
        const extension = extensionBeyondClip(contentRect, clipRect);
        if (extension === null) {
          continue;
        }
        observations.push({
          locator: buildLocator(element),
          escapeSide: extension.side,
          escapePx: Math.round(extension.extensionPx * 10) / 10,
          contentRect,
          clipChain: clipChainFor(element),
          occluderRects: [],
          computedOverflow: style.overflow,
          direction: style.direction || document.dir || "ltr",
          transformMatrices: transformMatricesFor(element),
          targetRect: contentRect,
        });
      }

      return observations;
    }

    function hasExpansionAffordance(element: Element): boolean {
      if (element.getAttribute("aria-expanded") !== null) {
        return true;
      }
      if (element.closest("[aria-controls]") !== null) {
        return true;
      }
      if (element.closest("[data-expansion-affordance]") !== null) {
        return true;
      }
      return false;
    }

    function collectTruncations() {
      const observations: GeometryProbe["truncations"] = [];
      const candidates = Array.from(document.querySelectorAll(".truncate-target, [data-truncate-target]")).filter(
        isVisible,
      );

      for (const element of candidates) {
        const style = window.getComputedStyle(element);
        const truncated =
          style.textOverflow === "ellipsis" ||
          style.overflow === "hidden" ||
          style.overflowX === "hidden";
        if (!truncated) {
          continue;
        }

        const hasTitle = element.getAttribute("title") !== null && element.getAttribute("title") !== "";
        const ariaLabel = element.getAttribute("aria-label");
        const hasAriaLabel = ariaLabel !== null && ariaLabel.length > 0;
        const hasAffordance = hasExpansionAffordance(element);
        if (hasTitle || hasAriaLabel || hasAffordance) {
          continue;
        }

        observations.push({
          locator: buildLocator(element),
          visibleText: (element.textContent || "").replace(/\s+/g, " ").trim(),
          hasTitle,
          hasAriaLabel,
          hasExpansionAffordance: hasAffordance,
          textOverflow: style.textOverflow,
          overflow: style.overflow,
          clipChain: clipChainFor(element),
          occluderRects: [],
          computedOverflow: style.overflow,
          direction: style.direction || document.dir || "ltr",
          transformMatrices: transformMatricesFor(element),
          targetRect: rectFromDomRect(element.getBoundingClientRect()),
        });
      }

      return observations;
    }

    return {
      deviceScaleFactor: window.devicePixelRatio,
      direction: document.dir || document.documentElement.dir || "ltr",
      scrollExtent: scrollExtentFromRoot(),
      glyphClippings: collectGlyphClippings(),
      occlusions: collectOcclusions(),
      overlaps: collectOverlaps(),
      clipEscapes: collectClipEscapes(),
      truncations: collectTruncations(),
      structuralRowCount: document.querySelectorAll("[data-structural-row]").length,
    };
  });
}

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

interface PageBounds {
  readonly width: number;
  readonly height: number;
}

function clampCropToPage(
  rect: Rect,
  bounds: PageBounds,
): { x: number; y: number; width: number; height: number } | undefined {
  const left = Math.max(0, Math.floor(rect.x - CROP_MARGIN_PX));
  const top = Math.max(0, Math.floor(rect.y - CROP_MARGIN_PX));
  const right = Math.min(bounds.width, Math.ceil(rect.x + rect.width + CROP_MARGIN_PX));
  const bottom = Math.min(bounds.height, Math.ceil(rect.y + rect.height + CROP_MARGIN_PX));

  if (right <= left || bottom <= top) {
    return undefined;
  }

  return { x: left, y: top, width: right - left, height: bottom - top };
}

export async function captureGeometryArtifacts(
  page: Page,
  probe: GeometryProbe,
  cellId: string,
  options: {
    artifactRunDir?: string;
    sourceRunId?: string;
    redactionRules?: RedactionRule[];
  } = {},
): Promise<GeometryCellArtifacts> {
  const original = await captureCellOriginal(page, cellId, {
    fileName: "geometry-full.png",
    ...options,
  });
  const pageBounds = original.pageBounds;
  const fullScreenshot: GeometryArtifactRef = original.ref;

  const targetRects = new Map<string, Rect>();
  for (const observation of probe.glyphClippings) {
    targetRects.set(observation.locator, observation.targetRect);
  }
  for (const observation of probe.occlusions) {
    targetRects.set(observation.locator, observation.targetRect);
  }
  for (const observation of probe.overlaps) {
    targetRects.set(`${observation.locatorA}::${observation.locatorB}`, observation.targetRect);
  }
  for (const observation of probe.clipEscapes) {
    targetRects.set(observation.locator, observation.targetRect);
  }
  for (const observation of probe.truncations) {
    targetRects.set(observation.locator, observation.targetRect);
  }

  const targetCrops: GeometryCellArtifacts["targetCrops"] = [];
  for (const [locator, rect] of targetRects) {
    const clip = clampCropToPage(rect, pageBounds);
    if (clip === undefined) {
      continue;
    }
    // Target rects are document coordinates; without fullPage a clip below the
    // fold falls outside the viewport-sized image Playwright renders.
    const cropBytes = await page.screenshot({ type: "png", clip, fullPage: true });
    const cropHash = sha256Hex(cropBytes);
    const relativePath = `cells/${cellId}/geometry-crop-${sha256Hex(Buffer.from(locator)).slice(0, 12)}-${cropHash.slice(0, 12)}.png`;
    const artifact: GeometryArtifactRef = {
      relativePath,
      contentHash: cropHash,
      mediaType: "image/png",
      dimensions: { width: clip.width, height: clip.height },
    };
    if (options.artifactRunDir !== undefined && options.sourceRunId !== undefined) {
      await persistArtifactAtPath({
        runDir: options.artifactRunDir,
        relativePath,
        payload: {
          bytes: cropBytes,
          mediaType: artifact.mediaType,
          ...(artifact.dimensions === undefined ? {} : { dimensions: artifact.dimensions }),
        },
        sourceRunId: options.sourceRunId,
      });
    }
    targetCrops.push({
      locator,
      margin: CROP_MARGIN_PX,
      artifact,
    });
  }

  return {
    fullScreenshot,
    targetCrops,
  };
}

export type GeometryCellArtifacts = {
  fullScreenshot: GeometryArtifactRef;
  targetCrops: Array<{
    locator: string;
    margin: number;
    artifact: GeometryArtifactRef;
  }>;
};
