/**
 * Wireframe-mode fix candidates.
 *
 * When DOM correspondence between baseline and variant is missing
 * (class renames / from-screenshot reproduction / migration to a
 * different markup style), the existing `MigrationFixCandidate`
 * generator can't say much: it scores CSS declarations from the
 * variant's own stylesheet against a coarse `dominantCategory`. The
 * 2026-05-15 design-md scenario report flagged this as the single
 * biggest "Fix Candidates: no suggestions" complaint from agents.
 *
 * This module operates from image-only signals (`MatchedBbox`,
 * `MatchedTextRow`) that are available regardless of DOM
 * correspondence, and — when a `DesignTokens` source is provided —
 * snaps the suggested px deltas to the nearest declared spacing
 * token. Output is a list of human-readable hypotheses with a
 * confidence indicator so agents can rank them.
 *
 * Pure module; no Playwright or filesystem.
 */

import type { MatchedBbox } from "@mizchi/vlmkit-markup/component/component-bbox.ts";
import type { MatchedTextRow } from "@mizchi/vlmkit-core/text-rows.ts";
import { snapSpacing, parseLengthToPx, type DesignTokens } from "./design-md-tokens.ts";
import type { DpEntryWithViewport } from "@mizchi/vlmkit-core/dom-position-styles.ts";

export interface WireframeFixSuggestion {
  /** What we observed in the image diff. */
  evidence: string;
  /** What property family / direction is likely responsible. */
  hypothesis: string;
  /** Concrete next action — token name when snappable. */
  suggestion: string;
  /** Viewports where this delta was observed. */
  viewports: string[];
  /** Heuristic confidence: low (single viewport, small delta),
   * medium (multi-viewport or large delta), high (multi-viewport + token-snapped). */
  confidence: "low" | "medium" | "high";
  /** Magnitude in px (positive for the canonical direction described). */
  deltaPx: number;
  /**
   * Set when this suggestion's magnitude dominates the rest of the
   * suggestion set — i.e. closing this one delta would account for
   * most of the visual diff. Lets the renderer pull it above the
   * [DIVERGENT] / [SUBSET] noise so agents act on the biggest win
   * first.
   *
   * Heuristic: |deltaPx| ≥ 12 (large enough to matter) AND
   * ≥ 1.5× the next-largest |deltaPx| in the set. Computed in a
   * single pass after the per-rank generation completes; at most
   * one suggestion per run carries the flag.
   */
  isHighImpact?: boolean;
  /**
   * How this delta relates to the full viewport set:
   *   - "all"               : observation covers every viewport with the same
   *                           sign+magnitude. Suggestion is safe to apply
   *                           globally.
   *   - "subset"            : observation only covers a subset of viewports.
   *                           The agent should gate the change with a media
   *                           query so the uncovered viewports aren't
   *                           affected.
   *   - "divergent"         : the same component has observations with
   *                           opposite signs on different viewports. A global
   *                           edit cannot satisfy both — the underlying CSS
   *                           rule is responsive and the per-viewport values
   *                           need separate handling.
   *   - "magnitude-divergent": same-sign but materially different magnitudes
   *                           across viewports (e.g. -24px on mobile, -48px on
   *                           desktop). Surfaces "the baseline uses distinct
   *                           per-viewport values; the variant uses one
   *                           value everywhere" — a global edit will fix
   *                           one viewport while under- or over-correcting
   *                           the others.
   */
  scope: "all" | "subset" | "divergent" | "magnitude-divergent" | "structural" | "reflow";
  /**
   * Per-viewport breakdown. Always populated; for non-divergent rows
   * every entry has the same sign.
   */
  perViewport?: Array<{ viewport: string; deltaPx: number }>;
  /**
   * Candidate CSS rule(s) whose computed-style delta matches this
   * suggestion's magnitude (within ±2px) on the affected viewports.
   * Sourced from `domPositionDiff.entries`. Empty when no DOM
   * correspondence is available or no entry matched — agents should
   * then fall back to their own structural search.
   *
   * The shape is deliberately action-oriented: `current` is what the
   * variant has now, `target` is what the baseline has. Reading
   * "current → target" matches the natural agent action ("change
   * from current to target"). An earlier version used
   * `{baselineValue, variantValue}` rendered as `B→V`, which agent-e
   * (v5 validation) misread as "go from B to V" and applied the
   * change in the wrong direction.
   */
  candidates?: Array<{
    selector: string;
    property: string;
    current: string;
    target: string;
    viewport: string;
    /**
     * The DOM path the candidate was sourced from
     * (e.g. `body[0]>main[0]>section[1]`). Used to detect
     * shared-parent clusters for the structural suggestion (F1) —
     * not surfaced in the renderer, but kept on the JSON so
     * downstream tooling can group candidates.
     */
    path?: string;
    /**
     * Set when the candidate's property mutates an element's box
     * size and therefore pushes its later siblings around — height,
     * min-height, max-height, padding-bottom, margin-bottom.
     * Renderer adds a `[cascade]` hint so agents understand why a
     * fix on this element resolves a positional delta on a
     * different (downstream) component. Closes F2.
     */
    cascades?: boolean;
  }>;
}

