import type { Browser } from "playwright";
import {
  classify,
  type ClassifiedRun,
  type CoverageEvent,
  type DetectorOutcome,
  type HarnessEvent,
  type KernelStores,
} from "../../../core/src/classify/classifier.js";
import { asReadonlyKernelStores } from "../../../core/src/sdk/detector.js";
import type { CapabilityProfile } from "../../../core/src/sdk/capability.js";
import type { MatrixCell } from "../../../schema/src/records/context.js";
import { matrixCellSchema } from "../../../schema/src/records/context.js";
import type { CaptureOptions } from "../../../playwright/src/cell-runner.js";
import type { AuthAdapter } from "../../../playwright/src/auth.js";
import type { CoverageGap } from "../../../universal/src/oracle/witnesses.js";
import type { Detector } from "../../../core/src/sdk/detector.js";
import type { RedactionRule } from "../../../playwright/src/redaction.js";
import { contentAddressedFileName } from "../../../playwright/src/artifacts.js";
import { sha256Canonical } from "../../../schema/src/canonical.js";
import { cellFailureEvent } from "../cell-harness.js";
import {
  assertImmutableMeasuredLayoutScreenshot,
  runMeasuredLayoutCell,
} from "./capture.js";
import type {
  MeasuredLayoutCellEvidence,
  MeasuredLayoutMatrixEvidence,
  Ui001EvidenceRecord,
} from "./types.js";
import { MEASURED_LAYOUT_DETECTOR_VERSION } from "./types.js";

export type MeasuredLayoutMatrixPlan = {
  cells: MatrixCell[];
  coverageGaps?: CoverageGap[];
};

export type RunMeasuredLayoutMatrixInput = {
  plan: MeasuredLayoutMatrixPlan;
  baseUrl: string;
  detectors: Detector<MeasuredLayoutMatrixEvidence, Ui001EvidenceRecord>[];
  stores: KernelStores;
  runId: string;
  seed: string;
  clockStart: string;
  browser: Browser;
  capture: CaptureOptions;
  timeouts: { navigateMs: number; settleMs: number };
  capabilities: CapabilityProfile;
  adapters?: Record<string, AuthAdapter>;
  artifactRunDir?: string;
  redactionRules?: RedactionRule[];
  onCellEvidence?: (evidence: MeasuredLayoutCellEvidence) => void;
};

function compareUnicodeScalars(left: string, right: string): number {
  let leftIndex = 0;
  let rightIndex = 0;
  while (leftIndex < left.length && rightIndex < right.length) {
    const leftCode = left.codePointAt(leftIndex);
    const rightCode = right.codePointAt(rightIndex);
    if (leftCode === undefined || rightCode === undefined) break;
    if (leftCode !== rightCode) return leftCode < rightCode ? -1 : 1;
    leftIndex += leftCode > 0xffff ? 2 : 1;
    rightIndex += rightCode > 0xffff ? 2 : 1;
  }
  return left.length - right.length;
}

function browserExecutionContext(cell: MatrixCell) {
  return { kind: "browser" as const, cell: { id: cell.id } };
}

function detectorScope(cell: MatrixCell, detectorId: string) {
  return {
    id: cell.id,
    detectorId,
    surfaceId: cell.id,
  };
}

function toClassifierOutcome(
  outcome: Awaited<
    ReturnType<Detector<MeasuredLayoutMatrixEvidence, Ui001EvidenceRecord>["evaluate"]>
  >[number],
  cell: MatrixCell,
  cellArtifacts: DetectorOutcome["artifacts"],
): DetectorOutcome {
  return {
    detector: outcome.detector,
    class: outcome.class,
    severity: outcome.severity,
    target: outcome.target,
    context: browserExecutionContext(cell),
    summary: outcome.summary,
    evidence: outcome.evidence,
    artifacts: [...outcome.artifacts, ...cellArtifacts],
    laneEligibility: outcome.laneEligibility,
    scope: detectorScope(cell, outcome.detector.id),
    violation: outcome.violation,
    ...(outcome.proofConditionMet === undefined
      ? {}
      : { proofConditionMet: outcome.proofConditionMet }),
    ...(outcome.contextDimensions === undefined
      ? {}
      : { contextDimensions: outcome.contextDimensions }),
  };
}

