// L2 — 렌더링 불변식. file:// 로 앱을 열어 검사한다(서버 불필요).
// 줄수(세로 쌓임·갸행) · 수축 탐지 · 대비 · axe 접근성 · 콘텐츠 정합 · 스케일×폭 행렬.
import puppeteer from "puppeteer-core";
import path from "node:path";
import url from "node:url";

const here = path.dirname(url.fileURLToPath(import.meta.url));
const ROOT = path.resolve(here, "../public/work");
const CHROME = "C:/Program Files/Google/Chrome/Application/chrome.exe";
const results = [];
const fail = (name, detail = "") => results.push({ ok: false, name, detail });
const pass = (name, detail = "") => results.push({ ok: true, name, detail });

const GAON_VIEWS = ["dashboard", "students", "attendance", "grades", "files", "calendar", "counsel", "report", "notice"];
const DURE_VIEWS = ["catalog", "starred", "enrollments", "credits", "archive", "notices"];

const PAGE_UTILS = `
  window.__lines = (el) => {
    if (!el) return -1;
    const range = document.createRange();
    range.selectNodeContents(el);
    const rects = [...range.getClientRects()].filter((r) => r.width > 1 && r.height > 4);
    const tops = rects.map((r) => r.top).sort((a, b) => a - b);
    const ls = [];
    for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t);
    return ls.length;
  };
  window.__textLines = (el) => {
    if (!el) return -1;
    const tn = [...el.childNodes].find((n) => n.nodeType === 3 && n.textContent.trim());
    const target = tn || el;
    const range = document.createRange();
    range.selectNodeContents(target);
    const rects = [...range.getClientRects()].filter((r) => r.width > 1 && r.height > 4);
    const tops = rects.map((r) => r.top).sort((a, b) => a - b);
    const ls = [];
    for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t);
    return ls.length;
  };
`;

async function openApp(page, app) {
  await page.goto(url.pathToFileURL(path.join(ROOT, app, "index.html")).href, { waitUntil: "networkidle0" });
  await page.evaluate(() => document.fonts.ready);
  await page.evaluate(PAGE_UTILS);
}

async function scanInvariants(page) {
  return page.evaluate(() => {
    const out = { stacked: [], contrast: [], h1: [] };
    const lum = ({ r, g, b }) => {
      const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
      return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
    };
    const rgb = (c) => { const m = c.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/); return m ? { r: +m[1], g: +m[2], b: +m[3], a: m[4] === undefined ? 1 : +m[4] } : null; };
    const blend = (top, bottom) => ({ r: top.r * top.a + bottom.r * (1 - top.a), g: top.g * top.a + bottom.g * (1 - top.a), b: top.b * top.a + bottom.b * (1 - top.a), a: 1 });
    const bgOf = (el) => {
      let node = el; const stack = [];
      while (node && node !== document.documentElement) {
        const c = rgb(getComputedStyle(node).backgroundColor);
        if (c && c.a >= 0.95) return c;
        if (c && c.a > 0) stack.push(c);
        node = node.parentElement;
      }
      let base = { r: 255, g: 255, b: 255, a: 1 };
      for (let i = stack.length - 1; i >= 0; i--) base = blend(stack[i], base);
      return base;
    };
    const h1 = document.querySelector(".view.is-on h2.v-h") || document.querySelector("h1");
    if (h1 && window.__lines(h1) > 1) out.h1.push(h1.textContent.trim().slice(0, 16));
    document.querySelectorAll("body *").forEach((el) => {
      if (!el.offsetParent) return;
      const txt = el.textContent.trim();
      const ownText = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
      if (!txt || !ownText) return;
      const cs = getComputedStyle(el);
      if (txt.length <= 14 && !el.querySelector("*") && window.__textLines(el) >= 3) {
        out.stacked.push(txt.slice(0, 12) + " (" + window.__textLines(el) + "줄)");
      }
      const fg = rgb(cs.color);
      if (fg) {
        const bg = bgOf(el);
        const fgb = blend(fg, bg);
        const l1 = lum(fgb), l2 = lum(bg);
        const ratio = +(((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05))).toFixed(2);
        const size = parseFloat(cs.fontSize);
        const w = +cs.fontWeight || 400;
        const need = size >= 24 || (size >= 18.66 && w >= 600) ? 3 : 4.5;
        if (ratio < need) {
          const key = cs.color + Math.round(size);
          if (!out.contrast.some((c) => c.key === key)) {
            out.contrast.push({ key, txt: txt.slice(0, 12), ratio, need });
          }
        }
      }
    });
    return out;
  });
}

