import type { PenNode, PenDocument } from '@zseven-w/pen-types';
import type { Issue } from './types';
import { detectEdgeSectionPadding, detectStackedHorizontalPadding } from './detectors-spacing';
import { detectTextBgContrast } from './detectors-typography';
import { colorContrast, parseHexColor, relativeLuminance } from './color-utils';

/** Extract the first fill color from a node (raw, including variable refs) */
function getFirstFillColor(node: PenNode): string | null {
  if (!('fill' in node) || !Array.isArray(node.fill) || node.fill.length === 0) return null;
  const first = node.fill[0];
  if (first && 'color' in first && first.color) return first.color;
  return null;
}

/** Check if a node already has a visible stroke */
function hasStroke(node: PenNode): boolean {
  if (!('stroke' in node)) return false;
  const s = node.stroke as { thickness?: number } | undefined;
  return s != null && (s.thickness ?? 0) > 0;
}

interface PenDocumentLike {
  variables?: Record<string, unknown>;
}

/** Resolve border color: prefer $color-border variable if it exists, else neutral gray */
function getBorderStroke(doc: PenDocumentLike): {
  thickness: number;
  fill: Array<{ type: 'solid'; color: string }>;
} {
  const hasBorderVar = doc.variables && 'color-border' in doc.variables;
  const color = hasBorderVar ? '$color-border' : '#E2E8F0';
  return { thickness: 1, fill: [{ type: 'solid', color }] };
}

export interface DetectInvisibleContainersOptions {
  /**
   * 'perceptual' uses WCAG relative-luminance contrast ratio with
   * `contrastRatioThreshold`. 'strict' uses === equality (the original
   * behavior). Default: 'perceptual'.
   */
  colorMatchMode?: 'strict' | 'perceptual';
  /**
   * Maximum WCAG contrast ratio (1.0–21.0) below which two colors are
   * considered "the same" in perceptual mode. Default: 1.10.
   *
   * Reference points:
   *   1.00 = identical
   *   1.10 = essentially indistinguishable on either light or dark bg
   *   1.50 = subtly visible
   *   3.00 = clearly visible (WCAG AA UI element)
   *   4.50 = WCAG AA text contrast
   *
   * Higher = more aggressive detection (will flag more pairs as
   * "invisible"). Lower = stricter (fewer false positives, more misses).
   */
  contrastRatioThreshold?: number;
}

/**
 * Detect frames whose fill matches their parent's fill (rendered "invisible")
 * and which contain visible content. Suggests adding a subtle stroke so the
 * container becomes distinguishable.
 */
export function detectInvisibleContainers(
  root: PenNode,
  doc: PenDocument,
  opts: DetectInvisibleContainersOptions = {},
): Issue[] {
  const mode = opts.colorMatchMode ?? 'perceptual';
  const threshold = opts.contrastRatioThreshold ?? 1.1;
  const issues: Issue[] = [];
  walk(root, null);
  return issues;

  function walk(node: PenNode, parentFillColor: string | null): void {
    const nodeFill = getFirstFillColor(node);

    if (
      parentFillColor &&
      nodeFill &&
      !hasStroke(node) &&
      node.type === 'frame' &&
      'layout' in node &&
      (node as { layout?: unknown }).layout &&
      'children' in node &&
      node.children &&
      node.children.length > 0
    ) {
      const ratio = colorContrast(nodeFill, parentFillColor);
      const same = mode === 'strict' ? nodeFill === parentFillColor : ratio <= threshold;
      if (same) {
        // Theme-aware severity: on dark backgrounds the suggested
        // light-gray border (#E2E8F0) would actively damage the design.
        // Downgrade dark-on-dark cases to 'info' (detect-only); pre-
        // validation skips info severity so the user/agent can decide
        // manually whether to add a dark-appropriate border. Light-on-
        // light cases stay 'warning' and remain auto-fixable.
        const parsedParent = parseHexColor(parentFillColor);
        const isDarkOnDark = parsedParent != null && relativeLuminance(parsedParent) < 0.1;
        issues.push({
          nodeId: node.id,
          category: 'invisible-container',
          severity: isDarkOnDark ? 'info' : 'warning',
          property: 'stroke',
          currentValue: (node as { stroke?: unknown }).stroke ?? null,
          suggestedValue: getBorderStroke(doc),
          reason: `same fill as parent (${nodeFill} ≈ ${parentFillColor}, contrast=${ratio.toFixed(2)})`,
        });
      }
    }

    if ('children' in node && node.children) {
      for (const child of node.children) {
        walk(child, nodeFill ?? parentFillColor);
      }
    }
  }
}