const MEASURED_LAYOUT_EVIDENCE_SCHEMA_VERSION = "measured-layout-evidence/v1";

function schemaError(path: string, message: string): never {
  throw new Error(`${MEASURED_LAYOUT_EVIDENCE_SCHEMA_VERSION} ${path}: ${message}`);
}

function objectAt(value: unknown, path: string): Record<string, unknown> {
  if (value === null || typeof value !== "object" || Array.isArray(value)) schemaError(path, "expected object");
  return value as Record<string, unknown>;
}

function exactKeys(value: unknown, path: string, keys: readonly string[]): Record<string, unknown> {
  const object = objectAt(value, path);
  const actual = Object.keys(object).sort(compareUnicodeScalars);
  const expected = [...keys].sort(compareUnicodeScalars);
  if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
    schemaError(path, "unexpected schema fields");
  }
  return object;
}

function stringAt(value: unknown, path: string): string {
  if (typeof value !== "string" || value.trim().length === 0) schemaError(path, "expected non-empty string");
  return value;
}

function finiteAt(value: unknown, path: string, nonNegative = false): number {
  if (typeof value !== "number" || !Number.isFinite(value) || (nonNegative && value < 0)) schemaError(path, "expected finite number");
  return value;
}

function booleanAt(value: unknown, path: string): boolean {
  if (typeof value !== "boolean") schemaError(path, "expected boolean");
  return value;
}

function enumAt<T extends string>(value: unknown, path: string, values: readonly T[]): T {
  if (typeof value !== "string" || !values.includes(value as T)) schemaError(path, "invalid enum value");
  return value as T;
}

function nullableStringAt(value: unknown, path: string): void {
  if (value !== null) stringAt(value, path);
}

function nullableFiniteAt(value: unknown, path: string): void {
  if (value !== null) finiteAt(value, path);
}

function graphIdentityAt(
  value: unknown,
  path: string,
  identities: ReadonlySet<string>,
  nullable = false,
): void {
  if (nullable && value === null) return;
  const identity = stringAt(value, path);
  if (!identities.has(identity)) schemaError(path, "must identify a graph node");
}

function rectAt(value: unknown, path: string): void {
  const object = exactKeys(value, path, ["x", "y", "width", "height"]);
  finiteAt(object.x, `${path}.x`); finiteAt(object.y, `${path}.y`);
  finiteAt(object.width, `${path}.width`, true); finiteAt(object.height, `${path}.height`, true);
}

