/**
 * ui-polish/audit.ts — Scan *.tsx files and produce a list of findings against
 * the hard rules catalogued in tokens.json.
 *
 * Design notes:
 * - Parsing is line-based and regex-based — NOT a real AST parser. This is
 *   intentional: keeps the skill zero-dep and the rules explainable. False
 *   positives are expected for exotic code; the baseline generator templates
 *   should only produce idiomatic code, so the heuristics work in practice.
 * - Each rule has a pure function `checkRxx(source, filePath, rule) → Finding[]`.
 * - Aggregation happens in `audit()` at the bottom.
 */
import path from 'node:path';
import { findFiles, readText, fileExists } from '../../../../../lib/fs.js';
import { detectThemeCapabilities, type ThemeCapabilities } from './theme-capabilities.js';
import type {
  AuditReport,
  Finding,
  HardRule,
  Severity,
  TokensFile,
  UiPolishArgs,
} from './types.js';

// ─── Tokens loading ───────────────────────────────────────────────────────

export async function loadTokens(tokensFile: string): Promise<TokensFile> {
  const raw = await readText(tokensFile);
  return JSON.parse(raw) as TokensFile;
}

// ─── className span extraction (shared with apply.ts) ────────────────────

/** className attribute spans (start/end offsets + inner text) in the source. */
export function classNameSpans(source: string): Array<{ start: number; end: number; text: string }> {
  const spans: Array<{ start: number; end: number; text: string }> = [];
  const re = /className=\{?[`"']([^`"']*)[`"']/g;
  let m: RegExpExecArray | null;
  while ((m = re.exec(source)) !== null) {
    const text = m[1];
    const start = m.index + m[0].indexOf(text);
    spans.push({ start, end: start + text.length, text });
  }
  return spans;
}

// ─── Page discovery ───────────────────────────────────────────────────────

async function discoverPages(projectPath: string): Promise<string[]> {
  const patterns = [
    'src/pages/**/*.tsx',
    'src/features/**/pages/**/*.tsx',
  ];
  const found: string[] = [];
  for (const pat of patterns) {
    const files = await findFiles(pat, { cwd: projectPath });
    found.push(...files);
  }
  // Dedup + filter tests
  return [...new Set(found)].filter(
    (f) => !f.endsWith('.test.tsx') && !f.endsWith('.spec.tsx'),
  );
}

// ─── Local-import resolver (shared component scanning) ───────────────────

/**
 * Follow 1 level of local imports from a page file. Extracts static import
 * paths matching @/components/** or @/features/** and resolves them to
 * absolute filesystem paths. Does NOT recurse into imported files' imports.
 */
async function resolveLocalImports(
  source: string,
  projectPath: string,
): Promise<string[]> {
  const importRe = /import\s+[^;]*from\s+['"](@\/(?:components|features)\/[^'"]+)['"]/g;
  const resolved: string[] = [];
  let m: RegExpExecArray | null;
  while ((m = importRe.exec(source)) !== null) {
    const importPath = m[1];
    const base = path.join(projectPath, 'src', importPath.slice(2));
    const candidates = [
      `${base}.tsx`, `${base}.ts`,
      path.join(base, 'index.tsx'), path.join(base, 'index.ts'),
    ];
    for (const c of candidates) {
      if (await fileExists(c)) {
        resolved.push(c);
        break;
      }
    }
  }
  return resolved;
}

// ─── URL → component file resolver ───────────────────────────────────────

/**
 * Resolve a URL path to component files via PageRegistry.register() data.
 * Scans src/extensions/*Registry.ts for register('<key>', lazy(() => import('<path>')))
 * and builds a reverse lookup.
 */
async function resolveUrlToFiles(
  url: string,
  projectPath: string,
): Promise<string[]> {
  const segments = url.replace(/^\//, '').split('/').filter(Boolean);
  const cleaned = segments.map(s =>
    s.startsWith(':') || s.startsWith('{') ? null : s,
  );
  let key = cleaned.filter(Boolean).join('.');
  const lastCleaned = cleaned[cleaned.length - 1];
  const secondLast = cleaned.length >= 2 ? cleaned[cleaned.length - 2] : undefined;
  if (key.endsWith('.new')) key = key.replace(/\.new$/, '.create');
  else if (lastCleaned === null && secondLast !== null) key += '.detail';

  const extensionsDir = path.join(projectPath, 'src', 'extensions');
  let registryFiles: string[];
  try {
    registryFiles = await findFiles('*Registry.ts', { cwd: extensionsDir });
  } catch {
    return [];
  }
  const registerRe = /PageRegistry\.register\(\s*['"`]([^'"`]+)['"`]\s*,[\s\S]*?import\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
  const results: string[] = [];

  for (const regFile of registryFiles) {
    let content: string;
    try {
      content = await readText(regFile);
    } catch {
      continue;
    }
    registerRe.lastIndex = 0;
    let m: RegExpExecArray | null;
    while ((m = registerRe.exec(content)) !== null) {
      if (m[1] === key || m[1].startsWith(key + '.')) {
        const importPath = m[2];
        const base = importPath.startsWith('@/')
          ? path.join(projectPath, 'src', importPath.slice(2))
          : path.join(extensionsDir, importPath);
        const candidates = [
          `${base}.tsx`, `${base}.ts`,
          path.join(base, 'index.tsx'), path.join(base, 'index.ts'),
        ];
        for (const c of candidates) {
          if (await fileExists(c)) {
            results.push(c);
            break;
          }
        }
      }
    }
  }
  return results;
}

// ─── Rule implementations ─────────────────────────────────────────────────

type RuleChecker = (
  source: string,
  filePath: string,
  rule: HardRule,
  caps?: ThemeCapabilities,
) => Finding[];

function withBase(rule: HardRule, filePath: string): Omit<Finding, 'line' | 'message' | 'snippet'> {
  return {
    ruleId: rule.id,
    severity: rule.severity,
    file: filePath,
    autoFixable: rule.autoFixable,
  };
}

/**
 * R1 — Page must use <PageTemplate>. Heuristic: PageTemplate usage anywhere in file.
 *
 * Project-aware (see theme-capabilities.ts): PageTemplate is an architecture this
 * project scaffolds — it is NOT shipped by the platform theme. If the project does
 * not define a PageTemplate component, a page "not using it" is not a per-page bug;
 * it is an app-wide architecture choice. We then downgrade to INFO (never block, and
 * the auto-fix is disabled in apply.ts so it can't import a phantom component). When
 * the component DOES exist, a page skipping it is a real ERROR as before.
 */
const checkR1: RuleChecker = (source, filePath, rule, caps) => {
  // Only check files that look like a page (export function XxxPage).
  const exportMatch = source.match(/export\s+(?:default\s+)?function\s+[A-Z]\w*Page\s*\(/);
  if (!exportMatch) return [];
  // Pass if <PageTemplate appears anywhere in the JSX (wrapped in PermissionGuard, early returns ok).
  const usesPageTemplate = /<PageTemplate[\s>]/.test(source);
  if (usesPageTemplate) return [];
  const lineNumber = source.slice(0, exportMatch.index!).split('\n').length;

  // Project does not ship a PageTemplate component → architectural gap, not a page bug.
  if (caps && !caps.hasComponent('PageTemplate')) {
    return [{
      ...withBase(rule, filePath),
      severity: 'info',
      autoFixable: false,
      line: lineNumber,
      message: `Page does not use <PageTemplate>, and this project defines no PageTemplate component (src/components/ui/PageTemplate.tsx not found). Adopting PageTemplate is an app-wide architecture decision (a migration), not a single-page fix — not enforced here. If this app should standardise on it, scaffold the layout first (scaffold-layout), then re-audit.`,
      snippet: exportMatch[0].slice(0, 60),
    }];
  }

  return [{
    ...withBase(rule, filePath),
    line: lineNumber,
    message: `Page does not render <PageTemplate> anywhere. Every page must wrap its main content in <PageTemplate title=…> from @/components/ui/PageTemplate.`,
    snippet: exportMatch[0].slice(0, 60),
  }];
};

/** R2 — No arbitrary hex in className. Matches `bg-[#xxx]`, `text-[#xxx]`, `border-[#xxx]`. */
const checkR2: RuleChecker = (source, filePath, rule) => {
  const findings: Finding[] = [];
  const hexClass = /className=\{?["'`][^"'`]*\b(?:bg|text|border|fill|stroke)-\[#[0-9a-fA-F]{3,8}\][^"'`]*["'`]/g;
  let m: RegExpExecArray | null;
  while ((m = hexClass.exec(source)) !== null) {
    const line = source.slice(0, m.index).split('\n').length;
    findings.push({
      ...withBase(rule, filePath),
      line,
      message: `className contains an arbitrary hex color. Use var(--token-name) instead.`,
      snippet: m[0].slice(0, 120),
    });
  }
  return findings;
};

/** R3 — Primary CTA must use accent-5/6 token. Heuristic: buttons with "primary" context using non-token bg. */
const checkR3: RuleChecker = (source, filePath, rule) => {
  const findings: Finding[] = [];
  // Catch obvious bad primaries: bg-blue-*, bg-indigo-*, bg-violet-* on <button>
  const badButton = /<button[^>]*className=\{?["'`][^"'`]*\bbg-(?:blue|indigo|violet|purple|green|red)-\d{3}[^"'`]*["'`]/g;
  let m: RegExpExecArray | null;
  while ((m = badButton.exec(source)) !== null) {
    const line = source.slice(0, m.index).split('\n').length;
    findings.push({
      ...withBase(rule, filePath),
      line,
      message: `<button> uses a raw Tailwind color. Primary CTAs must use the .btn .btn-primary helper class (the theme owns the accent fill + hover + radius — never re-assemble it inline).`,
      snippet: m[0].slice(0, 140),
    });
  }
  return findings;
};

/** R4 — No hardcoded user-facing strings. Heuristic: JSX text nodes with word chars not wrapped in t(). */
const checkR4: RuleChecker = (source, filePath, rule) => {
  const findings: Finding[] = [];
  // Look for >Something</ or >Something< where Something is a word+ and not {t(...)} / {expr}
  const textNode = />\s*([A-Z][a-zA-Z ]{3,}?)\s*</g;
  let m: RegExpExecArray | null;
  const seen = new Set<string>();
  while ((m = textNode.exec(source)) !== null) {
    const txt = m[1].trim();
    // Skip HTML entities, common framework text, single words in Pascal (likely component names not text).
    if (seen.has(txt)) continue;
    if (/^[A-Z][a-z]+$/.test(txt)) continue; // Single PascalCase word — often a variable, skip.
    if (['TODO', 'FIXME', 'N/A'].includes(txt)) continue;
    seen.add(txt);
    const line = source.slice(0, m.index).split('\n').length;
    findings.push({
      ...withBase(rule, filePath),
      line,
      message: `JSX contains hardcoded text "${txt}". Wrap in useTranslation('ns') + t('key').`,
      snippet: m[0].slice(0, 120),
    });
  }
  return findings;
};

/** R5 — Icons from lucide-react only. */
const checkR5: RuleChecker = (source, filePath, rule) => {
  const findings: Finding[] = [];
  const badImport = /import\s+[^;]+from\s+['"](?:@heroicons|react-icons|@fortawesome)[^'"]*['"]/g;
  let m: RegExpExecArray | null;
  while ((m = badImport.exec(source)) !== null) {
    const line = source.slice(0, m.index).split('\n').length;
    findings.push({
      ...withBase(rule, filePath),
      line,
      message: `Non-lucide icon library imported. Use lucide-react exclusively.`,
      snippet: m[0],
    });
  }
  return findings;
};

/** R6 — Loading state uses Loader2 animate-spin. Heuristic: "loading" string + non-Loader2 spinner. */
const checkR6: RuleChecker = (source, filePath, rule) => {
  // Look for "isLoading" / "loading" conditional rendering without Loader2.
  const hasLoading = /\bis?[Ll]oading\b/.test(source);
  const hasLoader2 = /\bLoader2\b/.test(source);
  const hasCustomSpinner = /className=\{?["'`][^"'`]*\banimate-spin\b[^"'`]*["'`]/.test(source);
  if (hasLoading && !hasLoader2 && hasCustomSpinner) {
    return [{
      ...withBase(rule, filePath),
      line: 1,
      message: `Page has a loading state but uses a custom spinner. Prefer <Loader2 className="animate-spin" /> from lucide-react.`,
    }];
  }
  return [];
};

/** R7 — Error banner anatomy. Catch bg-red-* error banners. */
const checkR7: RuleChecker = (source, filePath, rule) => {
  const findings: Finding[] = [];
  const badBanner = /className=\{?["'`][^"'`]*\b(?:bg-red-\d{3}|text-red-\d{3}|border-red-\d{3})[^"'`]*["'`]/g;
  let m: RegExpExecArray | null;
  while ((m = badBanner.exec(source)) !== null) {
    const line = source.slice(0, m.index).split('\n').length;
    findings.push({
      ...withBase(rule, filePath),
      line,
      message: `Error styling uses Tailwind red-*. Use bg-[var(--error-bg)] border-[var(--error-border)] text-[var(--error-text)].`,
      snippet: m[0].slice(0, 140),
    });
  }
  return findings;
};

/** R8 — Lists should use DataTable. Heuristic: file ends with ListPage.tsx, contains <table>, no <DataTable>. */
const checkR8: RuleChecker = (source, filePath, rule) => {
  if (!filePath.endsWith('ListPage.tsx')) return [];
  const hasTable = /<table[\s>]/.test(source);
  const hasDataTable = /<(?:Responsive)?DataTable[\s<>]/.test(source);
  const hasNestedSection = /rounded-\[var\(--radius-card\)\][^>]*>\s*[\s\S]*?<table/.test(source);
  if (hasTable && !hasDataTable && !hasNestedSection) {
    return [{
      ...withBase(rule, filePath),
      line: 1,
      message: `ListPage uses raw <table>. Consider <DataTable> from @/components/ui/DataTable for standard list views.`,
    }];
  }
  return [];
};

/** R9 — useParams<{id:string}>() must be null-checked. Accept classic `if (!id)` OR derived boolean (`!!id`, `id ??`, ternary with id). */
const checkR9: RuleChecker = (source, filePath, rule) => {
  const findings: Finding[] = [];
  const useParamsMatch = source.match(/const\s*\{\s*id(?:\s*:[^}]+)?\s*\}\s*=\s*useParams/);
  if (!useParamsMatch) return [];
  // Look for any of these guard patterns in the whole file:
  //  - `if (!id) ...`               → classic early-return guard
  //  - `!!id` / `!id`               → ternary / boolean derivation (e.g. `const isEdit = !!id`)
  //  - `id ?? ''` / `id ?? ...`    → nullish coalescing fallback (safe)
  //  - `id ? ... : ...`             → ternary
  //  - `id && ...`                  → short-circuit
  const patterns = [
    /if\s*\(\s*!\s*\w+\s*\)/,      // if (!id) or if (!tenantId) etc.
    /!!\s*\w+\b/,                  // !!id somewhere
    /\bid\s*\?\?\s*/,              // id ?? '...'
    /\bid\s*\?\s*[^:]+:/,          // id ? x : y
    /\bid\s*&&\s*/,                // id && x
    /\bid\s*!==?\s*['"`]?\w*['"`]?/, // id !== 'new' or id !== undefined etc.
  ];
  const hasGuard = patterns.some((r) => r.test(source));
  if (!hasGuard) {
    const line = source.slice(0, useParamsMatch.index!).split('\n').length;
    findings.push({
      ...withBase(rule, filePath),
      line,
      message: `useParams destructures "id" but no null-check pattern (if (!id), !!id, id ??, id ?:, id &&, id !== …) found in the file.`,
      snippet: useParamsMatch[0],
    });
  }
  return findings;
};

/** R10 — Permission key must be {module}.{section}.{action}, no appCode prefix. */
const checkR10: RuleChecker = (source, filePath, rule) => {
  const findings: Finding[] = [];
  const permRegex = /permission=\{?["'`]([a-zA-Z0-9._-]+)["'`]/g;
  let m: RegExpExecArray | null;
  while ((m = permRegex.exec(source)) !== null) {
    const key = m[1];
    const parts = key.split('.');
    const line = source.slice(0, m.index).split('\n').length;
    // Valid: 3 segments (module.section.action). Also tolerate 2 (module.action) for simple modules.
    if (parts.length < 2 || parts.length > 3) {
      findings.push({
        ...withBase(rule, filePath),
        line,
        message: `Permission key "${key}" has ${parts.length} segments. Expected {module}.{section}.{action} (3 segments).`,
        snippet: m[0],
      });
    }
    // Detect appCode prefix: 4 segments OR known bad shapes.
    if (parts.length === 4) {
      findings.push({
        ...withBase(rule, filePath),
        line,
        message: `Permission key "${key}" has 4 segments — likely contains appCode prefix. Strip the leading {appCode}. to match {module}.{section}.{action}.`,
        snippet: m[0],
      });
    }
    // Detect underscores instead of dots.
    if (key.includes('_') && !key.includes('.')) {
      findings.push({
        ...withBase(rule, filePath),
        line,
        message: `Permission key "${key}" uses underscores. Expected dot-separated: {module}.{section}.{action}.`,
        snippet: m[0],
      });
    }
  }
  return findings;
};

/** R11 — Pages SHOULD expose Slot injection points. Heuristic: ListPage/DetailPage without <Slot />. */
const checkR11: RuleChecker = (source, filePath, rule, caps) => {
  // Slots live inside the PageTemplate/extension architecture. If the project does
  // not even ship PageTemplate, the slot pattern is moot here — don't recommend an
  // extension surface for an architecture the app hasn't adopted (and apply.ts
  // won't inject a Slot import to a package that may be absent).
  if (caps && !caps.hasComponent('PageTemplate')) return [];
  const isList = filePath.endsWith('ListPage.tsx');
  const isDetail = filePath.endsWith('DetailPage.tsx');
  const isForm = filePath.endsWith('CreatePage.tsx') || filePath.endsWith('EditPage.tsx') || filePath.endsWith('FormPage.tsx');
  if (!isList && !isDetail && !isForm) return [];
  const hasSlot = /<Slot[\s>]/.test(source);
  if (!hasSlot) {
    return [{
      ...withBase(rule, filePath),
      line: 1,
      message: `Page does not expose any <Slot name="…" /> injection point. Recommend adding header.actions / table.columns / form.fields slots per tokens.json extensionPattern.`,
    }];
  }
  return [];
};

/** R12 — Dark theme base must be #161618. Catch hardcoded #0a0a0a / black in dark-mode context. */
const checkR12: RuleChecker = (source, filePath, rule) => {
  const findings: Finding[] = [];
  const badDark = /#(?:0[0a-f]{5}|000(?:000)?)\b/gi;
  let m: RegExpExecArray | null;
  while ((m = badDark.exec(source)) !== null) {
    // Only flag if preceded by "dark" within 60 chars
    const ctxStart = Math.max(0, m.index - 60);
    const ctx = source.slice(ctxStart, m.index);
    if (/\bdark\b/i.test(ctx)) {
      const line = source.slice(0, m.index).split('\n').length;
      findings.push({
        ...withBase(rule, filePath),
        line,
        message: `Dark-mode context references ${m[0]} — design system mandates #161618 as --bg-app.`,
        snippet: m[0],
      });
    }
  }
  return findings;
};

/** R13 — Status badge anatomy — catch bg-green-500/text-white as status badges. */
const checkR13: RuleChecker = (source, filePath, rule) => {
  const findings: Finding[] = [];
  const badBadge = /className=\{?["'`][^"'`]*\b(?:bg-(?:green|yellow|red|blue)-\d{3})[^"'`]*\b(?:rounded|badge)[^"'`]*["'`]/g;
  let m: RegExpExecArray | null;
  while ((m = badBadge.exec(source)) !== null) {
    const line = source.slice(0, m.index).split('\n').length;
    findings.push({
      ...withBase(rule, filePath),
      line,
      message: `Status badge uses raw Tailwind color. Use bg-[var(--<status>-bg)] text-[var(--<status>-text)] border-[var(--<status>-border)] (or .badge-<status>).`,
      snippet: m[0].slice(0, 140),
    });
  }
  return findings;
};

// ─── R14 / R15 — comprehensive hardcoded-color detection ───────────────────
//
// These two rules are the general net that complements the context-specific
// color rules (R3 button, R7 banner, R13 badge): they flag a raw Tailwind color
// utility ANYWHERE in a className, on ANY prefix.

/** Tailwind utility prefixes that accept a palette color. */
const COLOR_PREFIXES =
  'bg|text|border|ring|fill|stroke|outline|divide|decoration|placeholder|caret|accent|from|via|to';
/** Saturated / semantic families — a design token exists for these. */
const SATURATED_FAMILIES =
  'red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose';
/** Neutral families — only SURFACE tokens exist (var(--bg-*)); text/border have none yet. */
const NEUTRAL_FAMILIES = 'slate|gray|zinc|neutral|stone';

/**
 * Scan every className="…" / className={`…`} value and emit one finding per
 * hardcoded color utility whose family is in `familyAlternation`. Shared by R14
 * (saturated) and R15 (neutral). When `includeBW` is true, bare `white`/`black`
 * (which carry no shade) are matched too.
 */
function scanColorUtilities(
  source: string,
  filePath: string,
  rule: HardRule,
  familyAlternation: string,
  includeBW: boolean,
): Finding[] {
  const findings: Finding[] = [];
  const classAttr = /className=\{?[`"']([^`"']*)[`"']/g;
  const inner = `(?:${familyAlternation})-\\d{2,3}${includeBW ? '|white|black' : ''}`;
  let attr: RegExpExecArray | null;
  while ((attr = classAttr.exec(source)) !== null) {
    const classList = attr[1];
    const attrValueStart = attr.index + attr[0].indexOf(classList);
    // Fresh regex per attribute (stateful `lastIndex`).
    const utilRe = new RegExp(`\\b(?:${COLOR_PREFIXES})-(?:${inner})(?:\\/\\d{1,3})?\\b`, 'g');
    let u: RegExpExecArray | null;
    while ((u = utilRe.exec(classList)) !== null) {
      const absIndex = attrValueStart + u.index;
      const line = source.slice(0, absIndex).split('\n').length;
      findings.push({
        ...withBase(rule, filePath),
        line,
        message: `className uses hardcoded color "${u[0]}". Replace with a design token (var(--<status>-bg|text|border), var(--color-accent-<shade>) or var(--bg-*)).`,
        snippet: u[0],
      });
    }
  }
  return findings;
}

/** R14 — any saturated/semantic Tailwind color utility, anywhere in className. */
const checkR14: RuleChecker = (source, filePath, rule) =>
  scanColorUtilities(source, filePath, rule, SATURATED_FAMILIES, false);

/** R15 — any neutral Tailwind color utility (incl. white/black), anywhere in className. */
const checkR15: RuleChecker = (source, filePath, rule) =>
  scanColorUtilities(source, filePath, rule, NEUTRAL_FAMILIES, true);

/**
 * R16 — status token bound to the wrong utility prefix. Unlike R14 (Tailwind
 * families) and R2 (hex), the colour here is ALREADY a var(--token) — the bug is
 * that a token is used outside its role:
 *   - bg-[var(--<status>-text)]  → a saturated foreground used as a fill (illegible)
 *   - text-[var(--<status>-bg)]  → a pale surface used as text (invisible)
 * Both collapse contrast, most visibly in dark mode.
 */
const STATUS_NAMES = 'success|warning|error|info';
const checkR16: RuleChecker = (source, filePath, rule) => {
  const findings: Finding[] = [];
  const misuse = new RegExp(
    `\\bbg-\\[var\\(--(?:${STATUS_NAMES})-text\\)\\]|\\btext-\\[var\\(--(?:${STATUS_NAMES})-bg\\)\\]`,
    'g',
  );
  let m: RegExpExecArray | null;
  while ((m = misuse.exec(source)) !== null) {
    const line = source.slice(0, m.index).split('\n').length;
    const util = m[0];
    findings.push({
      ...withBase(rule, filePath),
      line,
      message: `Status token used in the wrong role: "${util}". A --<status>-text token is a saturated foreground (bind it to text-…); a --<status>-bg token is a pale surface (bind it to bg-…). For a badge/banner use the soft triplet bg-[var(--<status>-bg)] text-[var(--<status>-text)] border-[var(--<status>-border)].`,
      snippet: util,
    });
  }
  return findings;
};

/**
 * R17 — status token used as a DECORATIVE / categorical color. Unlike R16
 * (utility-role misuse), the token is bound to the CORRECT utility — the bug is
 * SEMANTIC: a status token (--success|--warning|--error|--info) is aliased to a
 * decorative color-name key (`blue`, `green`, `yellow`, …) in a color map, so a
 * neutral metric inherits a status meaning it does not have (a count rendered in
 * --warning reads as an alert; --success/--error imply ok/bad). This is exactly
 * the `colorClasses = { blue: 'bg-[var(--info-bg)]', yellow: '…--warning…' }`
 * stat-card pattern: every value is a var(--token) so R2 (hex) and R14 (Tailwind
 * family) stay silent, and the utility binding is right so R16 stays silent too.
 *
 * Heuristic: a color-family-named object key whose STRING value contains
 * var(--<status>-…). Precise for the common color-map pattern. Decorative keys
 * mapped to var(--color-accent-*) / var(--bg-*) are NOT flagged — accent and
 * neutral surfaces are the legitimate decorative palette. An inline, non-mapped
 * decorative status token is intentionally NOT flagged (undecidable by regex).
 */
const DECORATIVE_COLOR_KEYS =
  'red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose';
const checkR17: RuleChecker = (source, filePath, rule) => {
  const findings: Finding[] = [];
  // <colorKey>: '<…var(--<status>-…)>'  — value must be a string literal so a
  // type annotation like `teal: string = …` cannot false-match.
  const decorAsStatus = new RegExp(
    `(['"\`]?)(${DECORATIVE_COLOR_KEYS})\\1\\s*:\\s*['"\`][^'"\`]*?var\\(--(${STATUS_NAMES})-`,
    'g',
  );
  let m: RegExpExecArray | null;
  while ((m = decorAsStatus.exec(source)) !== null) {
    const line = source.slice(0, m.index).split('\n').length;
    const colorKey = m[2];
    const status = m[3];
    findings.push({
      ...withBase(rule, filePath),
      line,
      message: `Decorative key "${colorKey}" is mapped to the status token var(--${status}-…). Status tokens (success/warning/error/info) carry meaning — using them as a categorical/decorative palette mislabels neutral data (a count shown in --warning reads as an alert). Use var(--color-accent-<shade>) or a neutral surface for decorative categories, and reserve --<status>-* for real status (e.g. an active/inactive badge).`,
      snippet: m[0].slice(0, 80),
    });
  }
  return findings;
};

/**
 * R18 — hardcoded Tailwind color utilities OUTSIDE className attributes.
 * Catches colors in JS data structures (objects, arrays, config maps) that
 * escape the className-scoped R14/R15 scanners. Uses classNameSpans() to
 * build exclusion zones — never fires on strings already inside className.
 */
const checkR18: RuleChecker = (source, filePath, rule) => {
  const findings: Finding[] = [];
  const cnSpans = classNameSpans(source);
  const isInClassName = (offset: number): boolean =>
    cnSpans.some((s) => offset >= s.start && offset < s.end);

  const stringLiteral = /(['"`])([^'"`]*?)\1/g;
  const colorUtil = new RegExp(
    `\\b(?:${COLOR_PREFIXES})-(?:(?:${SATURATED_FAMILIES}|${NEUTRAL_FAMILIES})-\\d{2,3}|white|black)(?:\\/\\d{1,3})?\\b`,
    'g',
  );

  let strMatch: RegExpExecArray | null;
  while ((strMatch = stringLiteral.exec(source)) !== null) {
    const strContent = strMatch[2];
    const strContentStart = strMatch.index + 1;
    if (isInClassName(strContentStart)) continue;

    colorUtil.lastIndex = 0;
    let colorMatch: RegExpExecArray | null;
    while ((colorMatch = colorUtil.exec(strContent)) !== null) {
      const absOffset = strContentStart + colorMatch.index;
      if (isInClassName(absOffset)) continue;
      const line = source.slice(0, absOffset).split('\n').length;
      findings.push({
        ...withBase(rule, filePath),
        line,
        message: `String literal outside className contains hardcoded color "${colorMatch[0]}". This Tailwind utility will render with a hardcoded color bypassing the design token system.`,
        snippet: strMatch[0].slice(0, 120),
      });
    }
  }
  return findings;
};

/**
 * R19 — hex/rgb/rgba colors in inline style={{ }} props.
 * These bypass the design token system and do not respond to theme/dark mode.
 * Only flags style blocks that contain a color-related CSS property.
 */
const checkR19: RuleChecker = (source, filePath, rule) => {
  const findings: Finding[] = [];
  const styleBlock = /style=\{\{([^}]*(?:\{[^}]*\}[^}]*)*)\}\}/g;
  const hexOrRgb = /#[0-9a-fA-F]{3,8}\b|rgba?\s*\(/g;
  const colorProps = /(?:background(?:Color)?|color|border(?:Color|LeftColor|RightColor|TopColor|BottomColor)?|fill|stroke|outline(?:Color)?|boxShadow|textShadow|textDecoration(?:Color)?)\s*:/i;

  let styleMatch: RegExpExecArray | null;
  while ((styleMatch = styleBlock.exec(source)) !== null) {
    const content = styleMatch[1];
    if (!colorProps.test(content)) continue;
    hexOrRgb.lastIndex = 0;
    const colorMatch = hexOrRgb.exec(content);
    if (!colorMatch) continue;
    const line = source.slice(0, styleMatch.index).split('\n').length;
    findings.push({
      ...withBase(rule, filePath),
      line,
      message: `Inline style contains hardcoded color "${colorMatch[0].slice(0, 20)}". Inline hex/rgb values bypass the design token system and do not respond to theme or dark mode changes.`,
      snippet: styleMatch[0].slice(0, 140),
    });
  }
  return findings;
};

/**
 * R20 — accent token overuse on non-CTA elements. Three sub-heuristics:
 * A) thick accent border (border-2+) on non-button
 * B) pale accent (50/100) as section/header background
 * C) full accent fill (shade ≥ 500) on non-button
 */
const checkR20: RuleChecker = (source, filePath, rule) => {
  const findings: Finding[] = [];
  const lines = source.split('\n');

  for (let i = 0; i < lines.length; i++) {
    const ln = lines[i];
    const lineNum = i + 1;
    const ctx = lines.slice(Math.max(0, i - 3), i + 1).join('\n');

    // A) thick accent border on non-button
    if (/\bborder-[2-8]\b/.test(ln) && /\bborder-\[var\(--color-accent-/.test(ln)) {
      if (!/<button\b/i.test(ctx) && !/\bbtn\b/.test(ctx)) {
        findings.push({
          ...withBase(rule, filePath),
          line: lineNum,
          message: `Thick accent border (border-2+) on a non-button element creates visual noise. Use border (1px) with a subtle accent, or use a neutral border (var(--border-color)) for containers.`,
          snippet: ln.trim().slice(0, 120),
        });
      }
    }

    // B) pale accent as section/header background
    if (/\bbg-\[var\(--color-accent-(?:50|100)\)\]/.test(ln)) {
      if (/<(?:section|header|div)\b/i.test(ctx) || /\b(?:header|section|banner|hero)\b/i.test(ctx)) {
        findings.push({
          ...withBase(rule, filePath),
          line: lineNum,
          message: `Pale accent (accent-50/100) used as section/header background. Use var(--bg-secondary) or var(--bg-muted) for container backgrounds — reserve accent tints for interactive focus/hover states.`,
          snippet: ln.trim().slice(0, 120),
        });
      }
    }

    // C) full accent fill (shade >= 500) on non-button
    if (/\bbg-\[var\(--color-accent-[5-9]\d{2}\)\]/.test(ln)) {
      if (!/<button\b/i.test(ctx) && !/\bbtn\b/.test(ctx)) {
        findings.push({
          ...withBase(rule, filePath),
          line: lineNum,
          message: `Full accent fill (shade ≥ 500) on a non-button element. Reserve full accent backgrounds for primary CTAs. Use accent-50/100 for decorative tints or var(--bg-secondary) for containers.`,
          snippet: ln.trim().slice(0, 120),
        });
      }
    }
  }

  return findings;
};

/**
 * R21 — primary CTA reinvented inline instead of the .btn .btn-primary helper.
 * A full accent fill (bg-[var(--color-accent-500|600)]) on a <button> that does NOT
 * carry the `btn` helper class means the theme's button primitive was hand-rolled.
 * The theme owns the fill + hover + radius + padding; re-assembling it inline drifts
 * (wrong shade, missing hover/dark handling) and bypasses the single source of truth.
 * Only fires when the project actually ships .btn-primary (it does, by platform
 * baseline). Complements R20-C (the same fill on a NON-button → overuse).
 */
const checkR21: RuleChecker = (source, filePath, rule, caps) => {
  if (caps && !caps.helperClasses.has('btn-primary')) return [];
  const findings: Finding[] = [];
  const btnRe = /<button\b[^>]*className=\{?["'`]([^"'`]*)["'`]/g;
  let m: RegExpExecArray | null;
  while ((m = btnRe.exec(source)) !== null) {
    const classList = m[1];
    const hasAccentFill = /\bbg-\[var\(--color-accent-(?:500|600)\)\]/.test(classList);
    const usesHelper = /\bbtn\b/.test(classList);
    if (hasAccentFill && !usesHelper) {
      const line = source.slice(0, m.index).split('\n').length;
      findings.push({
        ...withBase(rule, filePath),
        line,
        message: `Primary CTA reinvents the theme button inline (bg-[var(--color-accent-…)] text-white …). Use the .btn .btn-primary helper class — the theme owns the accent fill, hover, radius and padding. Keep only genuinely-extra utilities (e.g. w-full).`,
        snippet: m[0].slice(0, 140),
      });
    }
  }
  return findings;
};

/**
 * R22 — ad-hoc accent tint that bypasses the soft-accent primitive. An
 * opacity-modified accent (bg-[var(--color-accent-500)]/10, text-…/70, …) is a
 * hand-rolled pale tint where the theme already provides a named soft surface
 * (.badge-accent / the --accent-bg|text|border triplet). Ad-hoc opacities drift
 * between components and do not dark-adapt. Only fires when the project ships the
 * soft-accent primitive. Distinct from R20-B (accent-50/100 used as a SECTION bg).
 */
const checkR22: RuleChecker = (source, filePath, rule, caps) => {
  const hasSoftAccent = !caps
    || caps.helperClasses.has('badge-accent')
    || caps.tokens.has('--accent-bg');
  if (!hasSoftAccent) return [];
  const findings: Finding[] = [];
  const classAttr = /className=\{?[`"']([^`"']*)[`"']/g;
  const tintRe = /\b(?:bg|text|border|ring|fill|stroke)-\[var\(--color-accent-\d{2,3}\)\]\/\d{1,3}\b/g;
  let attr: RegExpExecArray | null;
  while ((attr = classAttr.exec(source)) !== null) {
    const classList = attr[1];
    const attrValueStart = attr.index + attr[0].indexOf(classList);
    tintRe.lastIndex = 0;
    let u: RegExpExecArray | null;
    while ((u = tintRe.exec(classList)) !== null) {
      const absIndex = attrValueStart + u.index;
      const line = source.slice(0, absIndex).split('\n').length;
      findings.push({
        ...withBase(rule, filePath),
        line,
        message: `Ad-hoc accent tint "${u[0]}" bypasses the soft-accent primitive. Use .badge-accent or the soft triplet bg-[var(--accent-bg)] text-[var(--accent-text)] border-[var(--accent-border)] — a named surface that dark-adapts, instead of an arbitrary opacity.`,
        snippet: u[0],
      });
    }
  }
  return findings;
};

/**
 * R24 — dangling CSS-token reference. A `var(--x)` in className whose token is not
 * defined in the project's theme resolves to nothing (or an inherited value),
 * rendering inconsistently from one page to the next — the opposite of a harmonised
 * UI. Project-aware and SAFE: only runs when caps.tokensComplete (the theme lives in
 * the project's own CSS, so `tokens` is authoritative) and skips package-owned token
 * families (Shoelace `--sl-*`, Tailwind `--tw-*`) it cannot see. Only bare `var(--x)`
 * (no fallback) is flagged — a hard dependency on a token that does not exist.
 */
const checkR24: RuleChecker = (source, filePath, rule, caps) => {
  if (!caps || !caps.tokensComplete) return [];
  const findings: Finding[] = [];
  const classAttr = /className=\{?[`"']([^`"']*)[`"']/g;
  const varRef = /var\(\s*(--[a-z][a-z0-9-]*)\s*\)/g; // bare ref, no fallback
  let attr: RegExpExecArray | null;
  while ((attr = classAttr.exec(source)) !== null) {
    const classList = attr[1];
    const attrValueStart = attr.index + attr[0].indexOf(classList);
    varRef.lastIndex = 0;
    let v: RegExpExecArray | null;
    while ((v = varRef.exec(classList)) !== null) {
      const token = v[1];
      if (caps.tokens.has(token)) continue;
      if (token.startsWith('--sl-') || token.startsWith('--tw-')) continue; // package-owned, not scanned
      const absIndex = attrValueStart + v.index;
      const line = source.slice(0, absIndex).split('\n').length;
      findings.push({
        ...withBase(rule, filePath),
        line,
        message: `className references the CSS token "${token}", which is not defined in this project's theme — it resolves to nothing (or an inherited value) and renders inconsistently across pages. Use a defined token (e.g. --color-accent-<shade>, --bg-app|card|muted, --text-primary|secondary, --border-color, --<status>-bg|text|border) or add it via scaffold-theme.`,
        snippet: v[0],
      });
    }
  }
  return findings;
};

/**
 * R25 — page sets its own content WIDTH instead of declaring it via the layout.
 * Page width is a layout concern owned by <PageTemplate width="standard|focused">,
 * not the page. Hand-rolled widths (Tailwind `container`, a page-root `max-w-2xl+
 * mx-auto`) make every module render at a different measure — the app feels like it
 * jumps between UIs. Only two widths are sanctioned: standard (full) and focused
 * (centered cap); container / max-w-* / 3-4 / arbitrary are banned.
 *
 * SAFE: only on page files, only when the project uses PageTemplate (so the width
 * contract exists to move to), and modal/overlay containers (fixed / inset-0 — whose
 * max-w IS legitimate) are skipped. Small caps (max-w-md/lg/xl) are not flagged.
 */
const checkR25: RuleChecker = (source, filePath, rule, caps) => {
  if (!caps || !caps.hasComponent('PageTemplate')) return [];
  const isPage =
    /export\s+(?:default\s+)?function\s+[A-Z]\w*Page\s*\(/.test(source) || filePath.endsWith('Page.tsx');
  if (!isPage) return [];
  const findings: Finding[] = [];
  const classAttr = /className=\{?[`"']([^`"']*)[`"']/g;
  let m: RegExpExecArray | null;
  while ((m = classAttr.exec(source)) !== null) {
    const cls = m[1];
    // Skip modal/overlay backdrops AND styled panels/cards — capping their width is
    // legitimate. A genuine page-width wrapper is a TRANSPARENT layout constraint
    // (no background, no rounding); a modal/card panel carries bg + rounded. (The
    // panel's max-w often lives on a child of the `fixed inset-0` backdrop, so the
    // per-element overlay check alone isn't enough.)
    if (/\b(?:fixed|inset-0)\b/.test(cls)) continue;
    if (/\bbg-\[var\(--bg-/.test(cls) || /\brounded(?:-|\b)/.test(cls)) continue;
    const hasContainer = /\bcontainer\b/.test(cls) && /\bmx-auto\b/.test(cls);
    const bigMaxW = cls.match(/\bmax-w-(?:2xl|3xl|4xl|5xl|6xl|7xl|screen-\w+)\b/);
    const hasPageMaxW = bigMaxW !== null && /\bmx-auto\b/.test(cls);
    if (!hasContainer && !hasPageMaxW) continue;
    const offender = hasContainer ? 'container mx-auto' : `${bigMaxW![0]} mx-auto`;
    const line = source.slice(0, m.index).split('\n').length;
    findings.push({
      ...withBase(rule, filePath),
      line,
      message: `Page hard-codes its content width ("${offender}"). Width is owned by the layout, not the page — declare <PageTemplate width="focused"> (full-width "standard" is the default). Only two widths are sanctioned: standard (full) and focused (centered max-w); container / max-w-* / fractional page widths are banned. If your PageTemplate predates the width prop, re-run scaffold-layout.`,
      snippet: cls.slice(0, 120),
    });
  }
  return findings;
};

/**
 * R26 — native form control used instead of a theme-compliant primitive.
 * Native <input type="date|datetime-local">, <select> and <select multiple>
 * render browser-chrome widgets (calendar popup, option list) that ignore the
 * design system: corners (--radius-input), surface (--bg-card), borders, focus
 * ring and dark mode all differ per OS/browser. Recommend the scaffold-ui-
 * primitives DateInput / EnumSelect / MultiSelect.
 *
 * SAFE & project-aware (like R1/R21/R22): only on page files, and each control
 * is flagged ONLY when the matching primitive exists in the project — never
 * demands a component the app lacks. Report-only: the swap changes props
 * (options[], ISO value, string[]), so it is not a mechanical auto-fix.
 * R23 stays reserved for segmented-control anatomy.
 */
const checkR26: RuleChecker = (source, filePath, rule, caps) => {
  const isPage =
    /export\s+(?:default\s+)?function\s+[A-Z]\w*Page\s*\(/.test(source) || filePath.endsWith('Page.tsx');
  if (!isPage) return [];
  // Project-aware: when a capability set is present, only flag a control whose
  // replacement primitive the project actually ships. Without caps (e.g. unit
  // exercise), default to flagging so the rule stays testable.
  const ships = (name: string) => !caps || caps.hasComponent(name);
  const findings: Finding[] = [];

  // Native date / datetime inputs → <DateInput>
  if (ships('DateInput')) {
    const dateRe = /<input\b[^>]*?\btype=["'](datetime-local|date)["'][^>]*?>/g;
    let m: RegExpExecArray | null;
    while ((m = dateRe.exec(source)) !== null) {
      const line = source.slice(0, m.index).split('\n').length;
      findings.push({
        ...withBase(rule, filePath),
        line,
        message: `Native <input type="${m[1]}"> renders a browser-chrome calendar that ignores the theme (corners --radius-input, surface --bg-card, focus ring and dark mode differ per OS/browser). Use the theme-compliant <DateInput> from @/components/ui/DateInput (ISO string value/onChange).`,
        snippet: m[0].slice(0, 80),
      });
    }
  }

  // Native <select> → <EnumSelect> (single) / <MultiSelect> (multiple)
  const selectRe = /<select\b/g;
  let s: RegExpExecArray | null;
  while ((s = selectRe.exec(source)) !== null) {
    const head = source.slice(s.index, s.index + 120);
    const isMulti = /\bmultiple\b/.test(head);
    const want = isMulti ? 'MultiSelect' : 'EnumSelect';
    if (!ships(want)) continue;
    const line = source.slice(0, s.index).split('\n').length;
    findings.push({
      ...withBase(rule, filePath),
      line,
      message: isMulti
        ? `Native <select multiple> renders a browser-chrome list that ignores the theme. Use the theme-compliant <MultiSelect> from @/components/ui/MultiSelect (string[] value/onChange).`
        : `Native <select> renders a browser-chrome dropdown that ignores the theme. Use the theme-compliant <EnumSelect> from @/components/ui/EnumSelect (options[] + string value/onChange).`,
      snippet: head.slice(0, 80),
    });
  }
  return findings;
};

/**
 * R27 — list pages should use <ResponsiveDataTable> (the responsive-columns +
 * truncation-tooltip wrapper) rather than a bare <DataTable>. The wrapper keeps
 * the default columns (code, label, actions) always visible, reveals the rest as
 * the viewport widens, and shows a tooltip when a cell value is truncated.
 * Anti-gate: WARNING only. Gated to *ListPage.tsx like R8. A page already using
 * <ResponsiveDataTable> (or with no DataTable at all) is never flagged.
 */
const checkR27: RuleChecker = (source, filePath, rule) => {
  if (!filePath.endsWith('ListPage.tsx')) return [];
  const usesResponsive = /<ResponsiveDataTable[\s<>]/.test(source);
  // Bare <DataTable — the leading char before "DataTable" is '<', so this does
  // NOT match inside "ResponsiveDataTable" (preceded by a letter).
  const usesBareDataTable = /<DataTable[\s<>]/.test(source);
  if (usesBareDataTable && !usesResponsive) {
    return [{
      ...withBase(rule, filePath),
      line: 1,
      message: `ListPage renders a bare <DataTable>. Use <ResponsiveDataTable> from @/components/ui/ResponsiveDataTable so columns adapt to the available width (code/label/actions stay visible, extra columns appear as the screen widens) and long cell values get an ellipsis + tooltip. Re-run scaffold-component with --overwrite (and scaffold-ui-primitives to add the wrapper).`,
    }];
  }
  return [];
};

const CHECKERS: Record<string, RuleChecker> = {
  'R1-pagetemplate': checkR1,
  'R2-no-hex-colors': checkR2,
  'R3-primary-cta': checkR3,
  'R4-i18n-namespace': checkR4,
  'R5-lucide-icons': checkR5,
  'R6-loader2-spinner': checkR6,
  'R7-error-banner-anatomy': checkR7,
  'R8-datatable-for-lists': checkR8,
  'R9-useparams-nullcheck': checkR9,
  'R10-permission-key-format': checkR10,
  'R11-slot-anatomy': checkR11,
  'R12-darktheme-base': checkR12,
  'R13-badge-anatomy': checkR13,
  'R14-no-hardcoded-colors': checkR14,
  'R15-no-neutral-hardcoded-colors': checkR15,
  'R16-token-role-misuse': checkR16,
  'R17-status-token-as-decoration': checkR17,
  'R18-colors-outside-classname': checkR18,
  'R19-hex-in-inline-style': checkR19,
  'R20-accent-overuse': checkR20,
  'R21-cta-inline-bypass': checkR21,
  'R22-soft-accent-bypass': checkR22,
  'R24-dangling-token-ref': checkR24,
  'R25-content-width': checkR25,
  'R26-native-form-control': checkR26,
  'R27-responsive-datatable': checkR27,
};

// ─── Entry point ──────────────────────────────────────────────────────────

export async function audit(args: UiPolishArgs, tokens: TokensFile): Promise<AuditReport> {
  const projectPath = path.resolve(args.projectPath);
  // When explicit page paths are supplied (--page), scan only those (resolved
  // to absolute, relative to the project root). Otherwise discover all pages.
  const files = args.paths.length > 0
    ? args.paths.map((p) => (path.isAbsolute(p) ? p : path.join(projectPath, p)))
    : await discoverPages(projectPath);

  // Resolve --url arguments to file paths via PageRegistry
  if (args.urls && args.urls.length > 0) {
    for (const url of args.urls) {
      const resolved = await resolveUrlToFiles(url, projectPath);
      for (const f of resolved) {
        if (!files.includes(f)) files.push(f);
      }
    }
  }

  // Shared component expansion: when scanning specific pages (--page / --url),
  // also scan their 1st-level local imports so violations in shared components
  // are visible. Only follows @/components/** and @/features/** — no recursion.
  if (args.paths.length > 0 || (args.urls && args.urls.length > 0)) {
    const componentFiles = new Set<string>();
    for (const file of [...files]) {
      const abs = path.resolve(file);
      if (!(await fileExists(abs))) continue;
      const src = await readText(abs);
      const imports = await resolveLocalImports(src, projectPath);
      for (const imp of imports) {
        if (!files.includes(imp)) componentFiles.add(imp);
      }
    }
    files.push(...componentFiles);
  }

  // Filter rules to the subset requested
  const wantRules = args.rules.length === 0
    ? tokens.hardRules
    : tokens.hardRules.filter((r) => {
        return args.rules.some((req) => r.id === req || r.id.startsWith(req + '-') || r.id.split('-')[0] === req);
      });

  // Project-aware conformance context: what helper classes / tokens / components
  // THIS project actually provides. Lets rules flag bypass of primitives the app
  // ships, and stop demanding (or auto-importing) components the app lacks.
  const caps = await detectThemeCapabilities(projectPath, tokens);

  const findings: Finding[] = [];
  for (const file of files) {
    const abs = path.resolve(file);
    if (!(await fileExists(abs))) continue;
    const source = await readText(abs);
    const rel = path.relative(projectPath, abs).replace(/\\/g, '/');
    for (const rule of wantRules) {
      const checker = CHECKERS[rule.id];
      if (!checker) continue;
      const hits = checker(source, rel, rule, caps);
      findings.push(...hits);
    }
  }

  // Dedup: the general color nets (R14/R15) defer to the context-specific color
  // rules (R3 button, R7 banner, R13 badge). When a precise rule already flagged
  // the SAME color util at the same (file, line), drop the R14/R15 duplicate so
  // the user sees one actionable finding. Keyed on the exact color token — never
  // on the line alone — so a second, distinct hardcoded color on the same line is
  // still reported.
  const SPECIFIC_COLOR_RULES = new Set([
    'R3-primary-cta',
    'R7-error-banner-anatomy',
    'R13-badge-anatomy',
  ]);
  const colorUtilRe = new RegExp(
    `\\b(?:${COLOR_PREFIXES})-(?:(?:${SATURATED_FAMILIES}|${NEUTRAL_FAMILIES})-\\d{2,3}|white|black)(?:\\/\\d{1,3})?\\b`,
  );
  const claimed = new Set<string>();
  for (const f of findings) {
    if (!SPECIFIC_COLOR_RULES.has(f.ruleId)) continue;
    const util = f.snippet?.match(colorUtilRe)?.[0];
    if (util) claimed.add(`${f.file}:${f.line}:${util}`);
  }
  const visible = findings.filter((f) => {
    const isGeneralNet =
      f.ruleId === 'R14-no-hardcoded-colors' || f.ruleId === 'R15-no-neutral-hardcoded-colors';
    return !(isGeneralNet && claimed.has(`${f.file}:${f.line}:${f.snippet}`));
  });

  // Aggregate counts
  const counts: AuditReport['counts'] = { errors: 0, warnings: 0, infos: 0 };
  const byRule: Record<string, number> = {};
  for (const f of visible) {
    if (f.severity === 'error') counts.errors++;
    else if (f.severity === 'warning') counts.warnings++;
    else counts.infos++;
    byRule[f.ruleId] = (byRule[f.ruleId] ?? 0) + 1;
  }

  return {
    projectPath,
    filesInspected: files.length,
    findings: visible,
    counts,
    byRule,
  };
}

// ─── Helpers exported for apply.ts ────────────────────────────────────────

export { CHECKERS, discoverPages };
