import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";

const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const data = JSON.parse(fs.readFileSync(path.join(projectRoot, "app/quiz-data.json"), "utf8"));

test("renders the finished site metadata", async () => {
  const workerUrl = new URL("../dist/server/index.js", import.meta.url);
  workerUrl.searchParams.set("test", `${process.pid}-${Date.now()}`);
  const { default: worker } = await import(workerUrl.href);

  const staticHtml = fs.readFileSync(path.join(projectRoot, "public/index.html"), "utf8");
  const response = await worker.fetch(
    new Request("http://localhost/", { headers: { accept: "text/html" } }),
    { ASSETS: { fetch: async () => new Response(staticHtml, { headers: { "content-type": "text/html; charset=utf-8" } }) } },
    { waitUntil() {}, passThroughOnException() {} },
  );

  assert.equal(response.status, 200);
  assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
  const html = await response.text();
  assert.match(html, /<title>Ideology Sorter<\/title>/i);
  assert.match(html, /172 political ideologies through one complete decision tree/i);
  assert.match(html, /width=device-width,initial-scale=1,viewport-fit=cover/i);
  assert.match(html, /href="\/ideosorter\.css\?v=U1\.18"/i);
  assert.match(html, /src="\/ideosorter\.js\?v=U1\.18"/i);
  assert.equal((html.match(/quark88\.github\.io\/ideosorter/gi) || []).length, 1);
  assert.doesNotMatch(html, /beingintheera|Left-WT|Unified IdeoSorter/i);
  assert.ok(fs.existsSync(path.join(projectRoot, "public/source/ideology-sorter-source.zip")));
  assert.ok(!fs.existsSync(path.join(projectRoot, "public/source/unified-ideosorter-source.zip")));
  assert.equal(response.headers.get("cache-control"), "no-cache, max-age=0, must-revalidate");
  assert.doesNotMatch(html, /_next\/static|__next_f|react-server-dom/i);
  assert.doesNotMatch(html, /codex-preview/i);
  assert.ok(fs.statSync(path.join(projectRoot, "dist/server/index.js")).size < 10_000, "static entry worker should not eagerly bundle the app runtime");
});

