#!/usr/bin/env node
/* check-occlusion.cjs — pixel-perfect occlusion gate (Node port of check-occlusion-v2.py).
 * Runs measure-layout.cjs (real Chromium DOM rects), reads the subject-matte alpha via sharp,
 * computes per-word / per-cap occlusion. No Python.
 *   node check-occlusion.cjs <project-dir> [--strict] [--word-fail F] [--word-warn F] [--cap-fail F] [--remeasure]
 */
const path = require("path");
const fs = require("fs");
const os = require("os");
const cp = require("child_process");

function hfResolve(pkg) {
  const roots = [
    process.env.HYPERFRAMES_ROOT,
    path.resolve(__dirname, "..", "..", ".."),
    path.join(os.homedir(), "Downloads", "hyperframes"),
  ].filter(Boolean);
  for (const root of roots) {
    const cands = [path.join(root, "node_modules", pkg)];
    const bun = path.join(root, "node_modules", ".bun");
    try {
      if (fs.existsSync(bun))
        for (const d of fs.readdirSync(bun))
          if (d.startsWith(pkg + "@")) cands.push(path.join(bun, d, "node_modules", pkg));
    } catch {}
    for (const c of cands) {
      try {
        if (fs.existsSync(c)) return require(c);
      } catch {}
    }
  }
  console.error(`[v2] cannot find ${pkg} — set HYPERFRAMES_ROOT`);
  process.exit(3);
}
const sharp = hfResolve("sharp");

function ensureLayoutMeasured(project, force) {
  const lp = path.join(project, "_layout.json"),
    idx = path.join(project, "index.html");
  let stale = force || !fs.existsSync(lp);
  if (!stale && fs.existsSync(idx) && fs.statSync(idx).mtimeMs > fs.statSync(lp).mtimeMs)
    stale = true;
  if (stale)
    cp.execFileSync("node", [path.join(__dirname, "measure-layout.cjs"), project], {
      stdio: "inherit",
    });
  return JSON.parse(fs.readFileSync(lp, "utf8"));
}
async function loadAlphaMask(png) {
  if (!fs.existsSync(png)) return null;
  const { data, info } = await sharp(png)
    .ensureAlpha()
    .extractChannel(3)
    .raw()
    .toBuffer({ resolveWithObject: true });
  return { data, W: info.width, H: info.height };
}
function occlusionForRect(m, x, y, w, h) {
  if (!m) return 0;
  const x0 = Math.max(0, Math.round(x)),
    y0 = Math.max(0, Math.round(y));
  const x1 = Math.min(m.W, Math.round(x + w)),
    y1 = Math.min(m.H, Math.round(y + h));
  if (x1 <= x0 || y1 <= y0) return 0;
  let cnt = 0,
    tot = 0;
  for (let yy = y0; yy < y1; yy++) {
    const row = yy * m.W;
    for (let xx = x0; xx < x1; xx++) {
      tot++;
      if (m.data[row + xx] > 128) cnt++;
    }
  }
  return tot ? cnt / tot : 0;
}
function argf(name, d) {
  const i = process.argv.indexOf(name);
  return i >= 0 ? parseFloat(process.argv[i + 1]) : d;
}