export interface WireframeFixInput {
  bboxByViewport: Array<{ viewport: string; matches: MatchedBbox[] }>;
  textRowsByViewport: Array<{
    viewport: string;
    matches: MatchedTextRow[];
    /**
     * Total dark-band rows detected on each side, before pairing.
     * Used by the [REFLOW] detector — when the variant has more
     * rows than the baseline on the narrow viewport, text wrapped.
     * Optional for backward compatibility; reflow detection
     * silently skips when absent.
     */
    baselineRowCount?: number;
    variantRowCount?: number;
  }>;
  tokens?: DesignTokens;
  /**
   * Full viewport set the compare ran on (e.g. ["mobile", "desktop", "wide"]).
   * Required for correct scope detection: when a viewport had zero
   * meaningful bbox or text-row deltas it won't appear in
   * `bboxByViewport` / `textRowsByViewport`, so deriving the universe
   * from those alone produces false "scope: all" tags. Pass the
   * authoritative list here. Falls back to the input-derived set if
   * omitted (preserves test compatibility).
   */
  allViewports?: string[];
  /**
   * DOM-position-diff entries (one per (path, property, viewport)).
   * When provided, each generated suggestion is annotated with the
   * candidate CSS rule(s) whose computed-style delta matches its
   * magnitude — agent-c lost two rounds because the magnitude was
   * named but the *rule* wasn't. Empty / undefined for callers
   * without DOM correspondence; the generator degrades gracefully.
   */
  domPositionEntries?: DpEntryWithViewport[];
}

/**
 * CSS properties whose computed-style delta plausibly produces a
 * vertical bbox / text-row shift. Used to filter `domPositionDiff`
 * entries when annotating candidate selectors. Padding-bottom /
 * margin-bottom belong here because *predecessor* elements push the
 * element below them down; if their bottom-spacing changed it would
 * show up on the next sibling's bbox Δtop.
 */
const VERTICAL_SHIFT_PROPERTIES = new Set([
  "margin-top",
  "padding-top",
  "margin-bottom",
  "padding-bottom",
  "row-gap",
  "gap",
  "height",
  "min-height",
  "max-height",
  "top",
  "bottom",
]);

/**
 * Properties whose change physically pushes later siblings — a fix
 * on element X cascades to fix a positional delta on a downstream
 * element Y. Surfaced to the renderer as `[cascade]` so agents see
 * why a candidate on a different selector resolves the suggestion.
 */
const CASCADING_PROPERTIES = new Set([
  "height",
  "min-height",
  "max-height",
  "margin-bottom",
  "padding-bottom",
]);

const CANDIDATE_PX_TOLERANCE = 2;

function selectorFromDpEntry(entry: DpEntryWithViewport): string {
  // Prefer the variant's class string (that's what the agent currently
  // has in their CSS); fall back to baseline if variant has none.
  const cls = entry.variantClasses || entry.baselineClasses;
  if (cls) return `.${cls.split(/\s+/).filter(Boolean).join(".")}`;
  // No class — name by tag + path.
  return `${entry.tag} (${entry.path})`;
}

function matchCandidatesForDelta(
  deltaPx: number,
  viewports: string[],
  entries: DpEntryWithViewport[],
): WireframeFixSuggestion["candidates"] {
  const viewportSet = new Set(viewports);
  const target = Math.abs(deltaPx);
  const out: NonNullable<WireframeFixSuggestion["candidates"]> = [];
  const seen = new Set<string>();
  for (const e of entries) {
    if (!viewportSet.has(e.viewport)) continue;
    if (!VERTICAL_SHIFT_PROPERTIES.has(e.property)) continue;
    const bPx = parseLengthToPx(e.baseline);
    const vPx = parseLengthToPx(e.variant);
    if (bPx === null || vPx === null) continue;
    const entryDelta = Math.abs(vPx - bPx);
    if (Math.abs(entryDelta - target) > CANDIDATE_PX_TOLERANCE) continue;
    const sel = selectorFromDpEntry(e);
    const dedupeKey = `${sel}|${e.property}|${e.viewport}`;
    if (seen.has(dedupeKey)) continue;
    seen.add(dedupeKey);
    out.push({
      selector: sel,
      property: e.property,
      // Variant = what the agent currently has; baseline = target.
      // Order matches "change FROM current TO target" semantics.
      current: e.variant,
      target: e.baseline,
      viewport: e.viewport,
      path: e.path,
      cascades: CASCADING_PROPERTIES.has(e.property),
    });
    if (out.length >= 4) break; // Cap per suggestion — too many candidates is noise.
  }
  return out.length > 0 ? out : undefined;
}

/** Threshold below which a delta is considered subpixel / not actionable. */
const NEGLIGIBLE_DELTA_PX = 2;