/**
 * Detect path nodes without geometry data (empty `d` property).
 * These render as invisible empty rectangles on canvas.
 */
export function detectEmptyPaths(root: PenNode): Issue[] {
  const issues: Issue[] = [];
  walk(root);
  return issues;

  function walk(node: PenNode): void {
    if (node.type === 'path') {
      const hasD = 'd' in node && (node as unknown as Record<string, unknown>).d;
      if (!hasD) {
        issues.push({
          nodeId: node.id,
          category: 'empty-path',
          severity: 'warning',
          property: '__remove',
          currentValue: null,
          suggestedValue: true,
          reason: 'path node without geometry (renders invisible)',
        });
      }
    }
    if ('children' in node && node.children) {
      for (const child of node.children) walk(child);
    }
  }
}

/**
 * Detect text nodes with explicit pixel heights. Explicit heights on text
 * always cause clipping or overlap; the layout engine should auto-calculate
 * height from content + fontSize + lineHeight instead.
 */
export function detectTextExplicitHeights(root: PenNode): Issue[] {
  const issues: Issue[] = [];
  walk(root);
  return issues;

  function walk(node: PenNode): void {
    if (node.type === 'text') {
      const textNode = node as PenNode & { height?: unknown; textGrowth?: string };
      if (typeof textNode.height === 'number' && textNode.textGrowth !== 'fixed-width-height') {
        issues.push({
          nodeId: node.id,
          category: 'text-explicit-height',
          severity: 'warning',
          property: 'height',
          currentValue: textNode.height,
          suggestedValue: 'fit_content',
          reason: `text node has explicit height=${textNode.height}px — causes clipping`,
        });
      }
    }
    if ('children' in node && node.children) {
      for (const child of node.children) walk(child);
    }
  }
}

/**
 * Property classification for sibling consistency checks.
 *
 * STRICT props are intentionally role-dependent — their value SHOULD vary
 * by what the sibling represents (hero is tall, footer is short, tab-bar
 * is fixed-height). They are only compared among same-type, same-role
 * siblings.
 *
 * LOOSE props are design-system tokens that should be uniform across
 * structurally-similar siblings regardless of semantic role (typically
 * cornerRadius). They additionally get a cross-role check among same-type
 * siblings, catching outliers in singleton-role compositions (e.g. a web
 * landing page where every section has a unique role and the strict pass
 * would otherwise skip every group as <3 members).
 *
 * The strict/loose split is what lets this detector handle BOTH:
 *   - Mobile chrome shells without false positives (chrome's fixed height
 *     is in STRICT → never compared against sections cross-role)
 *   - Web landing pages without false negatives (cornerRadius is in LOOSE
 *     → compared across hero/features/cta/footer even though all roles
 *     are singletons)
 */
const FRAME_STRICT_PROPS = ['height'] as const;
const FRAME_LOOSE_PROPS = ['cornerRadius'] as const;
const TEXT_STRICT_PROPS = ['fontSize'] as const;

/**
 * Detect property inconsistencies among siblings (>= 3 siblings,
 * >= 2/3 majority rule). Outliers get an Issue to align with the majority.
 *
 * Two parallel passes:
 *
 *   1. STRICT pass: groups siblings by `${type}:${role || '__none__'}`
 *      and checks STRICT + LOOSE properties. Same-role siblings get the
 *      tightest consistency check. Divider/spacer roles are skipped
 *      entirely (intentionally tiny layout primitives whose dimensions
 *      shouldn't match anything).
 *
 *   2. LOOSE pass: groups siblings by `${type}` only and checks LOOSE
 *      properties. This catches outliers in compositions where every
 *      sibling has a unique role (web landing pages: hero/features/
 *      cta/footer) which would otherwise all be skipped as singleton
 *      strict groups.
 *
 * Issues that overlap between the two passes are deduplicated by nodeId
 * + property; the strict-pass version wins (it iterates first).
 *
 * Why a strict/loose split rather than a hand-curated chrome list:
 *   - Same role names mean different things in different design types:
 *     `navbar` is chrome on mobile but a content section on web; `footer`
 *     is a content section on web but rare on mobile. Any hand-curated
 *     chrome list will be wrong in one of those contexts.
 *   - Splitting by property semantics ("which props are role-dependent?")
 *     is invariant across design contexts and doesn't require curating
 *     a list of structurally-distinct roles.
 */