async function runAxe(page) {
  const axePath = path.resolve(here, "../node_modules/axe-core/axe.min.js");
  await page.addScriptTag({ path: axePath });
  const res = await page.evaluate(async () => {
    const r = await window.axe.run(document, { resultTypes: ["violations"] });
    return r.violations.map((v) => ({ id: v.id, impact: v.impact, nodes: v.nodes.length }));
  });
  return res;
}

async function contentConsistency(page) {
  return page.evaluate(() => {
    const out = [];
    if (window.GAON && document.getElementById("rep-stats")) {
      const stats = GAON.rosterStats(STUDENTS);
      const txt = document.getElementById("rep-stats").textContent;
      if (!txt.includes(stats.active + "명")) out.push("리포트 재학생 " + stats.active + "명 표시 안 됨");
      if (!txt.includes(stats.avgAtt + "%")) out.push("리포트 평균출석 " + stats.avgAtt + "% 표시 안 됨");
      if (!txt.includes(stats.hwRate + "%")) out.push("리포트 제출률 " + stats.hwRate + "% 표시 안 됨");
    }
    if (window.DURE && document.getElementById("en-credits")) {
      const list = [...enrolled].map((id) => COURSES.find((c) => c.id === id));
      const want = DURE.credits(list);
      const got = document.getElementById("en-credits").textContent.trim();
      if (got !== String(want)) out.push("신청 학점 표시 " + got + " ≠ 재계산 " + want);
    }
    return out;
  });
}

const browser = await puppeteer.launch({ executablePath: CHROME, headless: "new", args: ["--force-device-scale-factor=1"] });
const page = await browser.newPage();
await page.setCacheEnabled(false);

for (const [app, views] of [["gaon-lms", GAON_VIEWS], ["dure-enrollment", DURE_VIEWS]]) {
  await page.setViewport({ width: 1440, height: 900 });
  await openApp(page, app);
  for (const v of views) {
    await page.evaluate((name) => document.querySelector(`[data-view="${name}"]`).click(), v);
    await new Promise((r) => setTimeout(r, 80));
    const inv = await scanInvariants(page);
    if (inv.h1.length) fail(`${app}/${v} 뷰 제목 여러 줄`, inv.h1.join(", "));
    if (inv.stacked.length) fail(`${app}/${v} 세로 쌓임(수축)`, inv.stacked.slice(0, 4).join(", "));
    if (inv.contrast.length) fail(`${app}/${v} 대비 미달`, inv.contrast.slice(0, 3).map((c) => `"${c.txt}" ${c.ratio}:1`).join(", "));
  }
  pass(`${app} 불변식(전 뷰)`, "제목·수축·대비");

  await page.evaluate((name) => document.querySelector(`[data-view="${name}"]`).click(), views[0]);
  const violations = await runAxe(page);
  const serious = violations.filter((v) => v.impact === "serious" || v.impact === "critical");
  if (serious.length) fail(`${app} axe 심각 위반`, serious.map((v) => `${v.id}×${v.nodes}`).join(", "));
  else pass(`${app} axe`, violations.length ? `minor ${violations.length}종 무시` : "위반 0");

  const cons = await contentConsistency(page);
  if (cons.length) fail(`${app} 콘텐츠 정합`, cons.join(" / "));
  else pass(`${app} 콘텐츠 정합`);

  let matrixBad = [];
  for (const w of [320, 375, 390]) {
    for (const scale of [1.0, 1.3]) {
      await page.setViewport({ width: w, height: 800, isMobile: true, hasTouch: true });
      await openApp(page, app);
      await page.addStyleTag({ content: `html { font-size: ${16 * scale}px !important; }` });
      const lines = await page.evaluate("window.__lines(document.querySelector('.view.is-on h2.v-h') || document.querySelector('h1'))");
      if (lines > 1) matrixBad.push(`${w}@${scale}x:${lines}줄`);
    }
  }
  if (matrixBad.length) fail(`${app} 스케일×폭 제목`, matrixBad.join(", "));
  else pass(`${app} 스케일×폭 제목 행렬`);

  await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
  await openApp(page, app);
  const brandLines = await page.evaluate("window.__lines(document.querySelector('.brand, .appbar .brand'))");
  if (brandLines > 1) fail(`${app} 브랜드 세로 쌓임`, brandLines + "줄");
  else pass(`${app} 브랜드 1줄`);
}
await browser.close();

const totalFail = results.filter((r) => !r.ok).length;
for (const r of results) console.log((r.ok ? "PASS" : "FAIL") + "  " + r.name + (r.detail ? ` — ${r.detail}` : ""));
console.log(totalFail ? `\nINVARIANTS: ${totalFail} FAIL` : "\nINVARIANTS ALL PASS");
process.exit(totalFail ? 1 : 0);