export function generateWireframeFixCandidates(
  input: WireframeFixInput,
): WireframeFixSuggestion[] {
  const out: WireframeFixSuggestion[] = [];

  // ---- Bbox vertical shifts ----
  // For each component rank, collect Δtop across viewports. The
  // generator emits one of three shapes per rank:
  //   - divergent: opposite signs across viewports → one row that
  //     names the responsive divergence and tells the agent to gate
  //     with a media query. (Closes #29: agent-c saw +12 mobile / -12
  //     desktop as two separate global suggestions and broke desktop
  //     trying to satisfy both.)
  //   - subset: same sign everywhere but only covers some viewports
  //     → the suggestion explicitly says "mobile only" / "desktop,
  //     wide only" so the agent knows it isn't global.
  //   - all: every viewport in the input agrees on sign + magnitude
  //     → safe to apply globally.
  const allViewports = new Set(
    input.allViewports && input.allViewports.length > 0
      ? input.allViewports
      : input.bboxByViewport.map((v) => v.viewport),
  );
  const bboxByRank = new Map<number, Array<{ viewport: string; m: MatchedBbox }>>();
  for (const v of input.bboxByViewport) {
    for (const m of v.matches) {
      if (Math.abs(m.deltaTop) <= NEGLIGIBLE_DELTA_PX) continue;
      const arr = bboxByRank.get(m.rank) ?? [];
      arr.push({ viewport: v.viewport, m });
      bboxByRank.set(m.rank, arr);
    }
  }

  for (const [rank, observations] of bboxByRank) {
    // Per-viewport median deltaTop for this rank.
    const byVp = new Map<string, MatchedBbox[]>();
    for (const obs of observations) {
      const arr = byVp.get(obs.viewport) ?? [];
      arr.push(obs.m);
      byVp.set(obs.viewport, arr);
    }
    const perVpDeltas: Array<{ viewport: string; deltaPx: number; m: MatchedBbox }> = [];
    for (const [viewport, ms] of byVp) {
      const sorted = ms.map((m) => m.deltaTop).sort((a, b) => a - b);
      const median = sorted[Math.floor(sorted.length / 2)];
      perVpDeltas.push({ viewport, deltaPx: median, m: ms[0] });
    }
    const signs = new Set(perVpDeltas.map((p) => Math.sign(p.deltaPx)).filter((s) => s !== 0));
    const example = perVpDeltas[0].m;
    const dims = `${example.baseline.width}×${example.baseline.height}`;
    // V9b: intrinsic-height hint. When the component itself is
    // noticeably taller/shorter in baseline vs variant, the cause
    // is likely the component's internal sizing (padding, font-size,
    // min-height) — NOT spacing on/around it. Agent-i v9 chased a
    // "missing 42px" as spacing when the cards were intrinsically
    // shorter. Surface the bbox-height delta when material.
    const INTRINSIC_HEIGHT_DELTA_PX = 8;
    const heightDelta = example.variant.height - example.baseline.height;
    const intrinsicHint = Math.abs(heightDelta) >= INTRINSIC_HEIGHT_DELTA_PX
      ? ` ⚠ component height differs intrinsically: ${example.variant.height}px (now) → ${example.baseline.height}px (target). The shift may NOT be a spacing-token issue — check this component's own padding / min-height / font-size before adding margin upstream.`
      : "";

    // Magnitudes per viewport for this rank.
    const magnitudes = perVpDeltas.map((p) => Math.abs(p.deltaPx));
    const maxMag = Math.max(...magnitudes);
    const minMag = Math.min(...magnitudes);

    // REFLOW / CASCADE detection (closes #33; broadened post-#33
    // after agent-h v8 found the original row-count gate too
    // strict). Fires when one viewport's magnitude is ≥ 3× the
    // others AND EITHER:
    //   (a) that viewport has more dark-band text rows than the
    //       baseline → high-confidence typographic-wrap cascade
    //   (b) the magnitude is large enough (≥ 60px) that it can't
    //       plausibly be a single spacing token on this element →
    //       likely an upstream-cascade victim regardless of cause
    //
    // Either way the agent shouldn't try to fix this with spacing
    // tokens on this component — the magnitude originates upstream
    // (text wrap, missing element, oversized neighbor).
    const REFLOW_ASYMMETRY = 3; // max-mag must be ≥ 3× min-mag
    const REFLOW_MIN_MAG_PX = 32; // ignore small-asymmetry noise
    const LARGE_MAG_PX = 60; // mag ≥ this implies multi-line / cascade
    if (maxMag >= REFLOW_MIN_MAG_PX && minMag > 0 && maxMag / minMag >= REFLOW_ASYMMETRY) {
      const dominantVp = perVpDeltas.reduce((best, p) =>
        Math.abs(p.deltaPx) > Math.abs(best.deltaPx) ? p : best,
      perVpDeltas[0]);
      const tr = input.textRowsByViewport.find((t) => t.viewport === dominantVp.viewport);
      const baselineRows = tr?.baselineRowCount;
      const variantRows = tr?.variantRowCount;
      const extraRows = baselineRows !== undefined && variantRows !== undefined
        ? variantRows - baselineRows
        : 0;
      const wrapConfirmed = extraRows >= 1;
      const cascadeLikely = Math.abs(dominantVp.deltaPx) >= LARGE_MAG_PX;
      if (wrapConfirmed || cascadeLikely) {
        const summary = perVpDeltas
          .map((p) => `${p.viewport}: ${signed(p.deltaPx)}`)
          .join(", ");
        // Two flavors of suggestion text depending on which gate fired.
        const evidenceTail = wrapConfirmed
          ? `${dominantVp.viewport} variant has +${extraRows} text row${extraRows === 1 ? "" : "s"} above this component vs baseline`
          : `${Math.abs(dominantVp.deltaPx)}px shift on ${dominantVp.viewport} is ≥ ${LARGE_MAG_PX}px — too large to plausibly be a spacing token on this component`;
        const suggestionText = wrapConfirmed
          ? `typography cascade on ${dominantVp.viewport}: inspect headline/paragraph max-width, font-size at that breakpoint, and text content length. NOT a spacing-token fix — adjusting margin/padding will just move other elements out of place.`
          : `upstream cascade — the shift originates above this component on ${dominantVp.viewport}. Look at: previous siblings' heights / line-counts / max-widths at this breakpoint. Applying a ${Math.abs(dominantVp.deltaPx)}px spacing token to THIS element will not close the delta and will probably break other viewports.`;
        out.push({
          evidence: `component rank=${rank} (bbox ${dims}): asymmetric Δtop (${summary}); ${evidenceTail}`,
          hypothesis: wrapConfirmed
            ? `text wrapping on ${dominantVp.viewport} pushed this component down by ~${extraRows} line-height(s). Spacing tokens won't close the delta; the fix is upstream of the wrap.`
            : `this component is a cascade victim, not the source of the delta. Spacing tokens applied here will overshoot or break siblings.`,
          suggestion: suggestionText,
          viewports: perVpDeltas.map((p) => p.viewport),
          confidence: "high",
          deltaPx: Math.abs(dominantVp.deltaPx),
          scope: "reflow",
          perViewport: perVpDeltas.map((p) => ({ viewport: p.viewport, deltaPx: p.deltaPx })),
        });
        continue;
      }
    }

    // Magnitude-divergent: same sign across all viewports, but the
    // smallest and largest deltas are clearly distinct (>= 8px apart).
    // Distinct enough that one global value can't satisfy both ends.
    const MIN_MAGNITUDE_SPREAD = 8;
    if (signs.size === 1 && maxMag - minMag >= MIN_MAGNITUDE_SPREAD) {
      const summary = perVpDeltas
        .map((p) => `${p.viewport}: ${signed(p.deltaPx)}`)
        .join(", ");
      const direction = perVpDeltas[0].deltaPx > 0 ? "reducing" : "adding";
      const viewportList = perVpDeltas.map((p) => p.viewport);
      // Per-viewport token snapping for each magnitude.
      const perVpSnap = perVpDeltas.map((p) => {
        const snap = input.tokens ? snapSpacing(input.tokens, Math.abs(p.deltaPx)) : null;
        return {
          viewport: p.viewport,
          deltaPx: p.deltaPx,
          tokenHint: snap ? `${snap.token.name} (${snap.token.raw})` : `${Math.abs(p.deltaPx)}px`,
        };
      });
      const candidates = input.domPositionEntries
        ? matchCandidatesForDelta(maxMag, viewportList, input.domPositionEntries)
        : undefined;
      // F3: predictive overshoot. If the agent applies the LARGEST
      // magnitude globally (a common mistake when reading
      // MAG-DIVERGENT), what overshoot would the other viewports
      // suffer? Surface that explicitly on the same line so the
      // agent doesn't have to compute it. Deterministic, no agent-
      // intent modeling — just "if you took X globally, here's the
      // damage on Y".
      const dominantMag = Math.max(...perVpDeltas.map((p) => Math.abs(p.deltaPx)));
      const overshoots = perVpDeltas
        .filter((p) => Math.abs(p.deltaPx) < dominantMag - 2) // 2px tol
        .map((p) => ({
          viewport: p.viewport,
          residual: dominantMag - Math.abs(p.deltaPx),
        }));
      const overshootHint = overshoots.length > 0
        ? ` ⚠ applying ${dominantMag}px globally would overshoot ${overshoots
            .map((o) => `${o.viewport} by ${o.residual}px`).join(", ")}`
        : "";
      out.push({
        evidence: `component rank=${rank} (bbox ${dims}): magnitude-divergent Δtop across viewports (${summary})`,
        hypothesis: "baseline uses distinct per-viewport spacing values; variant uses one value everywhere — a global edit will over- or under-correct depending on viewport",
        suggestion: `use distinct per-viewport spacing values: ${perVpSnap
          .map((p) => `${direction} ${Math.abs(p.deltaPx)}px on ${p.viewport} (token: ${p.tokenHint})`).join("; ")}${overshootHint}${intrinsicHint}`,
        viewports: viewportList,
        confidence: "high",
        deltaPx: maxMag,
        scope: "magnitude-divergent",
        perViewport: perVpDeltas.map((p) => ({ viewport: p.viewport, deltaPx: p.deltaPx })),
        candidates,
      });
      continue;
    }
    if (signs.size > 1) {
      // Divergent: this component has opposite-sign deltas across
      // viewports. A single global edit cannot satisfy both — the
      // agent needs a media-query-gated change.
      const summary = perVpDeltas
        .map((p) => `${p.viewport}: ${signed(p.deltaPx)}`)
        .join(", ");
      const maxAbs = Math.max(...perVpDeltas.map((p) => Math.abs(p.deltaPx)));
      const positiveSide = perVpDeltas.filter((p) => p.deltaPx > 0).map((p) => p.viewport);
      const negativeSide = perVpDeltas.filter((p) => p.deltaPx < 0).map((p) => p.viewport);
      const snap = input.tokens ? snapSpacing(input.tokens, maxAbs) : null;
      const tokenHint = snap ? `nearest token: ${snap.token.name} (${snap.token.raw})` : `magnitude ≈ ${maxAbs}px`;
      const viewportList = perVpDeltas.map((p) => p.viewport);
      const candidates = input.domPositionEntries
        ? matchCandidatesForDelta(maxAbs, viewportList, input.domPositionEntries)
        : undefined;
      out.push({
        evidence: `component rank=${rank} (bbox ${dims}): divergent Δtop across viewports (${summary})`,
        hypothesis: "the same component has opposite-sign deltas on different viewports — a global edit will fix one side while breaking the other",
        suggestion: `gate the spacing change with a media query: ${positiveSide.length > 0 ? `add ${maxAbs}px on ${positiveSide.join(", ")}` : ""}${positiveSide.length > 0 && negativeSide.length > 0 ? "; " : ""}${negativeSide.length > 0 ? `remove ${maxAbs}px on ${negativeSide.join(", ")}` : ""} (${tokenHint})`,
        viewports: viewportList,
        confidence: "high",
        deltaPx: maxAbs,
        scope: "divergent",
        perViewport: perVpDeltas.map((p) => ({ viewport: p.viewport, deltaPx: p.deltaPx })),
        candidates,
      });
      continue;
    }

    // Same-sign across all covered viewports. Bucket by magnitude so
    // +24 and +25 collapse but +24 and +48 stay separate.
    const buckets = new Map<string, Array<{ viewport: string; deltaPx: number; m: MatchedBbox }>>();
    for (const p of perVpDeltas) {
      const key = `${Math.round(p.deltaPx / 4) * 4}`;
      const arr = buckets.get(key) ?? [];
      arr.push(p);
      buckets.set(key, arr);
    }
    for (const obs of buckets.values()) {
      const sorted = obs.map((o) => o.deltaPx).sort((a, b) => a - b);
      const median = sorted[Math.floor(sorted.length / 2)];
      const deltaPx = median;
      const viewports = obs.map((o) => o.viewport);
      const covered = new Set(viewports);
      const scope: "all" | "subset" = covered.size === allViewports.size && [...allViewports].every((v) => covered.has(v))
        ? "all"
        : "subset";
      const uncovered = [...allViewports].filter((v) => !covered.has(v));
      const snap = input.tokens ? snapSpacing(input.tokens, Math.abs(deltaPx)) : null;
      const tokenHint = snap
        ? `token: ${snap.token.name} (${snap.token.raw}${snap.delta > 0 ? `, ±${snap.delta.toFixed(1)}px` : ""})`
        : `≈ ${Math.abs(deltaPx)}px`;
      const scopeNote = scope === "subset"
        ? ` (subset — gate with media query; not seen on ${uncovered.join(", ")})`
        : "";
      const candidates = input.domPositionEntries
        ? matchCandidatesForDelta(Math.abs(deltaPx), viewports, input.domPositionEntries)
        : undefined;
      out.push({
        evidence: `component rank=${rank} (bbox ${dims}): Δtop ${signed(deltaPx)} on ${viewports.join(", ")}${scopeNote}`,
        hypothesis: deltaPx > 0
          ? "container above is taller than baseline (extra margin/padding/gap)"
          : "container above is shorter than baseline (missing margin/padding/gap)",
        suggestion: `try ${deltaPx > 0 ? "reducing" : "adding"} top spacing by ${Math.abs(deltaPx)}px (${tokenHint})${intrinsicHint}`,
        viewports,
        confidence: confidenceFor({ viewportCount: viewports.length, deltaPx, snapped: !!snap }),
        deltaPx,
        scope,
        perViewport: obs.map((o) => ({ viewport: o.viewport, deltaPx: o.deltaPx })),
        candidates,
      });
    }
  }

  // ---- Text-row vertical shifts ----
  // Same approach but keyed by row rank (which pairs by text-content
  // order, so it survives small structural rewrites). Suppress rows
  // already covered by a bbox observation at the same Δy bucket.
  const seenBuckets = new Set(out.map((s) => `${Math.sign(s.deltaPx)}:${Math.round(s.deltaPx / 4) * 4}`));
  const rowByBucket = new Map<string, Array<{ viewport: string; m: MatchedTextRow }>>();
  for (const v of input.textRowsByViewport) {
    for (const m of v.matches) {
      if (Math.abs(m.deltaY) <= NEGLIGIBLE_DELTA_PX) continue;
      const bucket = `${Math.sign(m.deltaY)}:${Math.round(m.deltaY / 4) * 4}`;
      if (seenBuckets.has(bucket)) continue;
      const arr = rowByBucket.get(bucket) ?? [];
      arr.push({ viewport: v.viewport, m });
      rowByBucket.set(bucket, arr);
    }
  }
  const allTextViewports = new Set(
    input.allViewports && input.allViewports.length > 0
      ? input.allViewports
      : input.textRowsByViewport.map((v) => v.viewport),
  );
  for (const [bucket, obs] of rowByBucket) {
    // Only report buckets that appear on ≥2 rows (consistent shift) or
    // on ≥2 viewports — otherwise it's noise.
    const viewports = [...new Set(obs.map((o) => o.viewport))];
    if (obs.length < 2 && viewports.length < 2) continue;
    const median = obs.map((o) => o.m.deltaY).sort((a, b) => a - b)[Math.floor(obs.length / 2)];
    const sampleText = obs[0].m.baseline.text?.slice(0, 40) ?? "";
    const snap = input.tokens ? snapSpacing(input.tokens, Math.abs(median)) : null;
    const tokenHint = snap
      ? `token: ${snap.token.name} (${snap.token.raw})`
      : `≈ ${Math.abs(median)}px`;
    const covered = new Set(viewports);
    const scope: "all" | "subset" = covered.size === allTextViewports.size
      && [...allTextViewports].every((v) => covered.has(v))
      ? "all"
      : "subset";
    const uncovered = [...allTextViewports].filter((v) => !covered.has(v));
    const scopeNote = scope === "subset"
      ? ` (subset — gate with media query; not seen on ${uncovered.join(", ")})`
      : "";
    const candidates = input.domPositionEntries
      ? matchCandidatesForDelta(Math.abs(median), viewports, input.domPositionEntries)
      : undefined;
    out.push({
      evidence: `${obs.length} text-row(s) shifted Δy ${signed(median)} on ${viewports.join(", ")}${scopeNote} (e.g. "${sampleText}")`,
      hypothesis: median > 0
        ? "vertical rhythm above this row added space"
        : "vertical rhythm above this row removed space",
      suggestion: `try ${median > 0 ? "reducing" : "adding"} margin/padding by ${Math.abs(median)}px (${tokenHint})`,
      viewports,
      confidence: confidenceFor({ viewportCount: viewports.length, deltaPx: median, snapped: !!snap }),
      deltaPx: median,
      scope,
      perViewport: viewports.map((vp) => ({
        viewport: vp,
        deltaPx: obs.filter((o) => o.viewport === vp)
          .map((o) => o.m.deltaY)
          .sort((a, b) => a - b)[0] ?? median,
      })),
      candidates,
    });
    void bucket;
  }

  // Sort: highest confidence first, then largest |Δ|, then more viewports.
  out.sort((a, b) => {
    const conf = (c: typeof a.confidence) => (c === "high" ? 2 : c === "medium" ? 1 : 0);
    if (conf(b.confidence) !== conf(a.confidence)) return conf(b.confidence) - conf(a.confidence);
    if (Math.abs(b.deltaPx) !== Math.abs(a.deltaPx)) return Math.abs(b.deltaPx) - Math.abs(a.deltaPx);
    return b.viewports.length - a.viewports.length;
  });

  // #34: cross-suggestion overshoot aggregation. When multiple
  // suggestions all blame the same candidate selector, the agent
  // following each individually still overshoots the cumulative
  // magnitude. Detect and append a ⚠ warning to the lead
  // suggestion's text so agents read it where they'll act.
  //
  // Heuristic:
  //   - group candidates by (selector, viewport) across all
  //     suggestions
  //   - sum |magnitudes| from contributing suggestions
  //   - if cumulative > max(single) + 8px AND group has ≥ 2
  //     contributors, emit the warning
  const selectorGroups = new Map<string, {
    sugIdxs: Set<number>;
    magnitudes: number[];
    selector: string;
    viewport: string;
  }>();
  for (let i = 0; i < out.length; i++) {
    const seen = new Set<string>();
    for (const c of out[i].candidates ?? []) {
      const key = `${c.selector}|${c.viewport}`;
      if (seen.has(key)) continue;
      seen.add(key);
      const entry = selectorGroups.get(key) ?? {
        sugIdxs: new Set(),
        magnitudes: [],
        selector: c.selector,
        viewport: c.viewport,
      };
      entry.sugIdxs.add(i);
      entry.magnitudes.push(Math.abs(out[i].deltaPx));
      selectorGroups.set(key, entry);
    }
  }
  const CONVERGE_OVERAGE_PX = 8;
  // Dedupe across viewports: when the same set of suggestion indices
  // touches the same selector on multiple viewports (mobile / desktop /
  // wide), one warning lists every affected viewport instead of three
  // repetitions of the same text. Key by `(leadIdx, selector, sorted
  // sugIdxs)`.
  const emittedKey = new Set<string>();
  // Group groups by their dedupe key first so we can collect viewports.
  const collapsed = new Map<string, { leadIdx: number; selector: string; viewports: string[]; sugIdxs: Set<number>; magnitudes: number[]; cumulative: number }>();
  for (const { sugIdxs, magnitudes, selector, viewport } of selectorGroups.values()) {
    if (sugIdxs.size < 2) continue;
    const cumulative = magnitudes.reduce((s, m) => s + m, 0);
    const maxSingle = Math.max(...magnitudes);
    if (cumulative <= maxSingle + CONVERGE_OVERAGE_PX) continue;
    const leadIdx = Math.min(...sugIdxs);
    const sortedIdxs = [...sugIdxs].sort((a, b) => a - b).join(",");
    const key = `${leadIdx}|${selector}|${sortedIdxs}`;
    const existing = collapsed.get(key);
    if (existing) {
      existing.viewports.push(viewport);
    } else {
      collapsed.set(key, {
        leadIdx, selector,
        viewports: [viewport],
        sugIdxs,
        magnitudes,
        cumulative,
      });
    }
  }
  for (const entry of collapsed.values()) {
    const list = entry.magnitudes.map((m) => `${m}px`).join(" + ");
    const vpList = entry.viewports.join(", ");
    out[entry.leadIdx].suggestion += ` ⚠ ${entry.sugIdxs.size} suggestions converge on ${entry.selector} (${vpList}): cumulative ${list} = ${entry.cumulative}px. Treat each individually and you risk compound overshoot — consolidate into one edit that resolves all together.`;
    emittedKey.add(`${entry.leadIdx}|${entry.selector}`);
  }
  void emittedKey;

  // #36: cross-edit interaction across DIFFERENT selectors. When a
  // single suggestion's candidate list contains ≥ 2 distinct
  // cascading selectors (height / margin-bottom / etc. on more than
  // one element), applying both compounds — fixing the first moves
  // geometry the second was trying to fix. #34 covers the
  // same-selector case; this is the multi-selector counterpart.
  // Agent-h v8: ".container padding AND .hero margin both affect
  // rank=0 — applying both compounded to +24 desktop."
  for (const s of out) {
    const cascading = (s.candidates ?? []).filter((c) => c.cascades);
    const distinctSelectors = new Set(cascading.map((c) => c.selector));
    if (distinctSelectors.size < 2) continue;
    const list = [...distinctSelectors].slice(0, 4).join(" + ");
    s.suggestion += ` ⚠ cross-edit: ${list} all cascade-affect this component's geometry. Applying multiple cascading edits compounds — apply one, re-measure, then the next, rather than treating them as independent.`;
  }

  // F1: structural-mismatch detection. When 3+ suggestions all
  // blame children of the same DOM parent — AND those children have
  // genuinely heterogeneous deltas (different magnitudes or
  // divergent signs) — that's a strong signal the parent's layout
  // is wrong rather than just needing per-child tuning.
  //
  // Agent-g v7 hit a false-positive when STRUCTURAL fired at
  // `body[0]` and at `body[0]>main[0]` (the document root and its
  // sole child). Their container was *already* display: grid + gap,
  // so the suggestion to "restructure" was useless. Two guards now
  // suppress that noise:
  //
  //   (a) parent path must have ≥ 2 segments — root and its sole
  //       child don't qualify as a meaningful "parent layout choice"
  //       location.
  //   (b) the candidate magnitudes under that parent must be
  //       heterogeneous (range ≥ 12px OR mix of signs) — if every
  //       child needs the same +24px the right answer IS per-child
  //       tuning, not restructuring.
  const MIN_PARENT_DEPTH = 2;
  const HETEROGENEITY_RANGE = 12;
  const parentCounts = new Map<string, { sugs: Set<number>; magnitudes: number[] }>();
  for (let i = 0; i < out.length; i++) {
    const seenParents = new Set<string>();
    for (const c of out[i].candidates ?? []) {
      if (!c.path) continue;
      const par = parentPath(c.path);
      if (!par || seenParents.has(par)) continue;
      if (par.split(">").length < MIN_PARENT_DEPTH) continue;
      seenParents.add(par);
      const entry = parentCounts.get(par) ?? { sugs: new Set(), magnitudes: [] };
      entry.sugs.add(i);
      entry.magnitudes.push(out[i].deltaPx);
      parentCounts.set(par, entry);
    }
  }
  let structuralRow: WireframeFixSuggestion | undefined;
  for (const [par, { sugs, magnitudes }] of parentCounts) {
    if (sugs.size < 3) continue;
    const signs = new Set(magnitudes.map((m) => Math.sign(m)).filter((s) => s !== 0));
    const absMags = magnitudes.map(Math.abs);
    const range = Math.max(...absMags) - Math.min(...absMags);
    const heterogeneous = signs.size > 1 || range >= HETEROGENEITY_RANGE;
    if (!heterogeneous) continue;
    const affectedVps = new Set<string>();
    for (const i of sugs) for (const v of out[i].viewports) affectedVps.add(v);
    // #35: surface the SPECIFIC layout-strategy mismatch at the
    // parent. Agent-h v8: "STRUCTURAL named the parent but not
    // the property — I still had to read the triptych."
    const layoutDeltas = parentLayoutDeltas(par, input.domPositionEntries);
    const layoutDetail = layoutDeltas.length > 0
      ? ` parent layout deltas: ${layoutDeltas.join("; ")}`
      : "";
    // V9a: when introducing a parent `gap` rule, list any non-zero
    // pre-existing child margins that will compound — agent-i v9
    // applied a parent gap edit and the unrclear'd child margin-tops
    // caused a +5pp mobile regression.
    const conflicts = conflictingChildMargins(par, layoutDeltas, input.domPositionEntries);
    const conflictTail = conflicts.length > 0
      ? ` ⚠ ALSO clear non-zero child margins that will compound with the new gap: ${conflicts.join(", ")}.`
      : "";
    const suggestion = layoutDeltas.length > 0
      ? `change \`${par}\`'s layout to match: ${layoutDeltas.join("; ")}. Resolving these at the parent will fix the ${sugs.size} child suggestions together; per-child patches will compound.${conflictTail}`
      : `consider restructuring \`${par}\` itself (e.g. switch flex-with-per-child-margins → display: grid + row-gap, or change align-items / justify-content) so the children fall into place together. (Parent's layout properties match between baseline and variant — the mismatch must be in child-level properties.)`;
    structuralRow = {
      evidence: `${sugs.size} suggestions all blame children of \`${par}\` with heterogeneous deltas (range ${range}px, ${signs.size > 1 ? "mixed signs" : "same sign"})${layoutDetail}`,
      hypothesis: "patching each child is a local-minima trap; the parent's layout strategy is the actual delta",
      suggestion,
      viewports: [...affectedVps],
      confidence: "medium",
      deltaPx: 0,
      scope: "structural",
    };
    break; // at most one structural row per run
  }
  if (structuralRow) out.unshift(structuralRow);

  // High-impact detection (closes G2 / agent-e v5 attention-bias):
  // when one suggestion's magnitude dominates the rest, flag it so
  // the renderer can pull it above the [DIVERGENT] / [SUBSET] noise.
  // Only one row carries the flag — at-most-one keeps the badge
  // meaningful (everything-highlighted = nothing-highlighted).
  if (out.length >= 2) {
    const sortedByMag = [...out].sort((a, b) => Math.abs(b.deltaPx) - Math.abs(a.deltaPx));
    const top = sortedByMag[0];
    const next = sortedByMag[1];
    const topAbs = Math.abs(top.deltaPx);
    const nextAbs = Math.abs(next.deltaPx);
    if (topAbs >= 12 && topAbs >= nextAbs * 1.5) {
      top.isHighImpact = true;
    }
  }

  return out;
}