export function detectSiblingInconsistencies(root: PenNode): Issue[] {
  const raw: Issue[] = [];
  walk(root);

  // Dedupe: strict and loose passes can both emit on the same
  // (nodeId, property) pair (e.g. a same-role cornerRadius outlier
  // is caught by both). Keep first occurrence — strict iterates first.
  const seen = new Set<string>();
  const unique: Issue[] = [];
  for (const issue of raw) {
    const key = `${issue.nodeId}:${issue.property}`;
    if (seen.has(key)) continue;
    seen.add(key);
    unique.push(issue);
  }
  return unique;

  function walk(node: PenNode): void {
    if (!('children' in node) || !node.children) return;
    if (node.children.length >= 3) {
      const strictGroups = new Map<string, PenNode[]>();
      const typeGroups = new Map<string, PenNode[]>();
      for (const child of node.children) {
        const childRole = ((child as { role?: string }).role ?? '').toLowerCase() || '__none__';
        // Skip divider/spacer — visual layout primitives whose dimensions
        // are intentionally tiny and don't share structure with siblings;
        // reporting them as outliers is always noise.
        if (childRole === 'divider' || childRole === 'spacer') continue;

        const strictKey = `${child.type}:${childRole}`;
        if (!strictGroups.has(strictKey)) strictGroups.set(strictKey, []);
        strictGroups.get(strictKey)!.push(child);

        if (!typeGroups.has(child.type)) typeGroups.set(child.type, []);
        typeGroups.get(child.type)!.push(child);
      }

      // Strict pass: same-type, same-role siblings get checked on every
      // applicable property (STRICT + LOOSE). Severity 'warning' — these
      // are confidently auto-fixable because the comparison group shares
      // a semantic role.
      for (const [strictKey, siblings] of strictGroups) {
        if (siblings.length < 3) continue;
        const type = strictKey.split(':', 1)[0];
        const props =
          type === 'text'
            ? (TEXT_STRICT_PROPS as readonly string[])
            : ([...FRAME_STRICT_PROPS, ...FRAME_LOOSE_PROPS] as readonly string[]);
        for (const prop of props) {
          checkConsistency(siblings, prop, raw, 'warning');
        }
      }

      // Loose pass: same-type-only siblings get checked on role-independent
      // properties only. Catches singleton-role outliers (web landing page
      // sections) without re-introducing the chrome height false positive
      // (height is in STRICT, never checked here).
      //
      // Severity 'info' — these are DETECT-ONLY. The pre-validation pipeline
      // skips info-severity issues for auto-fix, because cross-role
      // comparison can match a structurally distinct sibling (e.g. a
      // rounded tab-bar among square sections) and silently rewriting it
      // would damage intentional design choices. The issue still appears
      // in debug reports so the user/agent can review it manually.
      for (const [type, siblings] of typeGroups) {
        if (siblings.length < 3) continue;
        if (type === 'text') continue; // text has no role-independent props
        for (const prop of FRAME_LOOSE_PROPS) {
          checkConsistency(siblings, prop, raw, 'info');
        }
      }
    }
    for (const child of node.children) walk(child);
  }
}

/**
 * Aesthetic detector: rotation on a UI-bearing node.
 *
 * Rationale: AI-generated layouts almost never want rotation on a text /
 * frame / shape node. When a model emits `rotation: 12` on a frame the
 * card visibly tilts — usually an artifact of a misread Figma export or
 * a model copying decorative geometry into UI flow. Skip path / line /
 * polygon nodes (legitimate decorative geometry frequently has
 * rotation), skip exactly-0 and missing values, and skip rotation that
 * is a multiple of 90 (intentional vertical text or grid rotation).
 */
