/**
 * ui-polish/apply.ts — Mechanical rewriter for auto-fixable findings.
 *
 * Each rule with `autoFixable: true` in tokens.json has a `fixRxx(source, finding) → string | null` helper
 * that returns the patched source, or null if the fix can't be applied safely.
 * The dispatcher in `apply()` reads an AuditReport, groups findings by file,
 * applies all fixable findings to that file, and writes back.
 *
 * Safety guarantees:
 * - Only rules flagged `autoFixable` get rewrites — others are returned as `fixesSkipped`.
 * - If ANY fix throws for a file, NO writes happen for that file (atomicity per file).
 * - Source must round-trip (source -> fix1 -> fix2 ... -> written). We keep the rewrites
 *   line-based where possible to avoid parser fragility.
 */
import path from 'node:path';
import { exec } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { readText, writeText, findFiles } from '../../../../../lib/fs.js';
import { audit, classNameSpans } from './audit.js';
import { detectThemeCapabilities, type ThemeCapabilities } from './theme-capabilities.js';
import type {
  ApplyReport,
  Finding,
  TokensFile,
  UiPolishArgs,
} from './types.js';

type FixFn = (source: string, finding: Finding, caps?: ThemeCapabilities) => string | null;

/**
 * Utilities the `.btn .btn-primary` helper already owns — stripped when a <button>
 * is switched to the helper class. Conservative ON PURPOSE: only the colour identity
 * + the button radius, never layout/spacing (gap/flex/padding are kept so icon and
 * label alignment can't break). The SKILL.md apply-diff review tidies the residue.
 */
const BTN_HELPER_OWNED: RegExp[] = [
  /\bbg-\[var\(--color-accent-\d{2,3}\)\]/g,
  /\bhover:bg-\[var\(--color-accent-\d{2,3}\)\]/g,
  /\bbg-(?:blue|indigo|violet|purple|green|red)-\d{3}\b/g,
  /\bhover:bg-(?:blue|indigo|violet|purple|green|red)-\d{3}\b/g,
  /\btext-white\b/g,
  /\brounded-\[var\(--radius-button\)\]/g,
];

/** Prepend `btn btn-primary` and strip the helper-owned colour utilities. */
function rewriteButtonToHelper(classList: string): string {
  let cls = classList;
  for (const re of BTN_HELPER_OWNED) cls = cls.replace(re, ' ');
  cls = cls.replace(/\s+/g, ' ').trim();
  if (/\bbtn-primary\b/.test(cls)) return /\bbtn\b/.test(cls) ? cls : `btn ${cls}`.trim();
  return `btn btn-primary ${cls}`.trim();
}

