import type {
  VrtDiff,
  DiffRegion,
  VisualSemanticChange,
  VisualSemanticDiff,
  VisualChangeType,
} from "@mizchi/vlmkit-core/types.ts";
import {
  classifyRegionPolicy,
  layoutShiftGroupThreshold,
} from "./markup-core-visual-semantic.ts";

/**
 * Convert VRT pixel diff into visual semantics.
 *
 * Analyzes each diff region to infer what changed:
 * - text-change: text area change (wide, small region)
 * - color-change: color-only change (same shape)
 * - layout-shift: element repositioning (large region, correlated diffs)
 * - element-added/removed: new/removed element
 * - icon-change: small square region
 */
export function classifyVisualDiff(diff: VrtDiff): VisualSemanticDiff {
  const changes: VisualSemanticChange[] = [];

  for (const region of diff.regions) {
    const classified = classifyRegion(region, diff);
    changes.push(classified);
  }

  const grouped = groupLayoutShifts(changes);

  return {
    testId: diff.snapshot.testId,
    changes: grouped,
    summary: summarizeChanges(grouped),
  };
}

function classifyRegion(region: DiffRegion, diff: VrtDiff): VisualSemanticChange {
  // A measured translation beats the shape/color heuristics: a moved
  // element samples near-identical colors on both sides, which the
  // policy would misread as element-added (A/B validation v1/v2).
  if (region.shift && (region.shift.dx !== 0 || region.shift.dy !== 0)) {
    // A vertical-dominant translation is a reflow: a rule above the region
    // changed its block height and pushed everything below it down (A/B v1
    // draft 04). Horizontal-dominant movement keeps the generic layout-shift
    // label, since reflow specifically means the vertical document flow moved.
    const s = region.shift;
    const isReflow = s.dy !== 0 && Math.abs(s.dy) >= Math.abs(s.dx);
    return {
      type: isReflow ? "reflow" : "layout-shift",
      region,
      confidence: s.confidence,
      description: describeShift(region, isReflow),
    };
  }

  // Shape says "wide band = shift", but if the pixels recolored in place
  // (large color delta, no measured movement) the shape hint is wrong —
  // a full-width background recolor has the same silhouette.
  const recoloredInPlace = !region.shift
    && region.colorSample !== undefined
    && region.colorSample.distance >= 24;

  const policy = classifyRegionPolicy({
    regionType: region.regionType === "shift" && !recoloredInPlace ? "shift" : undefined,
    width: region.width,
    height: region.height,
    diffPixelCount: region.diffPixelCount,
    totalPixels: diff.totalPixels,
    colorSample: region.colorSample,
  });

  return {
    type: policy.type,
    region,
    confidence: policy.confidence,
    description: describeChange(policy.type, region, diff),
  };
}

function describeChange(
  type: VisualChangeType,
  region: DiffRegion,
  diff: VrtDiff,
): string {
  const area = region.width * region.height;
  const density = area > 0 ? region.diffPixelCount / area : 0;
  const globalRatio = region.diffPixelCount / diff.totalPixels;
  const where = `(${region.x}, ${region.y})`;
  const dims = `${region.width}x${region.height}`;
  switch (type) {
    case "layout-shift":
      // A measured translation returns early via describeShift; reaching here
      // means the label is shape-derived, so say so explicitly — an agent
      // reading "layout-shift" otherwise expects a dx/dy that was never
      // measured (A/B v3 draft 12).
      if (region.regionType === "shift") {
        return `Layout shift region hint at ${where}, ${dims} (wide-band shape; no translation measured — likely reflow or in-place change)`;
      }
      return `Layout shift at ${where}, ${dims}, ${(globalRatio * 100).toFixed(1)}% of total (shape-derived; no translation measured — likely reflow or in-place change)`;
    case "reflow":
      // A measured reflow returns early via describeShift; this defensive
      // branch keeps the switch exhaustive over VisualChangeType.
      return region.shift
        ? describeShift(region, true)
        : `Vertical reflow at ${where}, ${dims}`;
    case "icon-change":
      return `Small square region changed at ${where}`;
    case "text-change":
      return `Text-like region changed at ${where}, ${dims}${formatColorSample(region)}`;
    case "color-change":
      return `Color change in region ${where}, ${dims}, ${(density * 100).toFixed(0)}% density${formatColorSample(region)}`;
    case "element-added":
      if (region.colorSample) {
        return `Element appeared at ${where}, ${dims}${formatColorSample(region)}`;
      }
      if (density > 0.5 && area > 1024) {
        return `New element appeared at ${where}, ${dims}`;
      }
      return `Change at ${where}, ${dims}`;
    case "element-removed":
      return `Element disappeared at ${where}, ${dims}${formatColorSample(region)}`;
  }
}