/**
 * CSS properties on the PARENT element that drive its children's
 * layout. When a STRUCTURAL row fires, baseline-vs-variant differences
 * on these properties are the actionable layout-strategy mismatch
 * — surface them so the agent doesn't have to inspect the triptych
 * (agent-h v8: STRUCTURAL named the parent but not the property).
 */
const PARENT_LAYOUT_PROPERTIES = new Set([
  "display",
  "flex-direction",
  "flex-wrap",
  "flex-flow",
  "align-items",
  "align-content",
  "justify-content",
  "justify-items",
  "gap",
  "row-gap",
  "column-gap",
  "grid-template-rows",
  "grid-template-columns",
  "grid-template-areas",
  "grid-auto-flow",
  "grid-auto-rows",
  "grid-auto-columns",
]);

/**
 * Inspect the DP entries at the given parent path and surface any
 * layout-strategy properties that differ between baseline and
 * variant. Returns a list of human-readable diff lines like
 * `display: flex → grid` (current → target convention from agent-e).
 */
function parentLayoutDeltas(
  parentPath: string,
  entries: DpEntryWithViewport[] | undefined,
): string[] {
  if (!entries) return [];
  const seen = new Set<string>();
  const out: string[] = [];
  for (const e of entries) {
    if (e.path !== parentPath) continue;
    if (!PARENT_LAYOUT_PROPERTIES.has(e.property)) continue;
    if (e.baseline === e.variant) continue;
    const key = `${e.property}|${e.viewport}`;
    if (seen.has(key)) continue;
    seen.add(key);
    out.push(`${e.property}: ${e.variant} (now) → ${e.baseline} (target)`);
    if (out.length >= 5) break;
  }
  return out;
}