/** Switch every <button> whose className matches `trigger` (non-global) to the helper. */
function switchButtonsToHelper(source: string, trigger: RegExp): string {
  return source.replace(
    /(<button\b[^>]*className=\{?["'`])([^"'`]*)(["'`])/g,
    (whole, pre: string, classList: string, post: string) => {
      if (/\bbtn\b/.test(classList)) return whole; // already on the helper
      if (!trigger.test(classList)) return whole;
      return `${pre}${rewriteButtonToHelper(classList)}${post}`;
    },
  );
}

/**
 * R1 — wrap page body in <PageTemplate title={'TODO-title'}>…</PageTemplate>.
 * Project-aware guard: if the project ships NO PageTemplate component, refuse the
 * fix (return null). Injecting `import { PageTemplate } from '@/components/ui/
 * PageTemplate'` into a project where that file doesn't exist would break the
 * build — the exact failure that motivated this hardening. R1 stays an INFO
 * architectural note in that case (see checkR1).
 */
const fixR1: FixFn = (source, _finding, caps) => {
  if (caps && !caps.hasComponent('PageTemplate')) return null;
  if (/from\s+['"]@\/components\/ui\/PageTemplate['"]/.test(source)) {
    // Already imported, don't duplicate.
  } else {
    // Inject import after the last existing import line.
    const lastImport = source.match(/^import[\s\S]*?from\s+['"][^'"]+['"];?\s*$/gm);
    if (lastImport && lastImport.length > 0) {
      const last = lastImport[lastImport.length - 1];
      const insertAt = source.indexOf(last) + last.length;
      source = source.slice(0, insertAt)
        + `\nimport { PageTemplate } from '@/components/ui/PageTemplate';`
        + source.slice(insertAt);
    }
  }
  // Wrap the first JSX return's root element in <PageTemplate>.
  // Heuristic: replace `return (\n    <xxx` with `return (\n    <PageTemplate title="TODO-title">\n      <xxx`
  // and balance the closing.
  const returnMatch = source.match(/return\s*\(\s*\n(\s*)(<(?!PageTemplate\b|>|\/)[^>]+>)/);
  if (!returnMatch) return null;
  const indent = returnMatch[1];
  const bodyOpen = returnMatch[2];
  const beforeReturn = source.slice(0, returnMatch.index! + returnMatch[0].length - bodyOpen.length);
  const afterBodyOpen = source.slice(returnMatch.index! + returnMatch[0].length);
  // Find the matching ); of return.
  const closingParenIdx = findMatchingReturnClose(afterBodyOpen);
  if (closingParenIdx === -1) return null;
  const bodyInner = afterBodyOpen.slice(0, closingParenIdx);
  const rest = afterBodyOpen.slice(closingParenIdx);
  return (
    beforeReturn
    + `<PageTemplate title={/* TODO: derive from useTranslation */ 'TODO-title'}>\n${indent}  ${bodyOpen}`
    + bodyInner.replace(/\n/g, '\n  ') // extra indent inside wrapper
    + `\n${indent}</PageTemplate>`
    + rest
  );
};

function findMatchingReturnClose(text: string): number {
  // Walk forward: track JSX depth naively. When depth returns to 0 and we see `);`, that's the end.
  let depth = 1; // we already consumed the opening tag
  let i = 0;
  while (i < text.length) {
    const c = text[i];
    if (c === '<') {
      if (text[i + 1] === '/') depth--;
      else if (text[i + 1] !== '!') depth++;
    }
    if (depth === 0) {
      // Find the `);` after this point
      const close = text.slice(i).match(/>\s*\)\s*;?/);
      if (!close) return -1;
      return i + close.index! + close[0].length;
    }
    i++;
  }
  return -1;
}

/**
 * R3 — a raw-Tailwind primary CTA (`bg-blue-500` …) is switched to the
 * `.btn .btn-primary` helper. Converges with fixR21: the helper is the one true
 * CTA, never an inline re-assembly of accent + text-white + radius.
 */
const fixR3: FixFn = (source) =>
  switchButtonsToHelper(source, /\bbg-(?:blue|indigo|violet|purple|green|red)-\d{3}\b/);

/** R21 — an inline accent-fill CTA is switched to the `.btn .btn-primary` helper. */
const fixR21: FixFn = (source) =>
  switchButtonsToHelper(source, /\bbg-\[var\(--color-accent-(?:500|600)\)\]/);

/** R7 — rewrite bg-red-* / text-red-* / border-red-* error banners to token-based anatomy. */
const fixR7: FixFn = (source, _finding) => {
  return source
    .replace(/\bbg-red-\d{3}\b/g, 'bg-[var(--error-bg)]')
    .replace(/\btext-red-\d{3}\b/g, 'text-[var(--error-text)]')
    .replace(/\bborder-red-\d{3}\b/g, 'border-[var(--error-border)]');
};

/** R10 — strip appCode prefix from 4-segment permission keys, dash-to-dot fallback. */
const fixR10: FixFn = (source, _finding) => {
  return source.replace(/permission=(\{?)["'`]([^"'`]+)["'`]/g, (_match, brace, key) => {
    let fixed = key;
    const parts = fixed.split('.');
    if (parts.length === 4) {
      fixed = parts.slice(1).join('.'); // drop appCode prefix
    }
    // underscores → dots (only when no dot present)
    if (fixed.includes('_') && !fixed.includes('.')) {
      fixed = fixed.replace(/_/g, '.');
    }
    return `permission=${brace}"${fixed}"`;
  });
};

/** R11 — inject <Slot name="{entity}.header.actions" /> after PageTemplate opening. */
const fixR11: FixFn = (source, finding, caps) => {
  // Slots live inside the PageTemplate/extension architecture. Don't inject a Slot
  // (and its `@atlashub/smartstack` import) into a project that ships no
  // PageTemplate — it has not adopted the extension surface, and the import could
  // be phantom. checkR11 already stays silent in that case; this is belt-and-braces.
  if (caps && !caps.hasComponent('PageTemplate')) return null;
  // Derive entity name from file path (foo/bar/ProspectListPage.tsx → "prospect").
  const fileName = finding.file.split('/').pop() ?? '';
  const entity = (fileName.match(/^([A-Z][a-zA-Z]+?)(?:List|Detail|Edit|Create|Form)Page\.tsx$/) || [])[1];
  if (!entity) return null;
  const entityLower = entity.charAt(0).toLowerCase() + entity.slice(1);

  // Ensure Slot import
  if (!/from\s+['"]@atlashub\/smartstack['"]/.test(source)) {
    const lastImport = source.match(/^import[\s\S]*?from\s+['"][^'"]+['"];?\s*$/gm);
    if (lastImport && lastImport.length > 0) {
      const last = lastImport[lastImport.length - 1];
      const insertAt = source.indexOf(last) + last.length;
      source = source.slice(0, insertAt)
        + `\nimport { Slot } from '@atlashub/smartstack';`
        + source.slice(insertAt);
    }
  } else if (!/\bSlot\b/.test(source.match(/from\s+['"]@atlashub\/smartstack['"]/)?.[0] ?? '')) {
    source = source.replace(/(import\s*\{[^}]*?)(\})\s*from\s+['"]@atlashub\/smartstack['"]/, '$1, Slot$2 from \'@atlashub/smartstack\'');
  }

  // Inject <Slot name="entity.header.actions" /> right after <PageTemplate ...> opening.
  return source.replace(
    /(<PageTemplate[^>]*>)/,
    `$1\n      <Slot name="${entityLower}.header.actions" />`,
  );
};

/** R13 — rewrite bg-green-500 / text-white badge patterns to token-based anatomy. */
const fixR13: FixFn = (source, _finding) => {
  // Replace bg-<color>-<shade> within className attributes that also mention "rounded" or "badge"
  return source.replace(
    /(className=\{?["'`][^"'`]*)\bbg-green-\d{3}\b([^"'`]*(?:rounded|badge)[^"'`]*["'`])/g,
    `$1bg-[var(--success-bg)] text-[var(--success-text)] border border-[var(--success-border)]$2`,
  ).replace(
    /(className=\{?["'`][^"'`]*)\bbg-yellow-\d{3}\b([^"'`]*(?:rounded|badge)[^"'`]*["'`])/g,
    `$1bg-[var(--warning-bg)] text-[var(--warning-text)] border border-[var(--warning-border)]$2`,
  ).replace(
    /(className=\{?["'`][^"'`]*)\bbg-red-\d{3}\b([^"'`]*(?:rounded|badge)[^"'`]*["'`])/g,
    `$1bg-[var(--error-bg)] text-[var(--error-text)] border border-[var(--error-border)]$2`,
  ).replace(
    /(className=\{?["'`][^"'`]*)\bbg-blue-\d{3}\b([^"'`]*(?:rounded|badge)[^"'`]*["'`])/g,
    `$1bg-[var(--info-bg)] text-[var(--info-text)] border border-[var(--info-border)]$2`,
  );
};

/**
 * R14 — remap saturated/semantic Tailwind color utilities to design tokens.
 * Family → semantic group; brand families map to the accent ramp, status
 * families to --<status>-{bg|text|border}. Utilities with no clear single-color
 * token target (gradient stops from/via/to, caret, accent form-control) are left
 * untouched and resurface in the re-audit for manual handling.
 */
const SATURATED_TO_GROUP: Record<string, 'error' | 'success' | 'warning' | 'info' | 'accent'> = {
  red: 'error', rose: 'error',
  orange: 'warning', amber: 'warning', yellow: 'warning',
  lime: 'success', green: 'success', emerald: 'success', teal: 'success',
  sky: 'info', cyan: 'info',
  blue: 'accent', indigo: 'accent', violet: 'accent', purple: 'accent', fuchsia: 'accent', pink: 'accent',
};

function accentShade(shade: number): '300' | '400' | '500' | '600' | '700' {
  if (shade <= 300) return '300';
  if (shade < 500) return '400';
  if (shade < 600) return '500';
  if (shade < 700) return '600';
  return '700';
}

/** A status DOT is a tiny round filled indicator (rounded-full + a small fixed
 *  size). Its colour must be the saturated --<status>-dot, NOT the pale
 *  --<status>-bg surface — which at dot size is effectively invisible. Avatars
 *  and chips (h-8/h-10/…) are excluded by the small-size constraint. */
function isStatusDotContext(spanText: string): boolean {
  return /\brounded-full\b/.test(spanText)
    && /\b(?:h|w|size|min-h|min-w)-(?:1|1\.5|2|2\.5|3)\b/.test(spanText);
}

const fixR14: FixFn = (source, _finding) => {
  const spans = classNameSpans(source);
  const spanAt = (offset: number) => spans.find((s) => offset >= s.start && offset < s.end);
  const re = /\b(bg|text|border|ring|fill|stroke|outline|divide|decoration|placeholder|caret|accent|from|via|to)-(red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(\d{2,3})(?:\/\d{1,3})?\b/g;
  return source.replace(re, (match, prefix: string, family: string, shadeStr: string, offset: number) => {
    const group = SATURATED_TO_GROUP[family];
    if (!group) return match;
    if (group === 'accent') {
      if (!/^(?:bg|text|border|ring|fill|stroke|outline|divide|decoration)$/.test(prefix)) return match;
      return `${prefix}-[var(--color-accent-${accentShade(Number(shadeStr))})]`;
    }
    // Status families → bg / text / border / dot token by utility prefix.
    let suffix: 'bg' | 'text' | 'border' | 'dot';
    if (prefix === 'bg') {
      // A small round filled element is a status DOT — map to the saturated
      // indicator (--<status>-dot), not the pale surface (--<status>-bg).
      const span = spanAt(offset);
      suffix = span && isStatusDotContext(span.text) ? 'dot' : 'bg';
    }
    else if (prefix === 'text' || prefix === 'fill' || prefix === 'stroke') suffix = 'text';
    else if (/^(?:border|ring|outline|divide|decoration)$/.test(prefix)) suffix = 'border';
    else return match; // from / via / to / placeholder / caret / accent — ambiguous
    return `${prefix}-[var(--${group}-${suffix})]`;
  });
};

/**
 * R16 — flip a status token back to its role:
 *   bg-[var(--<status>-text)]  → bg-[var(--<status>-bg)]   (illegible fill → pale surface)
 *   text-[var(--<status>-bg)]  → text-[var(--<status>-text)] (invisible text → saturated fg)
 * Pure 1:1 role correction — never injects classes, so it is always safe. The
 * SKILL.md review step completes the soft triplet on badges/banners if needed.
 */
const fixR16: FixFn = (source, _finding) => {
  return source
    .replace(/\bbg-\[var\(--(success|warning|error|info)-text\)\]/g, 'bg-[var(--$1-bg)]')
    .replace(/\btext-\[var\(--(success|warning|error|info)-bg\)\]/g, 'text-[var(--$1-text)]');
};

/**
 * R15 — remap neutral SURFACE utilities to theme surface tokens. Only bg-* (and
 * bg-white/black) are rewritten: the theme has no neutral text/border token, so
 * neutral text and border utilities are left flagged (warning) for manual review.
 */
const fixR15: FixFn = (source, _finding) => {
  return source
    .replace(/\bbg-white\b/g, 'bg-[var(--bg-card)]')
    .replace(/\bbg-(?:slate|gray|zinc|neutral|stone)-(?:50|100|200)\b/g, 'bg-[var(--bg-muted)]')
    .replace(/\bbg-(?:slate|gray|zinc|neutral|stone)-(?:800|900|950)\b/g, 'bg-[var(--bg-app)]')
    .replace(/\bbg-black\b/g, 'bg-[var(--bg-app)]');
};

const FIXES: Record<string, FixFn> = {
  'R1-pagetemplate': fixR1,
  'R3-primary-cta': fixR3,
  'R7-error-banner-anatomy': fixR7,
  'R10-permission-key-format': fixR10,
  'R11-slot-anatomy': fixR11,
  'R13-badge-anatomy': fixR13,
  'R14-no-hardcoded-colors': fixR14,
  'R15-no-neutral-hardcoded-colors': fixR15,
  'R16-token-role-misuse': fixR16,
  'R21-cta-inline-bypass': fixR21,
};

// ─── Regeneration pass ────────────────────────────────────────────────────

/**
 * Heuristic: derive a scaffold-component spec from an existing page path
 * (by inspecting the filename + sibling types.ts files). Returns null if
 * we can't infer enough to build a valid spec.
 *
 * This is the bridge between "audit found N errors on this file" and
 * "scaffold-component can overwrite it with a conformant template".
 */
interface DerivedSpec {
  module: string;
  entity: string;
  section: string;
  appCode: string;
  views: ('list' | 'detail' | 'form')[];
  fields: Array<{ name: string; type: string; required: boolean }>;
  pagesDir: string;
}

const PAGE_FILENAME_RE = /^([A-Z]\w+?)(List|Detail|Form|Dashboard|Create|Edit)Page\.tsx$/;

async function deriveSpecFromPagePath(projectPath: string, pageFile: string): Promise<DerivedSpec | null> {
  const abs = path.resolve(projectPath, pageFile);
  const base = path.basename(abs);
  const m = PAGE_FILENAME_RE.exec(base);
  if (!m) return null;
  // Preserve the original entity name (plural or singular) so the regenerated
  // file OVERWRITES the original at its exact path. Stripping the "s" would
  // create a new singular file (LeadListPage.tsx) next to the unchanged plural
  // file (LeadsListPage.tsx) — coexistence not overwrite.
  const entity = m[1]; // e.g. "Leads" (as-is from filename)

  // Infer module + section from the path: {anything}/pages/{module}/{section?}/{file} OR {anything}/{module}/{section}/{file}
  const normalized = abs.replace(/\\/g, '/');
  const parts = normalized.split('/');
  // Find the deepest "pages" or "features" token — everything after is the hierarchy.
  const pagesIdx = parts.lastIndexOf('pages');
  const featuresIdx = parts.lastIndexOf('features');
  const anchor = pagesIdx >= 0 ? pagesIdx : featuresIdx;
  if (anchor < 0 || anchor + 2 >= parts.length) {
    return null;
  }
  const segments = parts.slice(anchor + 1, parts.length - 1); // everything between anchor and filename
  const [maybeApp, maybeModule, maybeSection] = segments;
  // Heuristic: 3 segments → app/module/section ; 2 segments → module/section ; 1 → section (module unknown).
  let module: string;
  let section: string;
  if (segments.length >= 3) {
    module = maybeModule;
    section = maybeSection;
  } else if (segments.length === 2) {
    module = maybeApp;
    section = maybeModule;
  } else {
    module = maybeApp;
    section = entity.toLowerCase();
  }

  // Infer appCode from project name (outermost web folder name like "testv2-web" → "testv2").
  let appCode = 'app';
  const webIdx = parts.lastIndexOf('web');
  if (webIdx >= 0 && webIdx + 1 < parts.length) {
    const webRoot = parts[webIdx + 1];
    const appMatch = webRoot.match(/^([a-z0-9]+)(?:-web)?$/i);
    if (appMatch) appCode = appMatch[1].toLowerCase();
  }

  // Derive fields from a sibling *Dto TypeScript type. Looks under projectPath/src/types/**
  // for files mentioning `<Entity>Dto` or `interface <Entity>`.
  const fields = await extractFieldsFromTypesFolder(projectPath, entity);

  // pagesDir is the directory containing the broken file — where regeneration will write.
  const pagesDir = path.dirname(abs);

  return {
    module,
    entity,
    section,
    appCode,
    views: ['list', 'detail', 'form'],
    fields: fields.length > 0 ? fields : [{ name: 'id', type: 'string', required: true }],
    pagesDir,
  };
}

/** Naive TypeScript interface field extractor — good enough for regeneration fallback. */
async function extractFieldsFromTypesFolder(
  projectPath: string,
  entity: string,
): Promise<Array<{ name: string; type: string; required: boolean }>> {
  const candidates: string[] = [];
  try {
    candidates.push(
      ...(await findFiles('**/src/types/**/*.ts', { cwd: projectPath })),
      ...(await findFiles('**/src/features/**/types/**/*.ts', { cwd: projectPath })),
    );
  } catch {
    return [];
  }
  // Try singular + plural variants of the entity name. Convention varies:
  // some projects name DTOs in singular (LeadDto) even for list pages (LeadsListPage).
  const singular = entity.endsWith('s') ? entity.slice(0, -1) : entity;
  const variants = entity === singular ? [entity] : [singular, entity];
  const interfaceRe = new RegExp(`(?:interface|type)\\s+(?:${variants.join('|')})(?:Dto)?\\b[^{]*\\{([\\s\\S]*?)\\n\\}`, 'm');
  for (const file of candidates) {
    let content: string;
    try {
      content = await readText(file);
    } catch {
      continue;
    }
    const m = interfaceRe.exec(content);
    if (!m) continue;
    const body = m[1];
    const fields: Array<{ name: string; type: string; required: boolean }> = [];
    // Match lines like `  firstName: string;` or `  email?: string;`
    const fieldRe = /^\s*(\w+)(\?)?:\s*([^;]+?);?\s*$/gm;
    let f: RegExpExecArray | null;
    while ((f = fieldRe.exec(body)) !== null) {
      if (['id', 'readonly'].includes(f[1])) continue;
      const tsType = f[3].trim();
      const simple = /^\s*(?:string|number|boolean)(\s*\|\s*(?:'[^']*'|"[^"]*"))*\s*$/.test(tsType)
        ? tsType.replace(/\s*\|.*$/, '')
        : tsType.toLowerCase().startsWith('number')
        ? 'number'
        : 'string';
      fields.push({
        name: f[1],
        type: simple,
        required: !f[2],
      });
      if (fields.length >= 10) break;
    }
    if (fields.length > 0) return fields;
  }
  return [];
}

interface RegenResult {
  file: string;
  regenerated: boolean;
  reason: string;
}

async function regenerateBroken(
  args: UiPolishArgs,
  findings: Finding[],
  threshold: number,
): Promise<RegenResult[]> {
  // threshold ≤ 0 disables the regen pass entirely.
  if (threshold <= 0) return [];

  // Group findings by file.
  const byFile = new Map<string, Finding[]>();
  for (const f of findings) {
    if (f.severity !== 'error') continue;
    // Color violations (R14) are mechanically fixable in place — they must NOT
    // count toward the destructive full-page regeneration threshold.
    if (f.ruleId === 'R14-no-hardcoded-colors') continue;
    const arr = byFile.get(f.file) ?? [];
    arr.push(f);
    byFile.set(f.file, arr);
  }

  // Locate the scaffold-component CLI relative to ui-polish.
  // `fileURLToPath` handles URL decoding (e.g. space → %20) + drive-letter
  // normalisation on Windows. The manual regex approach leaves %20 in place.
  const thisFile = fileURLToPath(import.meta.url);
  const scaffoldCli = path.resolve(
    path.dirname(thisFile),
    '../../../component/cli/scaffold-component/index.ts',
  );

  const results: RegenResult[] = [];
  for (const [file, errorFindings] of byFile) {
    if (errorFindings.length < threshold) continue;

    // Only regenerate pages (not arbitrary .tsx files).
    if (!PAGE_FILENAME_RE.test(path.basename(file))) {
      results.push({ file, regenerated: false, reason: 'not a *Page.tsx file' });
      continue;
    }

    const spec = await deriveSpecFromPagePath(args.projectPath, file);
    if (!spec) {
      results.push({ file, regenerated: false, reason: 'could not infer entity/module/section from path' });
      continue;
    }

    // Invoke scaffold-component with --pages-dir pointing at the existing location.
    const scaffoldSpec = {
      module: spec.module,
      entity: spec.entity,
      section: spec.section,
      appCode: spec.appCode,
      views: spec.views,
      fields: spec.fields,
      projectPath: args.projectPath,
    };
    try {
      await execScaffold(scaffoldCli, scaffoldSpec, spec.pagesDir);
      results.push({
        file,
        regenerated: true,
        reason: `regenerated from template (${errorFindings.length} errors → overwritten with conformant template, entity=${spec.entity}, module=${spec.module}, section=${spec.section})`,
      });
    } catch (err) {
      results.push({
        file,
        regenerated: false,
        reason: `scaffold-component failed: ${err instanceof Error ? err.message : String(err)}`,
      });
    }
  }
  return results;
}

async function execScaffold(cliPath: string, spec: object, pagesDir: string): Promise<void> {
  const specJson = JSON.stringify(spec);
  const quote = (s: string) =>
    process.platform === 'win32' ? `"${s.replace(/"/g, '\\"')}"` : `'${s.replace(/'/g, `'\\''`)}'`;
  return new Promise((resolve, reject) => {
    const cmd = `npx --prefer-offline tsx ${quote(cliPath)} --spec ${quote(specJson)} --pages-dir ${quote(pagesDir)} --overwrite`;
    const child = exec(cmd, { maxBuffer: 8 * 1024 * 1024, timeout: 60_000 });
    let stderr = '';
    child.stderr?.on('data', (d: Buffer | string) => { stderr += String(d); });
    child.on('close', (code) => {
      if (code === 0) resolve();
      else reject(new Error(`scaffold-component exit=${code}: ${stderr.slice(-500)}`));
    });
    child.on('error', reject);
  });
}

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

export async function apply(args: UiPolishArgs, tokens: TokensFile): Promise<ApplyReport> {
  // Phase 1 — run the audit to know what to fix.
  const report = await audit(args, tokens);

  // Project-aware context, so fixes can refuse to require/import components the
  // project lacks (e.g. fixR1 won't inject a phantom PageTemplate import).
  const caps = await detectThemeCapabilities(report.projectPath, tokens);

  // Group fixable findings by file.
  const byFile = new Map<string, Finding[]>();
  const skipped: Finding[] = [];
  for (const f of report.findings) {
    if (!f.autoFixable) continue;
    if (!FIXES[f.ruleId]) {
      skipped.push(f);
      continue;
    }
    const arr = byFile.get(f.file) ?? [];
    arr.push(f);
    byFile.set(f.file, arr);
  }

  // Phase 2 — apply fixes per file.
  const filesModified: string[] = [];
  let fixesApplied = 0;
  for (const [relFile, findings] of byFile) {
    const abs = path.join(report.projectPath, relFile);
    let source: string;
    try {
      source = await readText(abs);
    } catch {
      skipped.push(...findings);
      continue;
    }
    const original = source;
    let allOk = true;
    for (const finding of findings) {
      const fix = FIXES[finding.ruleId];
      try {
        const patched = fix(source, finding, caps);
        if (patched === null) {
          // Fix couldn't apply safely — mark skipped.
          skipped.push(finding);
          continue;
        }
        source = patched;
        fixesApplied++;
      } catch {
        allOk = false;
        skipped.push(finding);
        break; // abort this file entirely
      }
    }
    if (allOk && source !== original) {
      await writeText(abs, source);
      filesModified.push(relFile);
    }
  }

  // Phase 3 — regeneration pass. Re-audit after mechanical fixes and
  // REGENERATE any *Page.tsx file with ≥ threshold ERROR findings remaining.
  // This is the "no tech debt" policy: non-conformant pages are rebuilt from
  // templates, not patched incrementally. User customisations belong in
  // extension files (Slot/Fill), never in the page itself.
  const secondaryAudit = await audit(args, tokens);
  const regenThreshold = typeof args.regenerateThreshold === 'number' ? args.regenerateThreshold : 3;
  const regenResults = await regenerateBroken(args, secondaryAudit.findings, regenThreshold);
  for (const r of regenResults) {
    if (r.regenerated) filesModified.push(r.file);
  }

  return {
    ...report,
    fixesApplied,
    filesModified,
    fixesSkipped: skipped,
    regenerations: regenResults,
  } as ApplyReport & { regenerations: RegenResult[] };
}