test("every merged route terminates in a real ideology with a real flag", () => {
  assert.equal(Object.keys(data.ideologies).length, data.counts.ideologies);
  assert.equal(Object.keys(data.graph).length, data.counts.questions);
  assert.equal("routes" in data, false, "route paths should be derived client-side, not duplicated in the payload");
  assert.ok(data.graph[data.start]);

  const active = new Set();
  const reachedQuestions = new Set();
  const reachedResults = new Set();
  let routeCount = 0;

  function walk(id) {
    assert.ok(!active.has(id), `cycle detected at ${id}`);
    const node = data.graph[id];
    assert.ok(node, `missing question ${id}`);
    assert.ok(node.choices.length >= 2 && node.choices.length <= 5, `invalid choice count at ${id}`);
    active.add(id);
    reachedQuestions.add(id);
    for (const choice of node.choices) {
      if (choice.type === "question") {
        walk(choice.id);
      } else {
        assert.ok(data.ideologies[choice.ideology], `missing ideology ${choice.ideology}`);
        reachedResults.add(choice.ideology);
        routeCount += 1;
      }
    }
    active.delete(id);
  }

  walk(data.start);
  assert.equal(reachedQuestions.size, data.counts.questions);
  assert.equal(reachedResults.size, data.counts.ideologies);
  assert.equal(routeCount, data.counts.routes);
  assert.equal(routeCount, reachedResults.size, "each ideology should have exactly one best route");

  for (const ideology of Object.values(data.ideologies)) {
    const asset = path.join(projectRoot, "public", ideology.flag.replace(/^\//, ""));
    const thumbnail = path.join(projectRoot, "public", ideology.thumbnail.split("?", 1)[0].replace(/^\//, ""));
    const displayImage = path.join(projectRoot, "public", ideology.image.split("?", 1)[0].replace(/^\//, ""));
    assert.ok(fs.existsSync(asset), `missing flag asset for ${ideology.name}`);
    assert.ok(fs.existsSync(thumbnail), `missing thumbnail for ${ideology.name}`);
    assert.ok(fs.existsSync(displayImage), `missing display image for ${ideology.name}`);
    assert.ok(fs.statSync(thumbnail).size < 25_000, `thumbnail is too large for ${ideology.name}`);
    assert.ok(fs.statSync(displayImage).size < 50_000, `display image is too large for ${ideology.name}`);
    for (const imagePath of [thumbnail, displayImage]) {
      const header = fs.readFileSync(imagePath).subarray(0, 12);
      assert.equal(header.subarray(0, 4).toString(), "RIFF", `invalid WebP header for ${ideology.name}`);
      assert.equal(header.subarray(8, 12).toString(), "WEBP", `invalid WebP signature for ${ideology.name}`);
    }
  }

  assert.ok(fs.statSync(path.join(projectRoot, "app/quiz-data.json")).size < 200_000, "quiz payload regressed in size");
});

test("the static payload and Quark-style tree stay compact and complete", () => {
  const publicDataPath = path.join(projectRoot, "public/data/quiz-data.json");
  const treePath = path.join(projectRoot, "public/data/tree-layout.json");
  const html = fs.readFileSync(path.join(projectRoot, "public/index.html"), "utf8");
  const css = fs.readFileSync(path.join(projectRoot, "public/ideosorter.css"), "utf8");
  const client = fs.readFileSync(path.join(projectRoot, "public/ideosorter.js"), "utf8");
  const publicData = JSON.parse(fs.readFileSync(publicDataPath, "utf8"));
  const layouts = JSON.parse(fs.readFileSync(treePath, "utf8"));
  const sourceLayouts = JSON.parse(fs.readFileSync(path.join(projectRoot, "app/tree-layout.json"), "utf8"));
  assert.deepEqual(publicData, data);
  assert.deepEqual(layouts, sourceLayouts, "the public Tree should use the fixed reviewed layout");
  assert.equal(data.version, "U1.18");
  assert.match(html, /\/data\/quiz-data\.json\?v=U1\.18/);
  assert.match(client, /const assetVersion = "U1\.18"/);
  assert.equal(data.graph.q_markets.question, "Should market exchange remain a lasting institution after capitalism?");
  assert.equal(data.graph.q_communism.question, "Should post-capitalist society ultimately abolish classes, the state, and money?");
  assert.equal(data.graph.q_feudalRebirth.question, "Should national rebirth favor small private property over large estates and monopolies?");
  assert.match(client, /tree-layout\.json\?v=\$\{assetVersion\}/);
  assert.match(client, /paint-order="stroke fill"/);
  assert.match(client, /dataset\.crossing/);
  assert.match(client, /requestAnimationFrame\(reset\)/, "view changes should reliably reset iOS scroll position");
  assert.match(client, /question\.dataset\.length/, "long questions should receive responsive sizing");
  assert.equal((html.match(/class="ambient-orb ambient-orb-[a-e]"/g) || []).length, 5,
    "the adaptive background should use five lightweight color layers");
  assert.match(css, /\.ambient-background\s*\{/);
  assert.match(css, /radial-gradient\(ellipse at var\(--ambient-x/,
    "ambient colors should span the viewport instead of sitting beyond its edges");
  assert.match(css, /\.ambient-orb-a\s*\{[^}]*--ambient-x:\s*0%/s);
  assert.match(css, /\.ambient-orb-b\s*\{[^}]*--ambient-x:\s*100%/s);
  assert.match(css, /@keyframes ambient-drift/);
  assert.match(css, /transition:\s*color 900ms/);
  assert.match(css, /data-count="2"\] \{ --oa: 0\.48; --ob: 0\.46; \}/,
    "two-answer pages should retain approximately the original blue background's visual strength");
  assert.doesNotMatch(client, /else setAmbientForView\("result"\)/,
    "uncached results should transition directly from the prior palette into the artwork palette");
  assert.match(css, /--accent:\s*#a19d95/);
  assert.doesNotMatch(css, /--blue:|#00a0ff/,
    "the default interface accent should remain neutral rather than blue");
  assert.match(client, /const answerAmbientColors = \[/,
    "question backgrounds should derive from the answer-button palette");
  assert.match(client, /function sampleImagePalette\(image\)/,
    "result backgrounds should derive from the displayed ideology artwork");
  assert.match(client, /context\.drawImage\(image, 0, 0, canvas\.width, canvas\.height\)/,
    "palette sampling should reuse the image already loaded on the result page");
  assert.match(css, /\.arrowCell\[data-crossing\] div/);
  assert.match(css, /min-height:\s*100svh/);
  assert.match(css, /@media \(max-width:\s*680px\)/);
  assert.match(css, /\.question\[data-length="long"\]/);
  assert.match(css, /-webkit-overflow-scrolling:\s*touch/);
  assert.match(css, /\.flag\s*\{[^}]*height:\s*auto;[^}]*object-fit:\s*contain;/s,
    "result artwork should retain its intrinsic proportions without cropping");
  assert.doesNotMatch(css, /\.flag\s*\{[^}]*object-fit:\s*cover;/s,
    "mobile result artwork should never be cropped");
  assert.doesNotMatch(css, /data-stack/);

  assert.equal((html.match(/class="tree-scroll"/g) || []).length, 2, "each tree needs its own scrolling canvas");
  assert.match(css, /--tree-node-width:\s*8\.625rem/);
  assert.match(css, /--tree-node-height:\s*5\.75rem/);
  assert.match(css, /--tree-arrow-size:\s*3\.75rem/);
  assert.match(client, /var\(--tree-node-width\) var\(--tree-arrow-size\)/);
  assert.match(client, /var\(--tree-node-height\) var\(--tree-arrow-size\)/);
  assert.doesNotMatch(client, /element\.style\.aspectRatio/, "the merged tree must scroll instead of being squeezed to the viewport");

  const coreFiles = ["index.html", "ideosorter.css", "ideosorter.js"]
    .map((name) => path.join(projectRoot, "public", name));
  const initialAndTreeBytes = [...coreFiles, publicDataPath, treePath]
    .reduce((sum, file) => sum + fs.statSync(file).size, 0);
  assert.ok(initialAndTreeBytes < 220_000, `static core regressed to ${initialAndTreeBytes} bytes`);

  const coveredQuestions = new Set();
  const coveredResults = new Set();
  for (const [branch, rootId] of [["pro", "q_constitution"], ["anti", "q_markets"]]) {
    const layout = layouts[branch];
    const byKey = new Map(layout.cells.map((cell) => [cell.key, cell]));
    const coordinates = new Set();
    for (const cell of layout.cells) {
      const coordinate = `${cell.column}:${cell.row}`;
      assert.ok(!coordinates.has(coordinate), `duplicate ${branch} tree cell at ${coordinate}`);
      coordinates.add(coordinate);
      if (cell.type === "question") coveredQuestions.add(cell.id);
      else coveredResults.add(cell.ideology);
    }
    assert.equal(byKey.get(`question:${rootId}`).start, true);
    for (const cell of layout.cells.filter((item) => item.type === "question")) {
      for (const choice of data.graph[cell.id].choices) {
        const targetKey = choice.type === "question" ? `question:${choice.id}` : `result:${choice.ideology}`;
        const target = byKey.get(targetKey);
        assert.ok(target, `missing ${branch} tree target ${targetKey}`);
        const dx = Math.abs(target.column - cell.column);
        const dy = Math.abs(target.row - cell.row);
        assert.ok(dx <= 1 && dy <= 1 && dx + dy > 0, `non-adjacent tree edge ${cell.key} -> ${targetKey}`);
      }
    }

    const maximum = branch === "pro" ? { width: 12, height: 14, crossings: 11 } : { width: 13, height: 17, crossings: 14 };
    assert.ok(layout.width <= maximum.width, `${branch} tree is wider than its Quark-style row layout`);
    assert.ok(layout.height <= maximum.height, `${branch} tree is taller than its Quark-style row layout`);
    const minimumDensity = branch === "pro" ? 0.8 : 0.77;
    assert.ok(layout.cells.length / (layout.width * layout.height) >= minimumDensity, `${branch} tree row packing is too sparse`);

    const diagonalGaps = new Set();
    let crossings = 0;
    for (const cell of layout.cells.filter((item) => item.type === "question")) {
      for (const choice of data.graph[cell.id].choices) {
        const targetKey = choice.type === "question" ? `question:${choice.id}` : `result:${choice.ideology}`;
        const target = byKey.get(targetKey);
        const dx = target.column - cell.column;
        const dy = target.row - cell.row;
        if (Math.abs(dx) === 1 && Math.abs(dy) === 1) {
          const gap = `${cell.column * 2 + dx}:${cell.row * 2 + dy}`;
          if (diagonalGaps.has(gap)) crossings += 1;
          diagonalGaps.add(gap);
        }
      }
    }
    assert.equal(crossings, layout.crossings, `${branch} crossing metadata is stale`);
    assert.ok(crossings <= maximum.crossings, `${branch} tree has too many diagonal crossings`);
  }
  assert.equal(coveredQuestions.size, data.counts.questions - 1, "branch maps should include every question after the initial property split");
  assert.equal(coveredResults.size, data.counts.ideologies);

  const proByKey = new Map(layouts.pro.cells.map((cell) => [cell.key, cell]));
  const antiByKey = new Map(layouts.anti.cells.map((cell) => [cell.key, cell]));
  const assertDirection = (byKey, parentKey, childKey, dx, dy) => {
    const parent = byKey.get(parentKey);
    const child = byKey.get(childKey);
    assert.deepEqual([child.column - parent.column, child.row - parent.row], [dx, dy]);
  };
  assertDirection(proByKey, "question:q_constitution", "question:q_stateFunctions", -1, -1);
  assertDirection(proByKey, "question:q_constitution", "question:q_minarchy", 1, 0);
  assertDirection(proByKey, "question:q_constitution", "question:q_counterEcon", -1, 1);
  assertDirection(antiByKey, "question:q_markets", "question:q_authMarkSoc", 1, -1);
  assertDirection(antiByKey, "question:q_markets", "question:q_communism", -1, 1);
});

test("requested canonical names and placements are preserved", () => {
  const targets = (id) => data.graph[id].choices.map((choice) => choice.type === "question" ? choice.id : choice.ideology);

  assert.ok(data.ideologies.Jacobinism);
  assert.ok(data.ideologies.Jacobinism.aliases.includes("Montagnardism"));
  assert.equal(data.ideologies.Montagnardism, undefined);

  assert.ok(data.ideologies["Marxism-Leninism-MZT"]);
  assert.ok(data.ideologies["Marxism-Leninism-MZT"].aliases.includes("Dengism"));
  assert.equal(data.ideologies.Dengism, undefined);

  assert.equal(data.graph.q_anarchoUnions.choices[0].id, "q_proudhon");
  assert.equal(data.graph.q_proudhon.choices[0].ideology, "Proudhonian Mutualism");
  assert.equal(data.graph.q_ethnic.choices[1].ideology, "Tuckerite Mutualism");
  assert.equal(data.graph.q_mutualistCurrent, undefined);
  assert.equal(data.ideologies["Tuckerite Mutualism"].flag, "/assets/left/flags/Tuckerite Mutualism.svg");
  assert.match(data.ideologies["Tuckerite Mutualism"].thumbnail, /\?v=U1\.3$/);

  assert.equal(data.ideologies["Classical social democracy"], undefined);
  assert.equal(data.ideologies["Marxism-leninism-maoism"], undefined);

  assert.equal(data.graph.q_dotp.choices[0].id, "q_stalinCope");
  assert.equal(data.graph.q_dotp.choices[1].id, "q_commAnarchySplit");
  assert.equal(data.graph.q_stalinCope.question, "Can commodity production exist under the socialist mode of production?");
  assert.deepEqual(targets("q_stalinCope"), ["q_nepTime", "q_demCent"]);
  assert.deepEqual(targets("q_nepTime"), ["q_classStruggleSocialism", "Bukharinism"]);
  assert.deepEqual(targets("q_chinaBourgeois"), ["Anti-Revisionist Marxism-Leninism", "q_peopleWar"]);
  assert.deepEqual(targets("q_peopleWar"), ["q_universalPPW", "q_natCom"]);
  assert.deepEqual(targets("q_universalPPW"), ["Marxism-Leninism-Maoism, principally Maoism", "q_laborAristocracy"]);
  assert.deepEqual(targets("q_laborAristocracy"), ["Maoism-Third-Worldism", "q_muhCapitalistRoaders"]);
  assert.deepEqual(targets("q_muhCapitalistRoaders"), ["Maoism", "Marxism-Leninism-MZT"]);
  assert.deepEqual(targets("q_natCom"), ["q_songun", "Marxism-Leninism"]);
  assert.deepEqual(targets("q_songun"), ["Juche", "National Communism"]);
  assert.deepEqual(targets("q_khrushBrezhnev"), ["Marxism-Leninism (Khrushchev)", "Developed Socialism"]);

  assert.equal(data.graph.q_demCent.choices[0].id, "q_proletarianculture");
  assert.equal(data.graph.q_demCent.choices[1].id, "q_reform");
  assert.equal(data.graph.q_immediateAbolition.question, "Should the primary objective of the revolutionary transformation be the immediate process of abolishing capitalist relations?");
  assert.deepEqual(targets("q_immediateAbolition"), ["Palingenetic Communism", "Italian Left-Communism (Programma)"]);
  assert.equal(data.graph.q_proletarianculture.choices[0].ideology, "Vperedism");
  assert.equal(data.graph.q_proletarianculture.choices[1].id, "q_leninism");
  assert.deepEqual(targets("q_leninism"), ["Left Marxism-Leninism", "q_natLib"]);
  assert.equal(data.graph.q_natLib.choices[0].id, "q_peasantPower");
  assert.equal(data.graph.q_natLib.choices[1].id, "q_organic");
  assert.deepEqual(targets("q_organic"), ["q_immediateAbolition", "Italian Left-Communism (Battaglia)"]);
  assert.equal(data.graph.q_peasantPower.choices[0].ideology, "Leninism");
  assert.equal(data.graph.q_peasantPower.choices[1].id, "q_dws");
  assert.deepEqual(targets("q_dws"), ["q_trotskyCurrent", "Heterodox Trotskyism (Cliffite)"]);
  assert.deepEqual(targets("q_trotskyCurrent"), ["Posadism", "Orthodox Trotskyism"]);

  assert.equal(data.graph.q_reform.choices[0].id, "q_massparty");
  assert.equal(data.graph.q_reform.choices[1].id, "q_immortalParty");
  assert.equal(data.graph.q_massparty.choices[0].id, "q_deLeon");
  assert.equal(data.graph.q_massparty.choices[1].ideology, "Classical Marxism");
  assert.equal(data.graph.q_deLeon.choices[0].ideology, "De Leonism");
  assert.equal(data.graph.q_deLeon.choices[1].ideology, "Orthodox Marxism");
  assert.equal(data.graph.q_immortalParty.question, "Should autonomous mass organizations together form an invariant communist party?");
  assert.deepEqual(targets("q_immortalParty"), ["Immortalist Communism", "q_workerscouncils"]);
  assert.equal(data.graph.q_workerscouncils.question, "Should workers’ councils be the primary organs of revolutionary power?");
  assert.deepEqual(targets("q_workerscouncils"), ["q_electoralism", "q_maospontex"]);
  assert.deepEqual(targets("q_electoralism"), ["Spartacism", "q_partyElites"]);
  assert.deepEqual(targets("q_partyElites"), ["q_situationism", "Council Communism (Organizational Unitarism)"]);
  assert.deepEqual(targets("q_situationism"), ["Situationism", "Council Communism (Organizational Dualism)"]);
  assert.deepEqual(targets("q_maospontex"), ["Mao-Spontex", "q_areasofstruggle"]);
  assert.deepEqual(targets("q_areasofstruggle"), ["Libertarian Marxism", "q_multitude"]);
  assert.equal(data.graph.q_multitude.choices[0].ideology, "Post-Autonomism");
  assert.equal(data.graph.q_multitude.choices[1].ideology, "Autonomism");

  assert.equal(data.graph.q_commAnarchySplit.question, "Is opposition to hierarchy foundational to revolutionary politics?");
  assert.equal(data.graph.q_commAnarchySplit.choices[0].id, "q_agriculture");
  assert.equal(data.graph.q_commAnarchySplit.choices[1].id, "q_communization");
  assert.equal(data.graph.q_communization.choices[0].ideology, "Camattism");
  assert.equal(data.graph.q_communization.choices[1].id, "q_nature");
  assert.equal(data.graph.q_nature.choices[0].ideology, "Communization (Endnotes)");
  assert.equal(data.graph.q_nature.choices[1].id, "q_tiqqunForms");
  assert.equal(data.graph.q_tiqqunForms.choices[0].ideology, "Communization (Tiqqun)");
  assert.equal(data.graph.q_tiqqunForms.choices[1].ideology, "Communization (Dauvé)");
  for (const name of ["Camattism", "Communization (Endnotes)", "Communization (Tiqqun)", "Communization (Dauvé)"]) {
    assert.match(data.ideologies[name].flag, /\/assets\/unified\/flags\/communization-/);
    assert.ok(data.ideologies[name].quote.length > 30);
  }

  assert.equal(data.ideologies["Classical Marxism"].source, "curated");
  assert.equal(data.ideologies["Orthodox Marxism"].source, "curated");
  assert.equal(data.ideologies.Leninism.source, "curated");
  assert.equal(data.ideologies["Immortalist Communism"].source, "curated");
  assert.equal(data.ideologies["Immortalist Communism"].quote, "By tracing out the limits of any given struggle, these participants find themselves elaborating a larger pattern, principle, or truth: the invariant idea of communism.");
  assert.equal(data.ideologies["Palingenetic Communism"].source, "curated");
  assert.equal(data.ideologies["Palingenetic Communism"].author, "Ari Antheus");
  assert.equal(data.ideologies["Palingenetic Communism"].quote, "Communism does not free value-production from capitalists; it frees material and creative activity from the value-form.");
  assert.ok(data.ideologies["Palingenetic Communism"].aliases.includes("Post-Hegelian Palingenetic Communism"));
});
