import { modalCluster, toleranceFromJitter } from "./partition.js";
import type {
  MeasuredLayoutCellEvidence,
  MeasuredLayoutEdge,
  MeasuredLayoutNode,
  MeasuredLayoutViolationFact,
} from "./types.js";

export type SectionRhythmCandidate = {
  cellId: string;
  source: MeasuredLayoutNode;
  target: MeasuredLayoutNode;
  edge: MeasuredLayoutEdge;
  edgeIndex: number;
  sourceIndex: number;
  targetIndex: number;
  viewportHeight: number;
  tolerance: number;
  signature: string;
};

const SECTIONING_TAGS = new Set(["article", "aside", "footer", "header", "main", "nav", "section"]);
const SECTIONING_ROLES = new Set(["article", "banner", "complementary", "contentinfo", "main", "navigation", "region"]);

function compareText(left: string, right: string): number {
  return left.localeCompare(right, "en");
}

function nodeHasArea(node: MeasuredLayoutNode): boolean {
  return node.visibility.visible
    && !node.visibility.zeroArea
    && node.borderBox.width > 0
    && node.borderBox.height > 0;
}

export function isSectionCandidate(node: MeasuredLayoutNode): boolean {
  const [tag = "", role = ""] = node.structuralSignature.split("|", 3);
  return SECTIONING_TAGS.has(tag) || SECTIONING_ROLES.has(role);
}

export function isVisibleSectionPair(candidate: Pick<SectionRhythmCandidate, "source" | "target">): boolean {
  return nodeHasArea(candidate.source) && nodeHasArea(candidate.target);
}

export function isViewportPinnedSection(candidate: Pick<SectionRhythmCandidate, "source" | "target">): boolean {
  return candidate.source.viewportPinned
    || candidate.target.viewportPinned
    || candidate.source.position === "sticky"
    || candidate.source.position === "fixed"
    || candidate.target.position === "sticky"
    || candidate.target.position === "fixed";
}

export function isContainedOrOverlappingSectionPair(candidate: Pick<SectionRhythmCandidate, "source" | "target" | "edge">): boolean {
  const { source, target } = candidate;
  const sourceBottom = source.borderBox.y + source.borderBox.height;
  const targetBottom = target.borderBox.y + target.borderBox.height;
  const contains = (source.borderBox.y <= target.borderBox.y && sourceBottom >= targetBottom)
    || (target.borderBox.y <= source.borderBox.y && targetBottom >= sourceBottom);
  return contains || candidate.edge.signedClearance <= 0;
}

export function hasInterveningPaintedRegion(candidate: Pick<SectionRhythmCandidate, "edge">): boolean {
  return candidate.edge.interveningPaintedNodeIdentities.length > 0;
}

export function isFirstOrLastViewportEdge(candidate: Pick<SectionRhythmCandidate, "source" | "target" | "viewportHeight">): boolean {
  return candidate.source.borderBox.y === 0
    || candidate.target.borderBox.y + candidate.target.borderBox.height === candidate.viewportHeight;
}

export function isConsecutiveVerticalSectionPair(candidate: Pick<SectionRhythmCandidate, "edge">): boolean {
  return (candidate.edge.axis === "y" || candidate.edge.axis === "vertical" || candidate.edge.axis === "block")
    && (candidate.edge.relation === "sibling" || candidate.edge.relation === "region-adjacent");
}

function sectionOrder(left: MeasuredLayoutNode, right: MeasuredLayoutNode): number {
  return left.borderBox.y - right.borderBox.y
    || left.borderBox.x - right.borderBox.x
    || compareText(left.identity, right.identity);
}

export function isConsecutiveSectionCandidatePair(
  candidate: Pick<SectionRhythmCandidate, "source" | "target">,
  sections: readonly MeasuredLayoutNode[],
): boolean {
  const sourceIndex = sections.findIndex((node) => node.identity === candidate.source.identity);
  return sourceIndex >= 0 && sections[sourceIndex + 1]?.identity === candidate.target.identity;
}

function sectionPairSignature(source: MeasuredLayoutNode, target: MeasuredLayoutNode): string {
  return JSON.stringify([source.structuralSignature, target.structuralSignature]);
}

function nodeJitterField(nodeIndex: number, field: "y" | "height"): string {
  return `nodes[${String(nodeIndex)}].borderBox.${field}`;
}

function clearanceTolerance(cell: MeasuredLayoutCellEvidence, sourceIndex: number, targetIndex: number): number {
  const envelope = cell.measuredLayoutProbe.jitterEnvelope;
  return toleranceFromJitter(envelope, nodeJitterField(targetIndex, "y"))
    + toleranceFromJitter(envelope, nodeJitterField(sourceIndex, "y"))
    + toleranceFromJitter(envelope, nodeJitterField(sourceIndex, "height"));
}