export function detectUnexpectedRotation(root: PenNode): Issue[] {
  const issues: Issue[] = [];
  const SKIP_TYPES = new Set(['path', 'line', 'polygon', 'image']);
  function walk(node: PenNode): void {
    const rec = node as unknown as Record<string, unknown>;
    const rotation = typeof rec.rotation === 'number' ? rec.rotation : 0;
    if (
      !SKIP_TYPES.has(node.type) &&
      rotation !== 0 &&
      Math.abs(rotation % 90) > 0.5 // 90/180/270 rotations are usually intentional
    ) {
      issues.push({
        nodeId: node.id,
        category: 'unexpected-rotation',
        severity: 'warning',
        property: 'rotation',
        currentValue: rotation,
        suggestedValue: 0,
        reason: `${node.type} node has rotation=${rotation}; UI nodes are rarely tilted on purpose`,
      });
    }
    if ('children' in node && Array.isArray(node.children)) {
      for (const c of node.children) walk(c);
    }
  }
  walk(root);
  return issues;
}

/**
 * Aesthetic detector: text node with cornerRadius.
 *
 * Rationale: cornerRadius on a text node has no rendering effect (text
 * isn't drawn into a clipped rectangle), but it's a common AI hallucination
 * — the model copies a generic "card-like" prop set onto a text run. The
 * stale prop survives in the doc, confuses downstream code generators
 * that round-trip the schema, and burns LLM context on subsequent
 * batch_get calls. Suggest fixing to undefined (remove).
 */
export function detectTextCornerRadius(root: PenNode): Issue[] {
  const issues: Issue[] = [];
  function walk(node: PenNode): void {
    if (node.type === 'text') {
      const cr = (node as unknown as { cornerRadius?: unknown }).cornerRadius;
      const num = typeof cr === 'number' ? cr : null;
      if (num !== null && num > 0) {
        issues.push({
          nodeId: node.id,
          category: 'text-corner-radius',
          severity: 'warning',
          property: 'cornerRadius',
          currentValue: num,
          suggestedValue: undefined,
          reason: 'text nodes have no clip path — cornerRadius is silently dropped at render time',
        });
      }
    }
    if ('children' in node && Array.isArray(node.children)) {
      for (const c of node.children) walk(c);
    }
  }
  walk(root);
  return issues;
}

/**
 * Aesthetic detector: siblings (>= 3 of the same type+role) with
 * inconsistent cornerRadius. Picks the modal value, flags the outliers.
 *
 * The existing `sibling-inconsistency` detector already catches this for
 * frame nodes via FRAME_STRICT_PROPS, BUT only when the modal-vs-outlier
 * delta is >= 50% of the modal value. For cornerRadius specifically the
 * threshold misses tiny but visually obvious mismatches (8 vs 12 across
 * three cards reads as ragged on canvas). This detector uses a strict
 * "equal or report" check on cornerRadius alone.
 */
export function detectMixedSiblingCornerRadius(root: PenNode): Issue[] {
  const issues: Issue[] = [];
  function walk(node: PenNode): void {
    if (!('children' in node) || !Array.isArray(node.children)) return;
    if (node.children.length >= 3) {
      const groups = new Map<string, PenNode[]>();
      for (const c of node.children) {
        const role = ((c as { role?: string }).role ?? '').toLowerCase() || '__none__';
        if (role === 'divider' || role === 'spacer') continue;
        const key = `${c.type}:${role}`;
        if (!groups.has(key)) groups.set(key, []);
        groups.get(key)!.push(c);
      }
      for (const [, siblings] of groups) {
        if (siblings.length < 3) continue;
        const counts = new Map<number, number>();
        for (const s of siblings) {
          const cr = (s as unknown as { cornerRadius?: unknown }).cornerRadius;
          const num = typeof cr === 'number' ? cr : 0;
          counts.set(num, (counts.get(num) ?? 0) + 1);
        }
        if (counts.size < 2) continue;
        let modal = 0;
        let modalCount = 0;
        for (const [val, count] of counts) {
          if (count > modalCount) {
            modal = val;
            modalCount = count;
          }
        }
        // Only flag if the modal is a clear majority (>= 60%) so we
        // don't report a 1-1-1 three-way split as "outliers" — there's
        // no canonical value to suggest in that case.
        if (modalCount / siblings.length < 0.6) continue;
        for (const s of siblings) {
          const cr = (s as unknown as { cornerRadius?: unknown }).cornerRadius;
          const num = typeof cr === 'number' ? cr : 0;
          if (num !== modal) {
            issues.push({
              nodeId: s.id,
              category: 'mixed-sibling-corner-radius',
              severity: 'warning',
              property: 'cornerRadius',
              currentValue: num,
              suggestedValue: modal,
              reason: `cornerRadius ${num} doesn't match the ${modalCount} other ${node.children.length - 1} sibling(s) at ${modal}`,
            });
          }
        }
      }
    }
    for (const c of node.children) walk(c);
  }
  walk(root);
  return issues;
}

