// QA pe originea servită, fără browser.
//
// Rostul lui: fiecare cifră tipărită în text e recalculată din datele publicate
// și comparată cu ce scrie în pagină. Dacă o măsurătoare se schimbă și textul nu,
// buildul trece dar QA-ul cade.
//
//   node qa_assertions.mjs                         # pe public/, prin serverul local
//   node qa_assertions.mjs https://straturi.mariuscomper.uk

const BASE = (process.argv[2] || "http://localhost:4842").replace(/\/$/, "");
let pass = 0;
const fails = [];

function ok(name, cond, detail = "") {
  if (cond) { pass++; return true; }
  fails.push(`${name}${detail ? ` — ${detail}` : ""}`);
  return false;
}
const has = (hay, needle, name) =>
  ok(name, hay.includes(needle), `lipsește „${needle}”`);

async function get(path, json = false) {
  const r = await fetch(BASE + path);
  if (!r.ok) throw new Error(`${path} → ${r.status}`);
  return json ? r.json() : r.text();
}

const roInt = (n) => Math.round(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
const enInt = (n) => Math.round(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
const roPct = (n) => `${n.toFixed(1).replace(".", ",")} la sută`;
const enPct = (n) => `${n.toFixed(1)} per cent`;
const roDec = (n, d = 1) => n.toFixed(d).replace(".", ",");

const run = async () => {
  // ——— pagini și fișiere ————————————————————————————————————————————————
  const pages = ["/", "/en/", "/metoda/", "/en/method/", "/surse/", "/en/sources/"];
  const html = {};
  for (const p of pages) {
    html[p] = await get(p);
    ok(`pagina ${p} răspunde`, html[p].length > 3000, `${html[p].length} octeți`);
  }
  for (const f of ["/robots.txt", "/sitemap.xml", "/llms.txt",
    "/assets/site.css", "/assets/site.js", "/data/client.json",
    "/data/index.json", "/data/strata.json", "/data/meta.json",
    "/assets/facebook.png", "/assets/facebook-en.png",
    "/downloads/straturi-source.zip", "/downloads/straturi-source.sha256",
    "/downloads/DEXONLINE-NOTICE.md"]) {
    try { await get(f); pass++; } catch (e) { fails.push(`fișier ${f}: ${e.message}`); }
  }
  const dexNotice = await fetch(BASE + "/downloads/DEXONLINE-NOTICE.md");
  ok("nota dexonline se deschide ca Markdown",
    (dexNotice.headers.get("content-type") || "").startsWith("text/markdown"));
  ok("404 dă status 404", (await fetch(BASE + "/nu-exista-pagina")).status === 404);
  const robots = await get("/robots.txt");
  has(robots, "Content-Signal: ai-train=no, search=yes, ai-input=yes",
    "robots.txt declară Content Signals");

  const meta = await get("/data/meta.json", true);
  const strata = await get("/data/strata.json", true);
  const index = await get("/data/index.json", true);
  const client = await get("/data/client.json", true);
  const agreement = await get("/data/agreement.json", true);

  const S = Object.fromEntries(strata.map((s) => [s.id, s]));
  const ser = meta.series;
  const spoken = ser.vorbit, written = ser.scris;
  const typesTotal = strata.reduce((a, s) => a + s.types_all, 0);
  const pctOf = (st, s) => (s.per[st] || 0) / s.assigned * 100;
  const typePct = (st) => S[st].types_all / typesTotal * 100;
  const off = (y) => ser[`o${y}`];
  const officialYears = Object.values(ser).filter((s) => s.kind === "oficial");

  // ——— coerența datelor ————————————————————————————————————————————————
  ok("indexul are lungimea anunțată", index.length === meta.vocab.shipped,
    `${index.length} vs ${meta.vocab.shipped}`);
  ok("indexul e sortat și fără dubluri",
    index.every((w, i) => i === 0 || index[i - 1] < w));
  ok("cele 15 straturi există toate", strata.length === 15);
  ok("fiecare strat are exemple", strata.every((s) => s.examples.length > 0));
  ok("straturile au aceleași chei în ro și en",
    JSON.stringify(Object.keys(client.ro.strata)) ===
    JSON.stringify(Object.keys(client.en.strata)));
  ok("cinci intervale istorice", meta.hist.bins.length === 5);
  ok("nouă documente oficiale", officialYears.length === 9,
    `${officialYears.length}`);
  for (const s of Object.values(ser)) {
    ok(`seria ${s.kind} are text atribuit`, s.assigned > 0 && s.assigned <= s.tokens);
  }
  ok("procentele pe strat însumează 100 în vorbit",
    Math.abs(Object.keys(S).reduce((a, st) => a + pctOf(st, spoken), 0) - 100) < 0.5);
  ok("procentele pe strat însumează 100 în scris",
    Math.abs(Object.keys(S).reduce((a, st) => a + pctOf(st, written), 0) - 100) < 0.5);

  // ——— cifrele din text ————————————————————————————————————————————————
  const ro = html["/"], en = html["/en/"];
  const roM = html["/metoda/"], enM = html["/en/method/"];

  has(ro, roInt(meta.vocab.shipped), "numărul de cuvinte, ro");
  has(en, enInt(meta.vocab.shipped), "numărul de cuvinte, en");
  has(ro, roInt(meta.vocab.layered_lemmas), "lexeme cu donator, ro");
  has(ro, roPct(typePct("franceza")), "procentul de tipuri franceze, ro");
  has(en, enPct(typePct("franceza")), "procentul de tipuri franceze, en");
  has(ro, roPct(typePct("latina")), "procentul de tipuri latine, ro");
  has(ro, roPct(pctOf("latina", spoken)), "latina în vorbit, ro");
  has(en, enPct(pctOf("latina", spoken)), "latina în vorbit, en");
  has(ro, roPct(pctOf("franceza", spoken)), "franceza în vorbit, ro");
  has(ro, roPct(pctOf("latina", written)), "latina în scris, ro");
  has(ro, roPct(pctOf("franceza", off(1848))), "franceza în 1848, ro");
  has(ro, roPct(pctOf("franceza", off(1866))), "franceza în 1866, ro");
  has(ro, roPct(pctOf("franceza", off(2003))), "franceza în 2003, ro");
  has(ro, roPct(pctOf("slavona", off(1848))), "slavona în 1848, ro");
  has(ro, roPct(pctOf("slavona", off(1866))), "slavona în 1866, ro");
  has(ro, roPct(pctOf("vecini", off(1938))), "vecinii în 1938, ro");
  has(ro, roPct(pctOf("vecini", off(1965))), "vecinii în 1965, ro");
  has(ro, roDec(pctOf("turca", spoken) / pctOf("turca", written)),
    "raportul turcesc vorbit/scris, ro");
  has(ro, roDec(pctOf("franceza", written) / pctOf("franceza", spoken)),
    "raportul francez scris/vorbit, ro");
  has(ro, roInt(S.engleza.types_all), "tipuri engleze, ro");
  has(ro, roInt(S.necunoscut.types_all), "tipuri necunoscute, ro");

  const frMax = officialYears.reduce((a, b) =>
    (pctOf("franceza", b) > pctOf("franceza", a) ? b : a));
  has(ro, roPct(pctOf("franceza", frMax)), "vârful francez din textul oficial, ro");
  ok("vârful francez e în 1952", frMax.year === 1952, `e ${frMax.year}`);

  has(roM, roInt(meta.hist.total_tokens), "cuvintele corpusului istoric, ro");
  has(roM, roInt(meta.now.vorbit.tokens), "cuvintele corpusului vorbit, ro");
  has(roM, roInt(meta.now.scris.tokens), "cuvintele corpusului scris, ro");
  has(roM, roInt(meta.now.scris.articles), "articolele de Wikipedia, ro");
  has(roM, roInt(meta.dex_forms), "formele flexionare, ro");
  has(roM, roInt(agreement.compared), "cuvintele comparate cu dexonline, ro");
  has(roM, roPct(agreement.rate * 100), "acordul strict, ro");
  has(roM, roPct(agreement.family_rate * 100), "acordul pe limba-sursă, ro");
  has(roM, roInt(meta.vocab.plotted), "cuvintele cu curbă proprie, ro");
  has(roM, roInt(meta.hist.cap_per_author), "plafonul pe autor, ro");
  has(roM, roInt(meta.hist.bins[0].tokens), "cuvintele primului interval, ro");
  has(enM, enInt(meta.hist.total_tokens), "cuvintele corpusului istoric, en");
  has(enM, enPct(agreement.rate * 100), "acordul strict, en");
  has(ro, roPct(spoken.assigned / spoken.tokens * 100),
    "acoperirea clasificării pentru corpusul vorbit, ro");
  has(ro, roPct(written.assigned / written.tokens * 100),
    "acoperirea clasificării pentru corpusul scris, ro");
  has(en, enPct(spoken.assigned / spoken.tokens * 100),
    "acoperirea clasificării pentru corpusul vorbit, en");
  has(roM, "Copyright © 2004–2026 dexonline", "atribuirea dexonline, ro");
  has(enM, "Copyright © 2004–2026 dexonline", "atribuirea dexonline, en");
  has(roM, "GNU GPL v2 sau orice versiune ulterioară", "licența dexonline, ro");
  has(enM, "GNU GPL v2 or any later version", "licența dexonline, en");
  has(roM, 'href="/downloads/DEXONLINE-NOTICE.md"',
    "nota dexonline are link public, ro");
  has(enM, 'href="/downloads/DEXONLINE-NOTICE.md"',
    "nota dexonline are link public, en");
  ok("formulările istorice și lingvistice vechi au fost eliminate",
    ![ro, en].some((s) => [
      "Cuvintele ocupației", "Toată gramatica e latină",
      "condusă de la Istanbul", "copiată după cea belgiană",
      "The occupier's words", "The entire grammar is Latin",
      "ruled from Istanbul", "copied from the Belgian",
    ].some((needle) => s.includes(needle))));
  ok("formulările românești artificiale au fost eliminate",
    ![ro, roM].some((s) => [
      "Dezacordurile nu sunt zgomot", "certuri reale despre drum",
      "într-o mână de cuvinte", "textul curgător țin",
      "se umflă în locul ei", "Aceeași limbă, două sorți opuse",
      "Dovada fiind negativă", "unde se ținea puterea",
      "mașina de făcut verbe", "Granițele, apoi imperiile",
      "strat căruia comunismul i se vede", "prin ochi, nu prin gură",
      "a stat în gură o mie de ani", "limba statului despre sine",
      "stratul care nu are în spate", "drepturile de autor închid arhiva",
      "cinci sferturi de secol", "Traducerile ies afară",
      "Româna aproape nu-și arată", "gaură de cincizeci de ani",
      "cuvintele grele",
    ].some((needle) => s.includes(needle))));
  has(JSON.stringify(client.ro), "mai des în replici de film decât în articolele Wikipedia",
    "comparația de registru numește ambii termeni, ro");
  has(JSON.stringify(client.en), "more common in film dialogue than in Wikipedia articles",
    "comparația de registru numește ambii termeni, en");

  // corecțiile de mână apar toate pe pagina de metodă
  for (const w of Object.keys(meta.overrides)) {
    has(roM, `<b>${w}</b>`, `corecția „${w}” e listată`);
  }
  has(roM, roInt(Object.keys(meta.overrides).length), "numărul de corecții, ro");

  // ——— coloana ——————————————————————————————————————————————————————————
  const bandRe = /data-stratum="([a-z_]+)"[^>]*\n?\s*data-types="([\d.]+)"\s*\n?\s*data-spoken="([\d.]+)"\s*\n?\s*data-written="([\d.]+)"/g;
  const bands = [...ro.matchAll(bandRe)];
  ok("coloana are 15 benzi și o legendă de 15", bands.length === 30,
    `${bands.length} elemente cu date`);
  for (const [, st, ty, sp, wr] of bands.slice(0, 15)) {
    ok(`banda ${st} poartă cifra corectă`,
      Math.abs(+ty - typePct(st)) < 0.01 &&
      Math.abs(+sp - pctOf(st, spoken)) < 0.01 &&
      Math.abs(+wr - pctOf(st, written)) < 0.01);
  }

  // ——— dicționarul pe felii ————————————————————————————————————————————
  const probes = [
    ["mahala", "turca"], ["bulevard", "franceza"], ["oraș", "maghiara"],
    ["dragoste", "slavona"], ["biserică", "latina"], ["brânză", "substrat"],
    ["deget", "latina"], ["digital", "franceza"], ["cafea", "turca"],
  ];
  for (const [w, expect] of probes) {
    const sh = await get(`/data/words/${encodeURIComponent(w[0])}.json`, true);
    const rec = sh.w[w];
    if (!ok(`„${w}” e în dicționar`, !!rec)) continue;
    ok(`„${w}” e în stratul ${expect}`, rec.l === expect, `e ${rec.l}`);
    ok(`„${w}” are frecvențe`, rec.f && typeof rec.f.vorbit === "number");
  }
  // formele flexionare duc la lemă
  const infl = [["caselor", "casă"], ["mahalaua", "mahala"], ["branza", "brânză"],
    ["oraşul", "oraș"]];
  for (const [form, lemma] of infl) {
    const key = form[0].toLowerCase().replace("ş", "ș");
    const sh = await get(`/data/forms/${encodeURIComponent(key)}.json`, true);
    const got = sh.f[form.toLowerCase().replace(/ş/g, "ș").replace(/â/g, "î")]
      || sh.f[form.toLowerCase()];
    ok(`„${form}” duce la „${lemma}”`, got === lemma, `dă ${got}`);
  }

  // ——— invariante editoriale ————————————————————————————————————————————
  for (const [p, s] of Object.entries(html)) {
    const body = s.replace(/<script[\s\S]*?<\/script>/g, "");
    ok(`${p} nu are cedile turcești`, !/[şţŞŢ]/.test(
      body.replace(/oraşul/g, "")), "s-a strecurat o sedilă");
    ok(`${p} nu are jetoane neînlocuite`, !/\{[a-zA-Z]\w{2,}\}/.test(body),
      (body.match(/\{[a-zA-Z]\w{2,}\}/) || [])[0]);
    ok(`${p} declară limba`, /<html lang="(ro|en)">/.test(s));
    ok(`${p} are canonical`, s.includes('rel="canonical"'));
    ok(`${p} are og:image`, s.includes('property="og:image"'));
    ok(`${p} trimite la cealaltă ediție`, /hreflang="(ro|en)"/.test(s));
    ok(`${p} descoperă ediția Markdown`,
      s.includes('rel="alternate" type="text/markdown"'));
    ok(`${p} are controlul spre început`, s.includes('class="back-to-top" href="#top"'));
  }
  ok("ediția română nu conține text englezesc de navigație",
    !ro.includes(">Method<") && !ro.includes(">Sources<"));
  ok("ediția engleză nu conține text românesc de navigație",
    !en.includes(">Metodă<") && !en.includes(">Surse<"));
  has(ro, '"url":"https://mariuscomper.uk/"', "autorul JSON-LD are URL românesc");
  has(en, '"url":"https://mariuscomper.uk/en/"', "autorul JSON-LD are URL englezesc");
  has(ro, 'href="https://mariuscomper.uk/"', "footerul RO trimite la catalog");
  has(en, 'href="https://mariuscomper.uk/en/"', "footerul EN trimite la catalogul EN");
  const sourceHref = roM.match(
    /href="(\/downloads\/straturi-source-[a-f0-9]{12}\.zip)"/)?.[1];
  const sourceShaHref = roM.match(
    /href="(\/downloads\/straturi-source-[a-f0-9]{12}\.sha256)"/)?.[1];
  ok("metoda RO oferă arhiva cu nume content-addressed", !!sourceHref);
  ok("metoda RO oferă checksumul content-addressed", !!sourceShaHref);
  has(enM, sourceHref || "__missing_source_archive__", "metoda EN oferă aceeași arhivă");
  has(enM, sourceShaHref || "__missing_source_checksum__", "metoda EN oferă același checksum");
  if (sourceHref && sourceShaHref) {
    ok("arhiva content-addressed răspunde", (await fetch(BASE + sourceHref)).ok);
    ok("checksumul content-addressed răspunde", (await fetch(BASE + sourceShaHref)).ok);
  }

  // ——— rezultat —————————————————————————————————————————————————————————
  console.log(`\n${BASE}`);
  console.log(`  au trecut: ${pass}`);
  if (fails.length) {
    console.log(`  au căzut:  ${fails.length}`);
    for (const f of fails) console.log(`   ✗ ${f}`);
    process.exit(1);
  }
  console.log("  toate verificările trec\n");
};

run().catch((e) => { console.error("QA a eșuat:", e.message); process.exit(1); });