function validateNode(value: unknown, path: string): void {
  const node = exactKeys(value, path, ["identity", "structuralSignature", "componentIdentity", "stateSignature", "parent", "containingBlock", "compositeAncestor", "svgRoot", "borderBox", "contentBox", "childUnionBox", "visibility", "position", "viewportPinned", "direction", "writingMode", "transform", "borderWidths", "borderRadii", "flexGridTopology", "scrollContainerState", "textLineRects", "intrinsicAspectRatio"]);
  for (const key of ["identity", "structuralSignature", "stateSignature", "position", "writingMode"] as const) stringAt(node[key], `${path}.${key}`);
  for (const key of ["componentIdentity", "parent", "containingBlock", "compositeAncestor", "svgRoot"] as const) nullableStringAt(node[key], `${path}.${key}`);
  rectAt(node.borderBox, `${path}.borderBox`); rectAt(node.contentBox, `${path}.contentBox`);
  if (node.childUnionBox !== null) rectAt(node.childUnionBox, `${path}.childUnionBox`);
  const visibility = exactKeys(node.visibility, `${path}.visibility`, ["display", "visibility", "opacity", "visible", "zeroArea"]);
  stringAt(visibility.display, `${path}.visibility.display`); stringAt(visibility.visibility, `${path}.visibility.visibility`); finiteAt(visibility.opacity, `${path}.visibility.opacity`, true); booleanAt(visibility.visible, `${path}.visibility.visible`); booleanAt(visibility.zeroArea, `${path}.visibility.zeroArea`);
  booleanAt(node.viewportPinned, `${path}.viewportPinned`); enumAt(node.direction, `${path}.direction`, ["ltr", "rtl"]);
  const transform = exactKeys(node.transform, `${path}.transform`, ["css", "matrix", "axisAligned", "normalized"]);
  stringAt(transform.css, `${path}.transform.css`); if (transform.matrix !== null) { if (!Array.isArray(transform.matrix)) schemaError(`${path}.transform.matrix`, "expected array or null"); transform.matrix.forEach((entry, index) => finiteAt(entry, `${path}.transform.matrix[${String(index)}]`)); } booleanAt(transform.axisAligned, `${path}.transform.axisAligned`); booleanAt(transform.normalized, `${path}.transform.normalized`);
  const borders = exactKeys(node.borderWidths, `${path}.borderWidths`, ["top", "right", "bottom", "left"]); for (const key of ["top", "right", "bottom", "left"] as const) finiteAt(borders[key], `${path}.borderWidths.${key}`, true);
  const radii = exactKeys(node.borderRadii, `${path}.borderRadii`, ["topLeft", "topRight", "bottomRight", "bottomLeft"]); for (const key of ["topLeft", "topRight", "bottomRight", "bottomLeft"] as const) { const radius = exactKeys(radii[key], `${path}.borderRadii.${key}`, ["horizontal", "vertical"]); finiteAt(radius.horizontal, `${path}.borderRadii.${key}.horizontal`, true); finiteAt(radius.vertical, `${path}.borderRadii.${key}.vertical`, true); }
  const topology = exactKeys(node.flexGridTopology, `${path}.flexGridTopology`, ["kind", "flexDirection", "flexWrap", "flexLine", "flexOrder", "gridRow", "gridColumn", "gridRowSpan", "gridColumnSpan", "gridTrackCount", "gridTrackWidths", "gridTrackGaps"]); enumAt(topology.kind, `${path}.flexGridTopology.kind`, ["none", "flex", "grid"]); for (const key of ["flexDirection", "flexWrap"] as const) nullableStringAt(topology[key], `${path}.flexGridTopology.${key}`); for (const key of ["flexLine", "flexOrder", "gridRow", "gridColumn", "gridRowSpan", "gridColumnSpan", "gridTrackCount"] as const) nullableFiniteAt(topology[key], `${path}.flexGridTopology.${key}`); for (const key of ["gridTrackWidths", "gridTrackGaps"] as const) { if (!Array.isArray(topology[key])) schemaError(`${path}.flexGridTopology.${key}`, "expected array"); topology[key].forEach((entry, index) => finiteAt(entry, `${path}.flexGridTopology.${key}[${String(index)}]`, true)); }
  const scroll = exactKeys(node.scrollContainerState, `${path}.scrollContainerState`, ["isScrollContainer", "overflowX", "overflowY", "scrollX", "scrollY", "scrollWidth", "scrollHeight", "clientWidth", "clientHeight"]); booleanAt(scroll.isScrollContainer, `${path}.scrollContainerState.isScrollContainer`); for (const key of ["overflowX", "overflowY"] as const) stringAt(scroll[key], `${path}.scrollContainerState.${key}`); for (const key of ["scrollX", "scrollY", "scrollWidth", "scrollHeight", "clientWidth", "clientHeight"] as const) finiteAt(scroll[key], `${path}.scrollContainerState.${key}`, key !== "scrollX" && key !== "scrollY");
  if (!Array.isArray(node.textLineRects)) schemaError(`${path}.textLineRects`, "expected array"); node.textLineRects.forEach((entry, index) => { const line = exactKeys(entry, `${path}.textLineRects[${String(index)}]`, ["lineIndex", "rect", "baseline"]); finiteAt(line.lineIndex, `${path}.textLineRects[${String(index)}].lineIndex`, true); rectAt(line.rect, `${path}.textLineRects[${String(index)}].rect`); nullableFiniteAt(line.baseline, `${path}.textLineRects[${String(index)}].baseline`); });
  nullableFiniteAt(node.intrinsicAspectRatio, `${path}.intrinsicAspectRatio`);
}