function describeShift(region: DiffRegion, isReflow = false): string {
  const shift = region.shift!;
  const fmt = (v: number) => (v >= 0 ? `+${v}` : String(v));
  const offset = `${fmt(shift.dx)}, ${fmt(shift.dy)}`;
  const recolor = region.colorSample && region.colorSample.distance >= 24
    ? `${formatColorSample(region)} (also recolored)`
    : "";
  const lead = isReflow
    ? `Vertical reflow: content below shifted by ${fmt(shift.dy)}px`
    : `Content translated by (${offset}) px`;
  return `${lead} at (${region.x}, ${region.y}), ${region.width}x${region.height}${recolor}`;
}

function formatColorSample(region: DiffRegion): string {
  if (!region.colorSample) return "";
  // Prefer the peak pixel pair when present: on sparse changes (thin glyphs,
  // a minority recolor) the region mean is muddied by antialiasing blends and
  // names a useless blend color, while the peak recovers the glyph/edge core
  // (A/B v3 draft 11). On dense changes peak is absent and we use the mean.
  const pair = region.colorSample.peak ?? region.colorSample;
  // Identical samples carry zero information — the usual signature of a
  // position shift over a shared background. Omit rather than mislead.
  if (pair.baseline.hex === pair.current.hex) return "";
  return `, ${pair.baseline.hex} -> ${pair.current.hex}`;
}

/**
 * Group layout-shifts with close Y coordinates.
 * Multiple regions shifting on the same row = one layout shift.
 */
function groupLayoutShifts(
  changes: VisualSemanticChange[],
): VisualSemanticChange[] {
  const layoutShifts = changes.filter((c) => c.type === "layout-shift");
  const others = changes.filter((c) => c.type !== "layout-shift");

  if (layoutShifts.length <= 1) return changes;

  layoutShifts.sort((a, b) => a.region.y - b.region.y);

  const groupThreshold = layoutShiftGroupThreshold();
  const groups: VisualSemanticChange[][] = [];
  let currentGroup: VisualSemanticChange[] = [layoutShifts[0]];

  for (let i = 1; i < layoutShifts.length; i++) {
    const prev = currentGroup[currentGroup.length - 1];
    const curr = layoutShifts[i];
    if (Math.abs(curr.region.y - prev.region.y) < groupThreshold) {
      currentGroup.push(curr);
    } else {
      groups.push(currentGroup);
      currentGroup = [curr];
    }
  }
  groups.push(currentGroup);

  const merged = groups.map((group): VisualSemanticChange => {
    if (group.length === 1) return group[0];

    const minX = Math.min(...group.map((c) => c.region.x));
    const minY = Math.min(...group.map((c) => c.region.y));
    const maxX = Math.max(...group.map((c) => c.region.x + c.region.width));
    const maxY = Math.max(...group.map((c) => c.region.y + c.region.height));
    const totalDiff = group.reduce(
      (sum, c) => sum + c.region.diffPixelCount,
      0,
    );

    return {
      type: "layout-shift",
      region: {
        x: minX,
        y: minY,
        width: maxX - minX,
        height: maxY - minY,
        diffPixelCount: totalDiff,
      },
      confidence: Math.max(...group.map((c) => c.confidence)),
      description: `Layout shift spanning ${group.length} regions at y=${minY}-${maxY}`,
    };
  });

  return [...others, ...merged];
}

function summarizeChanges(changes: VisualSemanticChange[]): string {
  const byType = new Map<VisualChangeType, number>();
  for (const c of changes) {
    byType.set(c.type, (byType.get(c.type) ?? 0) + 1);
  }

  const parts: string[] = [];
  for (const [type, count] of byType) {
    parts.push(`${count} ${type}`);
  }
  return parts.join(", ") || "no changes";
}