/**
 * Aesthetic detector: text node with effects (shadow / blur / etc).
 *
 * Rationale: drop-shadows and blurs on UI text almost always read as
 * "designed by AI" — real product UIs use text shadows extremely
 * sparingly (hero overlays on photos and a few brand elements). The
 * model frequently sprinkles `effects: [{ type: 'shadow', … }]` onto
 * body / label / caption text which makes the type look fuzzy and
 * imprecise on canvas. Suggest removing the effects array.
 */
export function detectTextEffect(root: PenNode): Issue[] {
  const issues: Issue[] = [];
  function walk(node: PenNode): void {
    if (node.type === 'text') {
      const eff = (node as unknown as { effects?: unknown }).effects;
      if (Array.isArray(eff) && eff.length > 0) {
        issues.push({
          nodeId: node.id,
          category: 'text-effect',
          severity: 'warning',
          property: 'effects',
          currentValue: eff,
          suggestedValue: undefined,
          reason:
            'text node carries shadow / blur effects — almost always an AI hallucination on UI labels',
        });
      }
    }
    if ('children' in node && Array.isArray(node.children)) {
      for (const c of node.children) walk(c);
    }
  }
  walk(root);
  return issues;
}

/**
 * Aesthetic detector: text node with stroke (outlined text).
 *
 * Rationale: outlined text on a UI label is almost always an AI mistake
 * — Lucide / SF / Material icons get stroked, but body / heading / label
 * text is filled, not stroked. The model occasionally copies a generic
 * "give it a stroke" instruction onto text nodes; the result on canvas
 * is double-rendered glyphs that read as designed-by-AI.
 *
 * Suggest removing the stroke. Skip when stroke.thickness is 0 (some
 * model JSON keeps an empty stroke object as a placeholder).
 */
export function detectTextStroke(root: PenNode): Issue[] {
  const issues: Issue[] = [];
  function walk(node: PenNode): void {
    if (node.type === 'text') {
      const stroke = (node as unknown as { stroke?: unknown }).stroke;
      if (stroke && typeof stroke === 'object') {
        const s = stroke as { thickness?: number };
        if (typeof s.thickness === 'number' && s.thickness > 0) {
          issues.push({
            nodeId: node.id,
            category: 'text-stroke',
            severity: 'warning',
            property: 'stroke',
            currentValue: stroke,
            suggestedValue: undefined,
            reason:
              'text node has stroke — outlined UI labels are almost always an AI hallucination',
          });
        }
      }
    }
    if ('children' in node && Array.isArray(node.children)) {
      for (const c of node.children) walk(c);
    }
  }
  walk(root);
  return issues;
}

/**
 * Aesthetic detector: siblings (>= 3 of the same type+role) with
 * inconsistent padding. Mirrors detectMixedSiblingCornerRadius — picks
 * the modal value, flags the outliers.
 *
 * Padding is normalised to a 4-tuple [top, right, bottom, left] for
 * comparison so e.g. `padding: 16` (shorthand) and `padding: [16,16,16,16]`
 * (explicit) compare equal. Numbers, 2-tuples [v, h] (CSS shorthand) and
 * 4-tuples are all accepted; anything else is treated as "no padding".
 *
 * Picks up "three cards with padding 16 / 16 / 20" — the kind of
 * 4px-mismatch the model emits when copying examples from different
 * sources. The existing sibling-inconsistency detector already covers
 * cards-vs-cards via FRAME_STRICT_PROPS, BUT it dedupes against
 * cornerRadius and other props so the padding outlier sometimes drops.
 * This is a stricter equal-or-report check on padding alone.
 */