async function main() {
  const project = path.resolve(process.argv[2] || "");
  if (!process.argv[2]) {
    console.error("usage: check-occlusion.cjs <project-dir> [--strict]");
    process.exit(1);
  }
  const strict = process.argv.includes("--strict");
  const wordFail = argf("--word-fail", 0.65),
    wordWarn = argf("--word-warn", 0.35),
    capFail = argf("--cap-fail", 0.5);
  const layout = ensureLayoutMeasured(project, process.argv.includes("--remeasure"));
  const framesDir = path.join(project, "frames_fg");
  if (!fs.existsSync(framesDir)) {
    console.error(`[v2] missing ${framesDir}`);
    process.exit(2);
  }
  const planPath = path.join(project, "plan.json");
  const plan = fs.existsSync(planPath) ? JSON.parse(fs.readFileSync(planPath, "utf8")) : {};
  const planLayer = plan.caption_layer || "bg";
  // Hero groups (the ONE big promoted word) are SUPPOSED to sit ON the subject — for them
  // occlusion is a TARGET (~30–55%), not "minimize". Collect their ids for the advisory below.
  const heroIds = new Set();
  const heroIn = {};
  for (const g of plan.groups || [])
    if (g && (g.hero === true || /^(hero|crown)$/i.test(g.plane || ""))) {
      heroIds.add(g.id);
      heroIn[g.id] = g.in;
    }
  if (plan.crown_group && plan.crown_group.id) {
    heroIds.add(plan.crown_group.id);
    heroIn[plan.crown_group.id] = plan.crown_group.in;
  }
  const M = 2; // frame-edge tolerance (px) — matches check-overflow.cjs
  const frameW = layout.width,
    frameH = layout.height;

  const capStats = {};
  for (const sample of layout.samples) {
    const png = path.join(framesDir, `f_${String(sample.frame_idx).padStart(4, "0")}.png`);
    const mask = await loadAlphaMask(png);
    if (!mask) continue;
    for (const cap of sample.caps) {
      const entry = (capStats[cap.id] ||= { layer: cap.layer || planLayer, samples: [] });
      const wordsData = [];
      for (const w of cap.words || []) {
        if ((w.opacity ?? 1) < 0.3) continue;
        wordsData.push({ text: w.text, occlusion: occlusionForRect(mask, w.x, w.y, w.w, w.h) });
        // Frame-edge overflow — clipped SETTLED text is always wrong; a hero's first
        // 0.5s is its entrance TRANSIENT (slam over-scale, streak fly-in pass through
        // off-frame states by design) — judge overflow on the hold, not mid-flight.
        if (heroIds.has(cap.id) && heroIn[cap.id] != null && sample.t < heroIn[cap.id] + 0.5)
          continue;
        const off = {
          left: Math.max(0, Math.round(-w.x - M)),
          right: Math.max(0, Math.round(w.x + w.w - frameW - M)),
          top: Math.max(0, Math.round(-w.y - M)),
          bottom: Math.max(0, Math.round(w.y + w.h - frameH - M)),
        };
        const score = off.left + off.right + off.top + off.bottom;
        if (score > 0 && (!entry.overflow || score > entry.overflow.score))
          entry.overflow = { text: w.text, off, score, t: sample.t };
      }
      const capOccl = occlusionForRect(
        mask,
        cap.cap_bbox.x,
        cap.cap_bbox.y,
        cap.cap_bbox.w,
        cap.cap_bbox.h,
      );
      entry.samples.push({
        t: sample.t,
        cap_occl: capOccl,
        cap_bbox: cap.cap_bbox,
        words: wordsData,
      });
    }
  }

  const failures = [];
  console.log(
    `[v2] ${path.basename(project)}  word-fail≥${(wordFail * 100).toFixed(0)}%  cap-fail≥${(capFail * 100).toFixed(0)}%`,
  );
  for (const [gid, entry] of Object.entries(capStats)) {
    // Frame-edge overflow applies to every layer (fg too). The skill allows the
    // climax a few-px graze on the first/last letter, so only a clear glyph clip
    // (>8px past an edge) is a hard FAIL; a sub-glyph graze is a WARN.
    if (entry.overflow) {
      const o = entry.overflow;
      const maxOff = Math.max(o.off.left, o.off.right, o.off.top, o.off.bottom);
      const sides = Object.entries(o.off)
        .filter(([, v]) => v > 0)
        .map(([s, v]) => `${s} ${v}px`)
        .join(", ");
      const hard = maxOff >= 8;
      console.log(
        `  ${gid}  [overflow${hard ? "" : "-warn"}] "${o.text}" off-frame: ${sides} (@${o.t}s)` +
          (hard ? " — cropped text is always wrong" : " (graze — within climax tolerance)"),
      );
      if (hard && !failures.includes(gid)) failures.push(gid);
    }
    if (entry.layer === "fg") {
      console.log(`  ${gid}  fg    (occlusion skipped — fg renders above matte)`);
      continue;
    }
    const capOccls = entry.samples.map((s) => s.cap_occl);
    const avgCap = capOccls.length ? capOccls.reduce((a, b) => a + b, 0) / capOccls.length : 0;
    const peakCap = capOccls.length ? Math.max(...capOccls) : 0;
    const wordPeaks = {};
    for (const s of entry.samples)
      for (const wd of s.words)
        wordPeaks[wd.text] = Math.max(wordPeaks[wd.text] || 0, wd.occlusion);
    const oblit = Object.entries(wordPeaks).filter(([, p]) => p >= wordFail);
    const warn = Object.entries(wordPeaks).filter(([, p]) => p >= wordWarn && p < wordFail);
    let status = "OK";
    // HERO caps WANT occlusion (~30–55% is the embed); the generic cap threshold would
    // fail a working hero. Heroes fail only past the feasibility ceiling (68%).
    const capLimit = heroIds.has(gid) ? Math.max(capFail, 0.68) : capFail;
    if (oblit.length || peakCap >= capLimit) {
      status = "FAIL";
      failures.push(gid);
    } else if (warn.length) status = "WARN";
    let s = "";
    if (oblit.length)
      s =
        oblit
          .slice(0, 5)
          .map(([t, p]) => `${t}(${(p * 100).toFixed(0)}%)`)
          .join(" ") + (oblit.length > 5 ? ` …+${oblit.length - 5}` : "");
    else if (warn.length)
      s =
        "[warn] " +
        warn
          .slice(0, 3)
          .map(([t, p]) => `${t}(${(p * 100).toFixed(0)}%)`)
          .join(" ");
    console.log(
      `  ${gid}  ${entry.layer}  avg ${(avgCap * 100).toFixed(0)}%  peak ${(peakCap * 100).toFixed(0)}%  ${status}  ${s}`,
    );
    // HERO target-occlusion advisory (not a failure): a hero should sit ON the subject
    // (~30–55%). If it barely grazes, it reads as a small floating word, not an embed.
    if (heroIds.has(gid) && peakCap < 0.15) {
      // METRIC HONESTY: this advisory uses CAP-AREA occlusion, which saturates ~15%
      // for a width-filled hero over a narrow subject (the 30–55% figure elsewhere is
      // the safe-zones BAND metric — different denominator). If the hero already owns
      // the width, "center it + make it BIG" is unactionable — stay quiet.
      const widest = Math.max(...entry.samples.map((sm) => (sm.cap_bbox && sm.cap_bbox.w) || 0), 0);
      if (widest >= frameW * 0.8) {
        console.log(
          `  ${gid}  [hero-ok] peak ${(peakCap * 100).toFixed(0)}% cap-area — width-saturated hero over a narrow subject; cap-area can't reach the band target (this is the geometry, not a layout fault).`,
        );
      } else {
        console.log(
          `  ${gid}  [hero-weak] peak ${(peakCap * 100).toFixed(0)}% — hero barely crosses the subject; it should sit ON the subject (~30–55% by the safe-zones BAND metric = the embed effect). Center it (safe-zones heroAnchor) + make it BIG; don't park it in a clean margin.`,
        );
      }
    }
  }
  if (failures.length) {
    const uniq = [...new Set(failures)];
    console.error(`\n[v2] ${uniq.length} cap(s) FAIL: ${uniq.join(", ")}`);
    console.error(
      "  → occlusion: set that cap's layer to fg, OR shrink/reposition;  overflow: shrink/reposition (fg won't help)",
    );
  }
  process.exit(strict && failures.length ? 2 : 0);
}
main().catch((e) => {
  console.error("[v2]", e.message);
  process.exit(1);
});
