#!/usr/bin/env node
/**
 * VRT Fix Loop -- CSS break -> VLM analyze -> LLM fix -> VRT verify loop
 *
 * Renders real HTML with Playwright, removes one CSS property to break it,
 * then uses a two-stage pipeline to fix and verify via VRT.
 *
 * Usage:
 *   node src/fix-loop.ts --fixture page --seed 42
 *   node src/fix-loop.ts --fixture dashboard --max-rounds 5
 */
import { readFile, mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { isCliEntry } from "@mizchi/vlmkit-core/plugin/cli-entry.ts";
import { launchBrowser } from "@mizchi/vlmkit-core/browser-launch.ts";
import { compareScreenshots, generateDiffReport } from "@mizchi/vlmkit-core/heatmap.ts";
import {
  parseCssDeclarations, removeCssProperty, extractCss, replaceCss,
  seededRandom, groupBySelector, removeSelectorBlock, escapeRegex,
  type CssDeclaration,
} from "./css-challenge-core.ts";
import { createReasoningPipeline } from "@mizchi/vlmkit-ai/reasoning-pipeline.ts";
import { resolveResolutionForViewport } from "@mizchi/vlmkit-core/image-resize.ts";
import { getCssChallengeFixturePath } from "./css-challenge-fixtures.ts";
import { DIM, RESET, GREEN, RED, YELLOW, CYAN, BOLD, hr } from "@mizchi/vlmkit-core/terminal-colors.ts";
import { getArg, getIntArg } from "@mizchi/vlmkit-core/cli-args.ts";

// ---- Config ----

const FIXTURE = getArg("fixture", "page");
const SEED = getIntArg("seed", Date.now());
const MAX_ROUNDS = getIntArg("max-rounds", 3, { min: 1 });
const MODE = getArg("mode", "property") as "property" | "selector";
const VIEWPORT = { width: 1280, height: 900 };
const TMP = join(process.cwd(), "test-results", "fix-loop");

// ---- Main ----

export async function runCssFixLoop() {
  await mkdir(TMP, { recursive: true });

  const fixturePath = getCssChallengeFixturePath(FIXTURE);
  const htmlRaw = await readFile(fixturePath, "utf-8");
  const originalCss = extractCss(htmlRaw);
  if (!originalCss) { console.error("CSS not found"); process.exit(1); }

  const declarations = parseCssDeclarations(originalCss);
  const blocks = groupBySelector(declarations);
  const rand = seededRandom(SEED);

  // Pick what to remove
  let removed: { label: string; brokenCss: string; declarations: CssDeclaration[] };
  if (MODE === "selector") {
    const shuffled = blocks.sort(() => rand() - 0.5);
    const block = shuffled[0];
    removed = {
      label: `${block.selector} { ${block.declarations.length} props }`,
      brokenCss: removeSelectorBlock(originalCss, block),
      declarations: block.declarations,
    };
  } else {
    const shuffled = declarations.sort(() => rand() - 0.5);
    const decl = shuffled[0];
    removed = {
      label: `${decl.selector} { ${decl.property}: ${decl.value} }`,
      brokenCss: removeCssProperty(originalCss, decl),
      declarations: [decl],
    };
  }

  const pipeline = createReasoningPipeline({
    resolution: resolveResolutionForViewport(VIEWPORT.width) as any,
    throwIfMissing: false,
  });

  console.log();
  console.log(`${BOLD}${CYAN}╔════════════════════════════════════════════════════════════════════════╗${RESET}`);
  console.log(`${BOLD}${CYAN}║  VRT Fix Loop — Detect → Analyze → Fix → Verify                     ║${RESET}`);
  console.log(`${BOLD}${CYAN}╚════════════════════════════════════════════════════════════════════════╝${RESET}`);
  console.log(`  ${DIM}Fixture: ${FIXTURE} | Mode: ${MODE} | Seed: ${SEED} | Max rounds: ${MAX_ROUNDS}${RESET}`);
  console.log(`  ${DIM}Pipeline: VLM=${pipeline?.vlmModel ?? "none"} | LLM=${pipeline?.llmModel ?? "none"}${RESET}`);
  console.log(`  ${RED}Removed: ${removed.label}${RESET}`);
  console.log();

  // ---- Capture baseline ----
  const browser = await launchBrowser();
  const baselinePath = join(TMP, "baseline.png");
  const loopPage = await browser.newPage({ viewport: VIEWPORT });
  await loopPage.setContent(htmlRaw, { waitUntil: "networkidle" });
  await loopPage.screenshot({ path: baselinePath, fullPage: true });

  // ---- Fix loop ----
  let currentCss = removed.brokenCss;
  let round = 0;
  let fixed = false;
  const history: Array<{ round: number; diffRatio: number; changes: number; fixes: number; escalated: boolean }> = [];

  for (round = 1; round <= MAX_ROUNDS; round++) {
    console.log(`  ${BOLD}Round ${round}/${MAX_ROUNDS}${RESET}`);

    // Render broken state
    const brokenHtml = replaceCss(htmlRaw, originalCss, currentCss);
    const brokenPath = join(TMP, `broken-r${round}.png`);
    await loopPage.setContent(brokenHtml, { waitUntil: "networkidle" });
    await loopPage.screenshot({ path: brokenPath, fullPage: true });

    // VRT diff
    const diff = await compareScreenshots({
      testId: `r${round}`, testTitle: `r${round}`, projectName: "fix-loop",
      screenshotPath: brokenPath, baselinePath, status: "changed",
    }, { outputDir: TMP });

    const diffRatio = diff?.diffRatio ?? 0;
    console.log(`    Pixel diff: ${diffRatio === 0 ? GREEN + "0.0%" : (diffRatio * 100).toFixed(1) + "%"}${RESET}`);

    if (diffRatio === 0) {
      console.log(`    ${GREEN}${BOLD}✓ PIXEL-PERFECT — Fix succeeded!${RESET}`);
      fixed = true;
      history.push({ round, diffRatio: 0, changes: 0, fixes: 0, escalated: false });
      break;
    }

    if (diffRatio < 0.005) {
      console.log(`    ${GREEN}✓ Near-perfect (< 0.5%) — Acceptable${RESET}`);
      fixed = true;
      history.push({ round, diffRatio, changes: 0, fixes: 0, escalated: false });
      break;
    }

    // Pipeline: analyze + fix
    if (!pipeline) {
      console.log(`    ${YELLOW}No VLM/LLM available — cannot auto-fix${RESET}`);
      history.push({ round, diffRatio, changes: 0, fixes: 0, escalated: false });
      break;
    }

    const heatmapBase64 = diff?.heatmapPath
      ? (await readFile(diff.heatmapPath)).toString("base64")
      : (await readFile(brokenPath)).toString("base64");

    // Diff report with shift detection
    const diffReport = await generateDiffReport({
      testId: `r${round}`, testTitle: `r${round}`, projectName: "fix-loop",
      screenshotPath: brokenPath, baselinePath, status: "changed",
    }, { outputDir: TMP, detectShift: true, skipHeatmap: true });

    // Build rich text report: CSS text diff + selector list
    const currentDecls = parseCssDeclarations(currentCss);
    const originalDecls = parseCssDeclarations(originalCss);
    const missingSelectors = [...new Set(originalDecls.map((d) => d.selector))]
      .filter((s) => !currentDecls.some((d) => d.selector === s));
    const cssDiffLines: string[] = [];
    for (const od of originalDecls) {
      const cd = currentDecls.find((d) => d.selector === od.selector && d.property === od.property);
      if (!cd) cssDiffLines.push(`MISSING: ${od.selector} { ${od.property}: ${od.value} }`);
      else if (cd.value !== od.value) cssDiffLines.push(`CHANGED: ${od.selector} { ${od.property}: ${od.value} → ${cd.value} }`);
    }
    const textReport = [
      `Pixel diff: ${(diffRatio * 100).toFixed(1)}%. ${diff?.regions.length ?? 0} diff regions.`,
      missingSelectors.length > 0 ? `Missing CSS selectors: ${missingSelectors.join(", ")}` : "",
      cssDiffLines.length > 0 ? `CSS diff from baseline:\n${cssDiffLines.slice(0, 20).join("\n")}` : "",
    ].filter(Boolean).join("\n");

    const { analysis, fix, escalated } = await pipeline.analyzeAndFix({
      heatmapBase64,
      textReport,
      cssSource: currentCss,
      cssDiff: cssDiffLines.join("\n"),
      highResHeatmapBase64: (await readFile(brokenPath)).toString("base64"),
      compactHeatmap: diffReport?.compact,
      shiftInfo: diffReport ? {
        globalShift: diffReport.globalShift,
        shiftOnly: diffReport.shiftOnly,
        compensatedDiffRatio: diffReport.compensatedDiffCount / diffReport.totalPixels,
        contentChangeCount: diffReport.contentChangeCount,
      } : undefined,
    });

    console.log(`    VLM: ${analysis.changes.length} changes detected (${analysis.vlmLatencyMs}ms)${escalated ? ` ${YELLOW}[escalated]${RESET}` : ""}`);
    for (const c of analysis.changes.slice(0, 5)) {
      console.log(`      ${DIM}${c.element} { ${c.property}: ${c.before} → ${c.after} }${RESET}`);
    }
    if (analysis.changes.length > 5) console.log(`      ${DIM}... +${analysis.changes.length - 5} more${RESET}`);

    console.log(`    LLM: ${fix.fixes.length} fixes proposed (${fix.llmLatencyMs}ms, confidence: ${fix.confidence})`);

    // Filter fixes: prioritize MISSING selectors, only touch existing selectors if property was in original
    const missingProps = new Set(cssDiffLines.filter((l) => l.startsWith("MISSING:")).map((l) => {
      const m = l.match(/MISSING:\s*(.+?)\s*\{/);
      return m ? m[1].trim() : "";
    }));
    const validFixes = fix.fixes.filter((f) => {
      // Always allow fixes for missing selectors
      if (missingProps.has(f.selector)) return true;
      // For existing selectors, only if the property was in the diff
      return cssDiffLines.some((l) => l.includes(f.selector) && l.includes(f.property));
    });
    if (validFixes.length < fix.fixes.length) {
      console.log(`    ${YELLOW}Filtered: ${fix.fixes.length - validFixes.length} fixes for unknown selectors${RESET}`);
    }

    // Apply fixes to a candidate CSS
    let candidateCss = currentCss;
    let applied = 0;
    for (const f of validFixes) {
      const lines = candidateCss.split("\n");
      let patched = false;
      for (let i = 0; i < lines.length; i++) {
        const trimmed = lines[i].trim();
        const match = trimmed.match(/^([^{]+)\{([^}]+)\}\s*$/);
        if (match && match[1].trim() === f.selector) {
          const body = match[2];
          // `(?<![\\w-])` so `color` cannot match inside `border-color`. Without it,
          // `String.replace` takes the first match, so a proposed fix for `color` on
          // `.card { border-color: red; color: blue; }` rewrote `border-color` and left
          // `color` alone — the wrong property patched AND `border-` left dangling. The
          // apply-and-rollback gate then sees the diff get worse and blames the model's
          // fix rather than this line. Same defect as `removeCssProperty` carried until
          // 2026-08-14; this is a separate hand-rolled copy of it.
          const propRegex = new RegExp(`(?<![\\w-])${escapeRegex(f.property)}\\s*:[^;]+;?`);
          if (propRegex.test(body)) {
            const newBody = body.replace(propRegex, `${f.property}: ${f.value};`);
            lines[i] = `${f.selector} { ${newBody.trim()} }`;
          } else {
            // Terminate first: a trailing semicolon is optional in CSS, so appending to
            // `.card { color: red }` produced `.card { color: red padding: 4px; }` and
            // destroyed the declaration already there.
            const existing = body.trim();
            const terminated = existing === "" || existing.endsWith(";") ? existing : `${existing};`;
            lines[i] = `${f.selector} { ${`${terminated} ${f.property}: ${f.value};`.trim()} }`;
          }
          patched = true;
          applied++;
          break;
        }
      }
      if (!patched) {
        candidateCss += `\n${f.selector} { ${f.property}: ${f.value}; }`;
        applied++;
      } else {
        candidateCss = lines.join("\n");
      }
    }

    // Dry run: verify fix doesn't make things worse
    const candidateHtml = replaceCss(htmlRaw, originalCss, candidateCss);
    const candidatePath = join(TMP, `candidate-r${round}.png`);
    await loopPage.setContent(candidateHtml, { waitUntil: "networkidle" });
    await loopPage.screenshot({ path: candidatePath, fullPage: true });
    const candidateDiff = await compareScreenshots({
      testId: `candidate-r${round}`, testTitle: `candidate-r${round}`, projectName: "fix-loop",
      screenshotPath: candidatePath, baselinePath, status: "changed",
    }, { outputDir: TMP, skipHeatmap: true });
    const candidateDiffRatio = candidateDiff?.diffRatio ?? 1;

    if (candidateDiffRatio < diffRatio) {
      // Fix improved things — accept
      console.log(`    Applied: ${applied}/${validFixes.length} fixes → diff ${(diffRatio * 100).toFixed(1)}% → ${(candidateDiffRatio * 100).toFixed(1)}% ${GREEN}✓${RESET}`);
      currentCss = candidateCss;
      if (candidateDiffRatio === 0) {
        console.log(`    ${GREEN}${BOLD}✓ PIXEL-PERFECT — Fix succeeded!${RESET}`);
        fixed = true;
        history.push({ round, diffRatio: 0, changes: analysis.changes.length, fixes: applied, escalated });
        break;
      }
      if (candidateDiffRatio < 0.005) {
        console.log(`    ${GREEN}✓ Near-perfect (< 0.5%)${RESET}`);
        fixed = true;
        history.push({ round, diffRatio: candidateDiffRatio, changes: analysis.changes.length, fixes: applied, escalated });
        break;
      }
    } else {
      // Fix made things worse or no change — rollback
      console.log(`    Applied: ${applied}/${validFixes.length} fixes → diff ${(diffRatio * 100).toFixed(1)}% → ${(candidateDiffRatio * 100).toFixed(1)}% ${RED}✗ rollback${RESET}`);
    }

    history.push({ round, diffRatio: Math.min(diffRatio, candidateDiffRatio), changes: analysis.changes.length, fixes: applied, escalated });
  }

  await browser.close();

  // ---- Summary ----
  hr();
  console.log();
  console.log(`  ${BOLD}Fix Loop Summary${RESET}`);
  console.log();
  console.log(`  ${DIM}Fixture:${RESET}  ${FIXTURE}`);
  console.log(`  ${DIM}Removed:${RESET}  ${removed.label}`);
  console.log(`  ${DIM}Result:${RESET}   ${fixed ? GREEN + BOLD + "FIXED" : RED + BOLD + "NOT FIXED"}${RESET} (${round} round${round > 1 ? "s" : ""})`);
  console.log();

  if (history.length > 0) {
    console.log(`  ${"Round".padEnd(8)} ${"Diff".padStart(8)} ${"Changes".padStart(10)} ${"Fixes".padStart(8)} ${"Escalated".padStart(10)}`);
    for (const h of history) {
      const diffStr = h.diffRatio === 0 ? `${GREEN}0.0%${RESET}` : `${(h.diffRatio * 100).toFixed(1)}%`;
      console.log(`  ${String(h.round).padEnd(8)} ${diffStr.padStart(8)} ${String(h.changes).padStart(10)} ${String(h.fixes).padStart(8)} ${String(h.escalated).padStart(10)}`);
    }
  }

  console.log();

  await rm(TMP, { recursive: true, force: true }).catch(() => {});
  process.exit(fixed ? 0 : 1);
}

// Guarded via the shared helper: the old suffix match could not tell
// `fix-loop.ts` from another file ending in the same name.
if (isCliEntry(import.meta.url, "fix-loop")) {
  runCssFixLoop().catch((e) => { console.error(e); process.exitCode = 1; });
}