export function detectMixedSiblingPadding(root: PenNode): Issue[] {
  const issues: Issue[] = [];
  function normalise(p: unknown): string | null {
    if (typeof p === 'number') return `[${p},${p},${p},${p}]`;
    if (Array.isArray(p)) {
      if (p.length === 2) return `[${p[0]},${p[1]},${p[0]},${p[1]}]`;
      if (p.length === 4) return `[${p[0]},${p[1]},${p[2]},${p[3]}]`;
    }
    return null;
  }
  function walk(node: PenNode): void {
    if (!('children' in node) || !Array.isArray(node.children)) return;
    if (node.children.length >= 3) {
      const groups = new Map<string, PenNode[]>();
      for (const c of node.children) {
        const role = ((c as { role?: string }).role ?? '').toLowerCase() || '__none__';
        if (role === 'divider' || role === 'spacer') continue;
        const key = `${c.type}:${role}`;
        if (!groups.has(key)) groups.set(key, []);
        groups.get(key)!.push(c);
      }
      for (const [, siblings] of groups) {
        if (siblings.length < 3) continue;
        const counts = new Map<string, number>();
        const norms = siblings.map((s) => {
          const p = (s as unknown as { padding?: unknown }).padding;
          const n = normalise(p);
          if (n) counts.set(n, (counts.get(n) ?? 0) + 1);
          return n;
        });
        if (counts.size < 2) continue;
        let modal = '';
        let modalCount = 0;
        for (const [val, count] of counts) {
          if (count > modalCount) {
            modal = val;
            modalCount = count;
          }
        }
        // Same 60% rule as cornerRadius — only flag when there's a
        // clear majority. 1-1-1 splits get skipped (no canonical
        // value to suggest).
        if (modalCount / siblings.length < 0.6) continue;
        siblings.forEach((s, i) => {
          const norm = norms[i];
          if (norm !== null && norm !== modal) {
            // Parse modal back to a value the store can apply. Modal
            // is always [a,b,c,d] form.
            const m = modal.match(
              /\[(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)\]/,
            );
            if (!m) return;
            const [, a, b, c, d] = m;
            const tup = [Number(a), Number(b), Number(c), Number(d)];
            const allEqual = tup.every((n) => n === tup[0]);
            const suggested: number | number[] = allEqual ? tup[0] : tup;
            issues.push({
              nodeId: s.id,
              category: 'mixed-sibling-padding',
              severity: 'warning',
              property: 'padding',
              currentValue: (s as unknown as { padding?: unknown }).padding,
              suggestedValue: suggested,
              reason: `padding ${norm} doesn't match the ${modalCount} other sibling(s) at ${modal}`,
            });
          }
        });
      }
    }
    for (const c of node.children) walk(c);
  }
  walk(root);
  return issues;
}

/**
 * Aesthetic detector: frame node with shadow/blur effects whose parameters
 * are outside typical UI ranges. Real product UI shadows are tight and
 * subtle — blur 4-16, no positive spread, near-black low-alpha color. The
 * AI hallucination signature is one of:
 *   - blur >= 40 (too soft, looks like a glow / halo)
 *   - spread > 0 (a positive spread "bleeds" the shadow outward and reads
 *     as a colored bloom around the element — the "带尖的背景阴影"
 *     ("pointy / spiked background shadow") the 2026-05-10 user report
 *     called out on the Mexican badge)
 *   - 3+ effects on one frame (badges / cards rarely stack 3+ shadows)
 *
 * Suggested fix is to remove the effects array; the user / agent can
 * re-add a proper shadow afterwards if intentional.
 */