/**
 * When STRUCTURAL fires with a non-zero parent `gap` delta (i.e. the
 * baseline introduces a gap the variant lacks, or vice versa),
 * surface the children's existing top/bottom margins from DP entries
 * — those will compound with the new gap if not cleared. Closes V9a
 * (agent-i v9: "parent edit alone caused +5pp mobile regression
 * because pre-existing child margin-tops weren't cleared").
 */
function conflictingChildMargins(
  parentPath: string,
  parentDeltas: string[],
  entries: DpEntryWithViewport[] | undefined,
): string[] {
  if (!entries) return [];
  // Only warn when a gap-style property is in the parent deltas.
  const introducingGap = parentDeltas.some((d) =>
    d.startsWith("gap:") || d.startsWith("row-gap:") || d.startsWith("column-gap:")
  );
  if (!introducingGap) return [];
  const out: string[] = [];
  const seen = new Set<string>();
  for (const e of entries) {
    // Direct children of the parent path only (one segment deeper).
    if (!e.path.startsWith(parentPath + ">")) continue;
    const tail = e.path.slice(parentPath.length + 1);
    if (tail.includes(">")) continue;
    if (e.property !== "margin-top" && e.property !== "margin-bottom") continue;
    // Only flag NON-ZERO existing variant margins — those are the
    // ones that compound with the introduced gap.
    if (e.variant === "0px" || e.variant === "0") continue;
    const sel = e.variantClasses || e.baselineClasses;
    if (!sel) continue;
    const key = `.${sel.split(/\s+/)[0]}|${e.property}`;
    if (seen.has(key)) continue;
    seen.add(key);
    out.push(`.${sel.split(/\s+/)[0]}.${e.property}: ${e.variant}`);
    if (out.length >= 4) break;
  }
  return out;
}

function signed(n: number): string {
  return n >= 0 ? `+${n}px` : `${n}px`;
}

/**
 * Drop the trailing segment of a DOM path so we can group siblings
 * by their immediate parent. Empty string when the path has no
 * parent (e.g. `body[0]`).
 */
function parentPath(p: string): string {
  const segs = p.split(">");
  if (segs.length <= 1) return "";
  return segs.slice(0, -1).join(">");
}

function confidenceFor(input: {
  viewportCount: number;
  deltaPx: number;
  snapped: boolean;
}): "low" | "medium" | "high" {
  const abs = Math.abs(input.deltaPx);
  if (input.snapped && (input.viewportCount >= 2 || abs >= 16)) return "high";
  if (input.viewportCount >= 2 || abs >= 16) return "medium";
  return "low";
}