function validateMeasuredLayoutEvidence(evidence: MeasuredLayoutCellEvidence, matrixId: string): void {
  if (!matrixCellSchema.safeParse(evidence.cell).success) {
    schemaError("cell", "does not satisfy matrix cell schema");
  }
  const cell = objectAt(evidence.cell, "cell"); const cellId = stringAt(cell.id, "cell.id");
  const probe = exactKeys(evidence.measuredLayoutProbe, "measuredLayoutProbe", ["measuredLayoutGraph", "jitterEnvelope", "settledState"]);
  const graph = exactKeys(probe.measuredLayoutGraph, "measuredLayoutGraph", ["version", "cellId", "coordinateSpace", "normalizedAtRest", "viewport", "nodes", "edges"]);
  if (graph.version !== MEASURED_LAYOUT_DETECTOR_VERSION) schemaError("measuredLayoutGraph.version", "unsupported version"); if (graph.cellId !== cellId) schemaError("measuredLayoutGraph.cellId", "must match cell.id"); enumAt(graph.coordinateSpace, "measuredLayoutGraph.coordinateSpace", ["css-pixels"]); if (graph.normalizedAtRest !== true) schemaError("measuredLayoutGraph.normalizedAtRest", "must be true");
  const viewport = exactKeys(graph.viewport, "measuredLayoutGraph.viewport", ["width", "height", "clientWidth", "clientHeight", "deviceScaleFactor", "scrollX", "scrollY"]); for (const key of ["width", "height", "clientWidth", "clientHeight", "deviceScaleFactor"] as const) finiteAt(viewport[key], `measuredLayoutGraph.viewport.${key}`, true); finiteAt(viewport.scrollX, "measuredLayoutGraph.viewport.scrollX"); finiteAt(viewport.scrollY, "measuredLayoutGraph.viewport.scrollY"); if (viewport.scrollX !== 0 || viewport.scrollY !== 0) schemaError("measuredLayoutGraph.viewport", "must be at rest");
  if (!Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) schemaError("measuredLayoutGraph", "nodes and edges must be arrays"); const identities = new Set<string>(); graph.nodes.forEach((node, index) => { validateNode(node, `measuredLayoutGraph.nodes[${String(index)}]`); const identity = (node as { identity: string }).identity; if (identities.has(identity)) schemaError(`measuredLayoutGraph.nodes[${String(index)}].identity`, "must be unique"); identities.add(identity); });
  graph.nodes.forEach((entry, index) => { const node = objectAt(entry, `measuredLayoutGraph.nodes[${String(index)}]`); for (const field of ["parent", "containingBlock", "compositeAncestor", "svgRoot"] as const) graphIdentityAt(node[field], `measuredLayoutGraph.nodes[${String(index)}].${field}`, identities, true); });
  graph.edges.forEach((entry, index) => { const path = `measuredLayoutGraph.edges[${String(index)}]`; const edge = exactKeys(entry, path, ["source", "target", "relation", "axis", "layoutOrder", "signedClearance", "perpendicularOverlap", "logicalEdgeOffsets", "interveningPaintedNodeIdentities"]); graphIdentityAt(edge.source, `${path}.source`, identities); graphIdentityAt(edge.target, `${path}.target`, identities); enumAt(edge.relation, `${path}.relation`, ["sibling", "region-adjacent", "contains", "aligned", "same-track"]); enumAt(edge.axis, `${path}.axis`, ["x", "y", "inline", "block", "horizontal", "vertical"]); finiteAt(edge.layoutOrder, `${path}.layoutOrder`, true); finiteAt(edge.signedClearance, `${path}.signedClearance`); finiteAt(edge.perpendicularOverlap, `${path}.perpendicularOverlap`, true); const offsets = exactKeys(edge.logicalEdgeOffsets, `${path}.logicalEdgeOffsets`, ["logicalStart", "logicalEnd", "blockStart", "blockEnd"]); for (const key of ["logicalStart", "logicalEnd", "blockStart", "blockEnd"] as const) finiteAt(offsets[key], `${path}.logicalEdgeOffsets.${key}`); if (!Array.isArray(edge.interveningPaintedNodeIdentities)) schemaError(`${path}.interveningPaintedNodeIdentities`, "expected array"); edge.interveningPaintedNodeIdentities.forEach((identity, identityIndex) => { graphIdentityAt(identity, `${path}.interveningPaintedNodeIdentities[${String(identityIndex)}]`, identities); }); });
  const jitter = exactKeys(probe.jitterEnvelope, "jitterEnvelope", ["sampleCount", "maxDeltaByField"]); if (!Number.isInteger(jitter.sampleCount) || (jitter.sampleCount as number) < 2) schemaError("jitterEnvelope.sampleCount", "expected integer >= 2"); const deltas = objectAt(jitter.maxDeltaByField, "jitterEnvelope.maxDeltaByField"); if (Object.keys(deltas).length === 0) schemaError("jitterEnvelope.maxDeltaByField", "must not be empty"); for (const [field, delta] of Object.entries(deltas)) { stringAt(field, `jitterEnvelope.maxDeltaByField.${field}`); finiteAt(delta, `jitterEnvelope.maxDeltaByField.${field}`, true); }
  const settled = exactKeys(probe.settledState, "settledState", ["atRest", "scrollX", "scrollY", "signal", "timedOut", "phases"]); if (settled.atRest !== true || settled.timedOut !== false || settled.scrollX !== 0 || settled.scrollY !== 0) schemaError("settledState", "must be settled at rest"); enumAt(settled.signal, "settledState.signal", ["app", "quiet-windows"]); const phases = objectAt(settled.phases, "settledState.phases"); for (const [phase, value] of Object.entries(phases)) { stringAt(phase, `settledState.phases.${phase}`); finiteAt(value, `settledState.phases.${phase}`, true); }
  const artifacts = exactKeys(evidence.measuredLayoutArtifacts, "measuredLayoutArtifacts", ["fullScreenshot"]); const screenshot = exactKeys(artifacts.fullScreenshot, "measuredLayoutArtifacts.fullScreenshot", ["relativePath", "contentHash", "mediaType", "sourceRunId", "dimensions"]); const hash = stringAt(screenshot.contentHash, "measuredLayoutArtifacts.fullScreenshot.contentHash"); if (!/^[a-f0-9]{64}$/.test(hash)) schemaError("measuredLayoutArtifacts.fullScreenshot.contentHash", "expected SHA-256 hex"); if (screenshot.mediaType !== "image/png") schemaError("measuredLayoutArtifacts.fullScreenshot.mediaType", "expected image/png"); if (screenshot.sourceRunId !== matrixId) schemaError("measuredLayoutArtifacts.fullScreenshot.sourceRunId", "must match matrix id"); const expectedPath = `cells/${cellId}/${contentAddressedFileName("measured-layout-full.png", hash)}`; if (screenshot.relativePath !== expectedPath) schemaError("measuredLayoutArtifacts.fullScreenshot.relativePath", "does not identify immutable screenshot bytes"); if (screenshot.dimensions !== undefined) { const dimensions = exactKeys(screenshot.dimensions, "measuredLayoutArtifacts.fullScreenshot.dimensions", ["width", "height"]); finiteAt(dimensions.width, "measuredLayoutArtifacts.fullScreenshot.dimensions.width", true); finiteAt(dimensions.height, "measuredLayoutArtifacts.fullScreenshot.dimensions.height", true); }
}