function candidatesForCell(cell: MeasuredLayoutCellEvidence): SectionRhythmCandidate[] {
  const graph = cell.measuredLayoutProbe.measuredLayoutGraph;
  const nodes = new Map(graph.nodes.map((node, index) => [node.identity, { node, index }]));
  const sections = graph.nodes.filter(isSectionCandidate).sort(sectionOrder);
  const candidates = graph.edges.flatMap((edge, edgeIndex) => {
    const source = nodes.get(edge.source);
    const target = nodes.get(edge.target);
    if (source === undefined || target === undefined) {
      throw new Error(`section rhythm edge ${String(edgeIndex)} identifies an unknown node`);
    }
    const candidate: SectionRhythmCandidate = {
      cellId: cell.cell.id,
      source: source.node,
      target: target.node,
      edge,
      edgeIndex,
      sourceIndex: source.index,
      targetIndex: target.index,
      viewportHeight: graph.viewport.height,
      tolerance: clearanceTolerance(cell, source.index, target.index),
      signature: sectionPairSignature(source.node, target.node),
    };
    return isSectionCandidate(source.node)
      && isSectionCandidate(target.node)
      && isConsecutiveSectionCandidatePair(candidate, sections)
      && isConsecutiveVerticalSectionPair(candidate)
      && isVisibleSectionPair(candidate)
      && !isViewportPinnedSection(candidate)
      && !isContainedOrOverlappingSectionPair(candidate)
      && !hasInterveningPaintedRegion(candidate)
      && !isFirstOrLastViewportEdge(candidate)
      ? [candidate]
      : [];
  });
  const unique = new Map<string, SectionRhythmCandidate>();
  for (const candidate of candidates.sort((left, right) =>
    compareText(candidateIdentity(left), candidateIdentity(right))
    || compareText(left.edge.relation, right.edge.relation)
    || left.edgeIndex - right.edgeIndex,
  )) {
    unique.set(candidateIdentity(candidate), candidate);
  }
  return [...unique.values()];
}

function candidateIdentity(candidate: SectionRhythmCandidate): string {
  return JSON.stringify([candidate.cellId, candidate.source.identity, candidate.target.identity]);
}

function findingFor(candidate: SectionRhythmCandidate, modalValue: number, peerIdentities: string[]): MeasuredLayoutViolationFact {
  return {
    ruleId: "UI-097",
    kind: "section-spacing-rhythm-outlier",
    cellId: candidate.cellId,
    locator: `${candidate.source.identity}->${candidate.target.identity}`,
    blamedIdentity: candidate.target.identity,
    summary: "Section clearance differs from the observed repeated rhythm.",
    measurement: {
      rawMeasurement: candidate.edge.signedClearance,
      modalValue,
      peerIdentities,
      cellId: candidate.cellId,
      sourceIdentity: candidate.source.identity,
      targetIdentity: candidate.target.identity,
      adjacentPairSignature: candidate.signature,
      tolerance: candidate.tolerance,
    },
  };
}

export function detectSectionRhythm(matrix: { cells: readonly MeasuredLayoutCellEvidence[] }): MeasuredLayoutViolationFact[] {
  const byCellAndSignature = new Map<string, SectionRhythmCandidate[]>();
  for (const cell of [...matrix.cells].sort((left, right) => compareText(left.cell.id, right.cell.id))) {
    for (const candidate of candidatesForCell(cell)) {
      const key = JSON.stringify([candidate.cellId, candidate.signature]);
      const group = byCellAndSignature.get(key) ?? [];
      group.push(candidate);
      byCellAndSignature.set(key, group);
    }
  }

  const findings: MeasuredLayoutViolationFact[] = [];
  for (const candidates of [...byCellAndSignature.entries()].sort(([left], [right]) => compareText(left, right)).map(([, group]) => group)) {
    const ordered = [...candidates].sort((left, right) => compareText(candidateIdentity(left), candidateIdentity(right)));
    if (ordered.length < 2) continue;
    const tolerance = Math.max(...ordered.map((candidate) => candidate.tolerance));
    const modal = modalCluster(ordered.map((candidate) => candidate.edge.signedClearance), tolerance);
    if (modal === undefined) continue;
    const memberIndexes = new Set(modal.memberIndexes);
    const peers = modal.memberIndexes
      .map((index) => ordered[index])
      .filter((candidate): candidate is SectionRhythmCandidate => candidate !== undefined)
      .map(candidateIdentity)
      .sort(compareText);
    for (const [index, candidate] of ordered.entries()) {
      if (!memberIndexes.has(index) && Math.abs(candidate.edge.signedClearance - modal.modalValue) > candidate.tolerance) {
        findings.push(findingFor(candidate, modal.modalValue, peers));
      }
    }
  }
  return findings.sort((left, right) => compareText(JSON.stringify([left.locator, left.measurement.rawMeasurement]), JSON.stringify([right.locator, right.measurement.rawMeasurement])));
}