export function detectExcessiveFrameEffects(root: PenNode): Issue[] {
  const issues: Issue[] = [];
  // Threshold is "strictly greater than" — modal-shell + similar
  // production builders legitimately use blur=40 for the modal scrim.
  // A blur of 41+ is the AI-hallucination signature.
  const TYPICAL_BLUR_MAX = 40;
  function walk(node: PenNode): void {
    if (node.type === 'frame') {
      const eff = (node as unknown as { effects?: unknown }).effects;
      if (Array.isArray(eff) && eff.length > 0) {
        let suspect = false;
        let reason = '';
        if (eff.length >= 3) {
          suspect = true;
          reason = `${eff.length} stacked effects on one frame (typical UI uses 0-2)`;
        } else {
          for (const e of eff) {
            const r = e as { blur?: unknown; spread?: unknown; type?: unknown };
            if (typeof r.blur === 'number' && r.blur > TYPICAL_BLUR_MAX) {
              suspect = true;
              reason = `effect blur ${r.blur} > ${TYPICAL_BLUR_MAX} (glow / halo, atypical for UI)`;
              break;
            }
            if (typeof r.spread === 'number' && r.spread > 0) {
              suspect = true;
              reason = `effect spread ${r.spread} > 0 (bleeds outward, "spiked" appearance)`;
              break;
            }
          }
        }
        if (suspect) {
          issues.push({
            nodeId: node.id,
            category: 'excessive-frame-effects',
            severity: 'warning',
            property: 'effects',
            currentValue: eff,
            suggestedValue: undefined,
            reason,
          });
        }
      }
    }
    if ('children' in node && Array.isArray(node.children)) {
      for (const c of node.children) walk(c);
    }
  }
  walk(root);
  return issues;
}

/**
 * Run all 14 detectors and return the deduplicated combined issue list.
 * Dedup key: `${nodeId}:${property}` (matches runPreValidationFixes).
 * On collision, the first issue wins (detector execution order below).
 */
export function detectAllIssues(root: PenNode, doc: PenDocument): Issue[] {
  const combined: Issue[] = [
    ...detectInvisibleContainers(root, doc),
    ...detectEmptyPaths(root),
    ...detectTextExplicitHeights(root),
    ...detectSiblingInconsistencies(root),
    ...detectUnexpectedRotation(root),
    ...detectTextCornerRadius(root),
    ...detectMixedSiblingCornerRadius(root),
    ...detectTextEffect(root),
    ...detectTextStroke(root),
    ...detectMixedSiblingPadding(root),
    ...detectExcessiveFrameEffects(root),
    ...detectEdgeSectionPadding(root),
    ...detectStackedHorizontalPadding(root),
    ...detectTextBgContrast(root, doc),
  ];
  const seen = new Set<string>();
  const unique: Issue[] = [];
  for (const issue of combined) {
    const key = `${issue.nodeId}:${issue.property}`;
    if (seen.has(key)) continue;
    seen.add(key);
    unique.push(issue);
  }
  return unique;
}

function checkConsistency(
  siblings: PenNode[],
  property: string,
  issues: Issue[],
  severity: 'warning' | 'info',
): void {
  const values = new Map<string, { value: unknown; nodes: PenNode[] }>();
  for (const node of siblings) {
    const raw = (node as unknown as Record<string, unknown>)[property];
    if (raw == null) continue;
    const key = JSON.stringify(raw);
    if (!values.has(key)) values.set(key, { value: raw, nodes: [] });
    values.get(key)!.nodes.push(node);
  }
  if (values.size < 2) return;

  let majority: { value: unknown; nodes: PenNode[] } | null = null;
  for (const entry of values.values()) {
    if (!majority || entry.nodes.length > majority.nodes.length) majority = entry;
  }
  if (!majority) return;

  const totalWithProp = Array.from(values.values()).reduce((s, e) => s + e.nodes.length, 0);
  if (majority.nodes.length < (totalWithProp * 2) / 3) return;

  for (const entry of values.values()) {
    if (entry === majority) continue;
    for (const node of entry.nodes) {
      issues.push({
        nodeId: node.id,
        category: 'sibling-inconsistency',
        severity,
        property,
        currentValue: (node as unknown as Record<string, unknown>)[property],
        suggestedValue: majority.value,
        reason: `inconsistent with ${majority.nodes.length} siblings`,
      });
    }
  }
}