export function coverageEventsFromMeasuredLayoutGaps(
  gaps: CoverageGap[],
  detectors: Detector<MeasuredLayoutMatrixEvidence, Ui001EvidenceRecord>[],
): CoverageEvent[] {
  return gaps.flatMap((gap) => detectors.map((detector) => ({
    scope: { id: `${gap.reason}:${sha256Canonical({ kind: "measured-layout-coverage-gap-scope", detectorId: detector.id, gap })}`, detectorId: detector.id },
    context: { kind: "browser" as const, cell: { id: `${gap.reason}:${sha256Canonical({ kind: "measured-layout-coverage-gap-cell", gap })}` } },
    reason: gap.reason,
    witnessRefs: [{ id: `${gap.reason}:${sha256Canonical({ kind: "measured-layout-coverage-gap-witness", gap })}` }],
  })));
}

export function resolveMeasuredLayoutOutcomeCell(
  cells: readonly MeasuredLayoutCellEvidence[],
  surfaceId: string | undefined,
): MeasuredLayoutCellEvidence {
  if (surfaceId === undefined) throw new Error("measured layout detector outcome has no surface id");
  const cell = cells.find((candidate) => candidate.cell.id === surfaceId);
  if (cell === undefined) throw new Error(`unknown measured layout detector surface: ${surfaceId}`);
  return cell;
}

export function buildMeasuredLayoutMatrixEvidence(
  matrixId: string,
  cells: MeasuredLayoutCellEvidence[],
): MeasuredLayoutMatrixEvidence {
  if (matrixId.trim().length === 0) {
    throw new Error("measured layout matrix id must be non-empty");
  }
  const sortedCells = [...cells].sort((left, right) =>
    compareUnicodeScalars(left.cell.id, right.cell.id),
  );
  const identities = new Set<string>();
  for (const cell of sortedCells) {
    validateMeasuredLayoutEvidence(cell, matrixId);
    if (identities.has(cell.cell.id)) {
      throw new Error(`duplicate measured layout cell evidence: ${cell.cell.id}`);
    }
    identities.add(cell.cell.id);
  }
  return { matrixId, cells: sortedCells };
}

export async function runMeasuredLayoutMatrix(
  input: RunMeasuredLayoutMatrixInput,
): Promise<ClassifiedRun> {
  let clockTick = 0;
  const clockStartMs = Date.parse(input.clockStart);
  const clock = (): string => new Date(clockStartMs + clockTick++).toISOString();
  const detectorContext = {
    clock,
    seed: input.seed,
    capabilities: input.capabilities,
    readStores: asReadonlyKernelStores(input.stores),
  };

  const sortedCells = [...input.plan.cells].sort((left, right) =>
    compareUnicodeScalars(left.id, right.id),
  );
  const sortedDetectors = [...input.detectors].sort((left, right) =>
    compareUnicodeScalars(left.id, right.id),
  );

  const capturedCells: MeasuredLayoutCellEvidence[] = [];
  const harnessEvents: HarnessEvent[] = [];

  for (const cell of sortedCells) {
    const adapter = input.adapters?.[cell.role];
    let result: Awaited<ReturnType<typeof runMeasuredLayoutCell>>;
    try {
      result = await runMeasuredLayoutCell({
        cell,
        baseUrl: input.baseUrl,
        browser: input.browser,
        seed: input.seed,
        clockStart: input.clockStart,
        capture: input.capture,
        timeouts: input.timeouts,
        ...(adapter === undefined ? {} : { authAdapter: adapter }),
        ...(input.artifactRunDir === undefined ? {} : { artifactRunDir: input.artifactRunDir }),
        sourceRunId: input.runId,
        ...(input.redactionRules === undefined ? {} : { redactionRules: input.redactionRules }),
      });
    } catch (error) {
      harnessEvents.push(cellFailureEvent(cell, error));
      continue;
    }

    if (result.kind === "evidence") {
      input.onCellEvidence?.(result.evidence);
      capturedCells.push(result.evidence);
      continue;
    }
    harnessEvents.push(result.outcome);
  }

  const matrixEvidence = buildMeasuredLayoutMatrixEvidence(input.runId, capturedCells);
  const detectorOutcomes: DetectorOutcome[] = [];

  for (const detector of sortedDetectors) {
    const outcomes = await detector.evaluate(matrixEvidence, detectorContext);
    for (const outcome of outcomes) {
      const targetCell = resolveMeasuredLayoutOutcomeCell(capturedCells, outcome.scope.surfaceId);
      const screenshot = targetCell.measuredLayoutArtifacts.fullScreenshot;
      detectorOutcomes.push(
        toClassifierOutcome(
          outcome,
          targetCell.cell,
          screenshot.sourceRunId === undefined
            ? []
            : [
                {
                  relativePath: screenshot.relativePath,
                  mediaType: screenshot.mediaType,
                  contentHash: screenshot.contentHash,
                  redactionState: "none",
                  sourceRunId: screenshot.sourceRunId,
                  ...(screenshot.dimensions === undefined ? {} : { dimensions: screenshot.dimensions }),
                },
              ],
        ),
      );
    }
  }

  for (const cell of capturedCells) {
    await assertImmutableMeasuredLayoutScreenshot(
      cell.measuredLayoutArtifacts.fullScreenshot,
      cell.cell.id,
      input.artifactRunDir,
    );
  }

  return classify({
    detectorOutcomes,
    harnessEvents,
    coverageEvents: coverageEventsFromMeasuredLayoutGaps(input.plan.coverageGaps ?? [], sortedDetectors),
    stores: input.stores,
    runId: input.runId,
  });
}

export { runMeasuredLayoutCell } from "./capture.js";
