/**
 * N7.5 — MR-F9 «innenfra og ut» demand-sweep: the mechanism that FILLS the N6 reader fields
 * (readerQuestion/painPoint/saturation). Discovery finds "what happened"; this layer translates
 * it into "the problem the reader is stuck on". Three passes after discovery, before drafting:
 * demand-sweep (Pass 1) → pain-point map (Pass 2) → vocabulary translation (Pass 3), with a new
 * output unit — the ARC (åre), not the news-item.
 *
 * This suite pins the deterministic code half:
 *   (1) the additive-optional `demand` signal (strength + answered) — the rankable twin of the
 *       verbatim `saturation` text; capture round-trip + hard-fail on malformed, backward-compat;
 *   (2) `classifyMarketGap` — the §4 "ærlig markedsdom" asymmetry (supply-gap ≠ demand-gap);
 *   (3) `rankArcQuestions` — the §4 ranking `etterspørsel × kan-svare × ikke-besvart`, total order;
 *   (4) `groupIntoArcs` — relatedIds transitive closure into veins, BÆRENDE vs STØTTE partition;
 *   (5) `renderArcs` — the §4 output-contract, reader's words in the title, honest-null, deterministic.
 *
 * GENERIC BY ARCHITECTURE: the fixtures use a neutral domain — no KTG pillar/source/calibration is
 * baked into the mechanism (what counts as demand is the sweep's judgment, carried by the fields).
 */
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";

import { emptyStore, loadStore, saveStore, addTrend } from "../src/store.js";
import { normalizeItem, itemToInput } from "../src/item.js";
import { SCHEMA_VERSION } from "../src/types.js";
import type { TrendRecord, DemandSignal } from "../src/types.js";
import {
  classifyMarketGap,
  toArcQuestion,
  rankArcQuestions,
  groupIntoArcs,
  renderArcs,
} from "../src/arc.js";

const tmp = () => mkdtempSync(join(tmpdir(), "trends-n75-"));

/** Build a store record with N7.5-relevant fields; generic domain, no KTG specifics. */
function rec(over: Partial<TrendRecord> & Pick<TrendRecord, "id" | "title" | "url">): TrendRecord {
  return {
    source: "tavily",
    capturedAt: "2026-07-20",
    topics: ["ai"],
    ...over,
  } as TrendRecord;
}

// ── (1) demand schema — additive-optional, capture round-trip ──────────────────────────────
describe("N7.5 — demand signal schema (rankable twin of saturation text)", () => {
  test("addTrend persists demand + the F9 reader fields, load/save round-trips them", () => {
    const dir = tmp();
    try {
      const path = join(dir, "trends.json");
      const { store } = addTrend(emptyStore(), {
        title: "RAG on your own documents",
        url: "https://example.com/rag",
        source: "tavily",
        capturedAt: "2026-07-20",
        topics: ["ai", "search"],
        readerQuestion: "Can we point a chatbot at our own files?",
        painPoint: "competes against the M365 licence they already pay for",
        saturation: "setup guides saturated; the cost/ownership questions are open",
        demand: { strength: "strong", answered: false },
        verdict: "BÆRENDE",
        actionability: { formulated: true },
      });
      saveStore(path, store);
      const t = loadStore(path).trends[0];
      assert.deepEqual(t.demand, { strength: "strong", answered: false });
      assert.equal(t.readerQuestion, "Can we point a chatbot at our own files?");
      assert.equal(t.painPoint, "competes against the M365 licence they already pay for");
      assert.equal(t.saturation, "setup guides saturated; the cost/ownership questions are open");
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });

  test("schema version stays 4 — demand is additive-optional, no migration", () => {
    assert.equal(SCHEMA_VERSION, 4);
  });

  test("normalizeItem accepts a valid demand and carries it through itemToInput", () => {
    const res = normalizeItem({
      title: "T",
      url: "https://x.test/a",
      source: "tavily",
      topics: ["ai"],
      demand: { strength: "moderate", answered: true },
    });
    assert.equal(res.ok, true);
    if (!res.ok) return;
    assert.deepEqual(res.item.demand, { strength: "moderate", answered: true });
    const input = itemToInput(res.item, "2026-07-20");
    assert.deepEqual(input.demand, { strength: "moderate", answered: true });
  });

  test("normalizeItem hard-fails a malformed demand (bad strength vocab)", () => {
    const res = normalizeItem({
      title: "T",
      url: "https://x.test/a",
      source: "tavily",
      topics: ["ai"],
      demand: { strength: "huge", answered: false },
    });
    assert.equal(res.ok, false);
    if (res.ok) return;
    assert.ok(res.errors.some((e) => /demand/i.test(e)), "should report a demand error");
  });

  test("normalizeItem hard-fails a demand with a non-boolean answered", () => {
    const res = normalizeItem({
      title: "T",
      url: "https://x.test/a",
      source: "tavily",
      topics: ["ai"],
      demand: { strength: "thin", answered: "no" },
    });
    assert.equal(res.ok, false);
  });

  test("demand absent → key omitted (a pre-N7.5 record reads unchanged)", () => {
    const res = normalizeItem({ title: "T", url: "https://x.test/a", source: "tavily", topics: ["ai"] });
    assert.equal(res.ok, true);
    if (!res.ok) return;
    assert.equal("demand" in res.item, false);
    assert.equal("demand" in itemToInput(res.item, "2026-07-20"), false);
  });
});

// ── (2) classifyMarketGap — the §4 "ærlig markedsdom" asymmetry ─────────────────────────────
describe("N7.5 — classifyMarketGap (supply-gap ≠ demand-gap ≠ saturated)", () => {
  const strong: DemandSignal = { strength: "strong", answered: false };

  test("many ask, none answer → supply-gap (the gold)", () => {
    assert.equal(classifyMarketGap({ demand: strong }), "supply-gap");
    assert.equal(classifyMarketGap({ demand: { strength: "moderate", answered: false } }), "supply-gap");
  });

  test("few ask but the sender can answer → demand-gap (highest value, lowest guaranteed audience)", () => {
    assert.equal(
      classifyMarketGap({ demand: { strength: "thin", answered: false }, verdict: "BÆRENDE" }),
      "demand-gap",
    );
    assert.equal(
      classifyMarketGap({ demand: { strength: "thin", answered: false }, actionability: { formulated: true } }),
      "demand-gap",
    );
  });

  test("already answered → saturated (don't write), regardless of demand strength", () => {
    assert.equal(classifyMarketGap({ demand: { strength: "strong", answered: true } }), "saturated");
  });

  test("no demand signal → unknown (honest null, never invented)", () => {
    assert.equal(classifyMarketGap({}), "unknown");
    assert.equal(classifyMarketGap({ demand: { strength: "thin", answered: false } }), "unknown");
  });
});

// ── (3) rankArcQuestions — §4 ranking, total order ──────────────────────────────────────────
describe("N7.5 — rankArcQuestions (etterspørsel × kan-svare × ikke-besvart)", () => {
  test("supply-gap outranks demand-gap outranks saturated; unmeasured sinks last", () => {
    const supply = rec({
      id: "aaa1",
      title: "supply",
      url: "u1",
      demand: { strength: "strong", answered: false },
      verdict: "BÆRENDE",
      actionability: { formulated: true },
    });
    const demand = rec({
      id: "bbb2",
      title: "demand",
      url: "u2",
      demand: { strength: "thin", answered: false },
      verdict: "BÆRENDE",
      actionability: { formulated: true },
    });
    const saturated = rec({
      id: "ccc3",
      title: "saturated",
      url: "u3",
      demand: { strength: "strong", answered: true },
    });
    const unmeasured = rec({ id: "ddd4", title: "unmeasured", url: "u4" });

    const ranked = rankArcQuestions([saturated, unmeasured, demand, supply]);
    assert.deepEqual(
      ranked.map((q) => q.id),
      ["aaa1", "bbb2", "ccc3", "ddd4"],
    );
    assert.equal(ranked[0].gap, "supply-gap");
    assert.equal(ranked[1].gap, "demand-gap");
    assert.equal(ranked[2].gap, "saturated");
    assert.equal(ranked[3].gap, "unknown");
  });

  test("total order is deterministic — ties broken by id ascending", () => {
    const a = rec({ id: "zzz9", title: "z", url: "uz", demand: { strength: "moderate", answered: false } });
    const b = rec({ id: "aaa1", title: "a", url: "ua", demand: { strength: "moderate", answered: false } });
    const ranked = rankArcQuestions([a, b]);
    assert.deepEqual(ranked.map((q) => q.id), ["aaa1", "zzz9"]);
    // Same input, second call → identical order (pure).
    assert.deepEqual(rankArcQuestions([a, b]), rankArcQuestions([b, a]));
  });

  test("saturated ranks strictly below any unanswered measured demand", () => {
    const sat = rec({ id: "s", title: "s", url: "us", demand: { strength: "strong", answered: true } });
    const open = rec({ id: "o", title: "o", url: "uo", demand: { strength: "thin", answered: false } });
    const ranked = rankArcQuestions([sat, open]);
    assert.equal(ranked[0].id, "o");
    assert.equal(ranked[1].id, "s");
  });
});

// ── (4) toArcQuestion + groupIntoArcs — reader's words, relatedIds veins ─────────────────────
describe("N7.5 — arc grouping (the vein, not the news-item)", () => {
  test("toArcQuestion uses the reader's words (pass 3), keeping the field's words alongside", () => {
    const q = toArcQuestion(
      rec({
        id: "x1",
        title: "OKF-bundle second-brain interop",
        url: "u",
        readerQuestion: "Will our chat logs become public record?",
        painPoint: "arkivplikt",
      }),
    );
    assert.equal(q.question, "Will our chat logs become public record?");
    assert.equal(q.fieldQuestion, "OKF-bundle second-brain interop");
    assert.equal(q.painPoint, "arkivplikt");
  });

  test("toArcQuestion falls back to the title when no reader question is set (backward-compat)", () => {
    const q = toArcQuestion(rec({ id: "x2", title: "Only a headline", url: "u" }));
    assert.equal(q.question, "Only a headline");
  });

  test("relatedIds cluster transitively into one arc; unrelated records stay separate", () => {
    const a = rec({ id: "a1", title: "A", url: "ua", relatedIds: ["b2"] });
    const b = rec({ id: "b2", title: "B", url: "ub", relatedIds: ["c3"] }); // a→b→c transitive
    const c = rec({ id: "c3", title: "C", url: "uc" });
    const lone = rec({ id: "z9", title: "Z", url: "uz" });

    const arcs = groupIntoArcs([a, b, c, lone]);
    assert.equal(arcs.length, 2);
    const vein = arcs.find((ar) => ar.questions.some((q) => q.id === "a1"))!;
    assert.deepEqual(vein.questions.map((q) => q.id).sort(), ["a1", "b2", "c3"]);
    const solo = arcs.find((ar) => ar.questions.some((q) => q.id === "z9"))!;
    assert.deepEqual(solo.questions.map((q) => q.id), ["z9"]);
  });

  test("an arc partitions its questions into BÆRENDE (bearing) vs the rest (support)", () => {
    const bearing = rec({ id: "a1", title: "bear", url: "ua", relatedIds: ["b2"], verdict: "BÆRENDE" });
    const support = rec({ id: "b2", title: "supp", url: "ub", verdict: "STØTTE" });
    const arc = groupIntoArcs([bearing, support])[0];
    assert.deepEqual(arc.bearing.map((q) => q.id), ["a1"]);
    assert.deepEqual(arc.support.map((q) => q.id), ["b2"]);
  });

  test("arcs are ordered by their top question's rank (highest-demand vein first)", () => {
    const hot = rec({ id: "h1", title: "hot", url: "uh", demand: { strength: "strong", answered: false }, verdict: "BÆRENDE" });
    const cold = rec({ id: "c1", title: "cold", url: "uc", demand: { strength: "thin", answered: true } });
    const arcs = groupIntoArcs([cold, hot]);
    assert.equal(arcs[0].questions[0].id, "h1");
  });
});

// ── (5) renderArcs — §4 output-contract, deterministic, honest-null ──────────────────────────
describe("N7.5 — renderArcs (§4 output-contract)", () => {
  const supplyVein = () =>
    groupIntoArcs([
      rec({
        id: "a1",
        title: "LLM wiki OKF-bundle",
        url: "ua",
        relatedIds: ["b2"],
        readerQuestion: "Is our knowledge base going to leak?",
        painPoint: "informasjonssikkerhet",
        saturation: "no independent answer yet",
        demand: { strength: "strong", answered: false },
        verdict: "BÆRENDE",
        actionability: { formulated: true },
      }),
      rec({
        id: "b2",
        title: "second brain agent memory",
        url: "ub",
        readerQuestion: "Who owns the notes when we are five people?",
        demand: { strength: "moderate", answered: false },
        verdict: "STØTTE",
      }),
    ]);

  test("renders the reader's words, not the field's jargon, in the question inventory", () => {
    const md = renderArcs(supplyVein());
    assert.ok(md.includes("Is our knowledge base going to leak?"), "reader question present");
    assert.ok(!/^#.*OKF-bundle/m.test(md), "field jargon must not head a section");
  });

  test("renders the honest market verdict per vein (supply-gap = write here)", () => {
    const md = renderArcs(supplyVein());
    assert.ok(/markedsdom/i.test(md), "explicit market verdict");
    assert.ok(/hull i tilbudet|supply-gap/i.test(md), "supply-gap named");
  });

  test("renders the BÆRENDE vs STØTTE arc proposal (reading order)", () => {
    const md = renderArcs(supplyVein());
    assert.ok(/BÆRENDE/.test(md) && /STØTTE/.test(md), "both roles surfaced");
  });

  test("an unmeasured / thin vein renders an explicit thin-signal marker (no invented demand)", () => {
    const thin = groupIntoArcs([rec({ id: "t1", title: "quiet topic", url: "ut" })]);
    const md = renderArcs(thin);
    assert.ok(/tynt signal|unknown|umålt/i.test(md), "honest null surfaced, not filled in");
  });

  test("render is deterministic — same arcs → byte-identical output", () => {
    assert.equal(renderArcs(supplyVein()), renderArcs(supplyVein()));
  });
});

// ── (6) CLI roundtrip — the AC: a swept item flows verbatim capture → arcs §4 map ───────────
describe("N7.5 — CLI roundtrip (capture demand-swept item → arcs)", () => {
  const trendsDir = fileURLToPath(new URL("..", import.meta.url));
  const runCli = (args: string[], input = ""): { status: number | null; stdout: string } => {
    const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", ...args], {
      input,
      encoding: "utf8",
      cwd: trendsDir,
    });
    return { status: res.status, stdout: res.stdout };
  };

  test("readerQuestion/painPoint/saturation/demand survive capture and drive the arc map", () => {
    const dir = tmp();
    try {
      const store = join(dir, "trends.json");
      const item = JSON.stringify({
        source: "tavily",
        title: "OKF second-brain interop",
        url: "https://example.com/okf",
        topics: ["ai", "knowledge"],
        readerQuestion: "Will our knowledge base become public record?",
        painPoint: "arkivplikt + innsyn",
        saturation: "setup guides saturated; the ownership question is open",
        demand: { strength: "strong", answered: false },
        verdict: "BÆRENDE",
        actionability: { formulated: true },
      });
      assert.equal(runCli(["capture", "--store", store, "--json"], item).status, 0);

      const md = runCli(["arcs", "--store", store]);
      assert.equal(md.status, 0);
      assert.ok(
        /## Åre 1: Will our knowledge base become public record\?/.test(md.stdout),
        "the vein heading uses the reader's words, not the field's jargon",
      );
      assert.ok(/hull i tilbudet|supply-gap/i.test(md.stdout), "the honest market verdict is rendered");
      assert.ok(/feltets ord: OKF second-brain interop/.test(md.stdout), "field words kept alongside");

      const j = runCli(["arcs", "--store", store, "--json"]);
      const parsed = JSON.parse(j.stdout);
      assert.equal(parsed[0].questions[0].demand.strength, "strong");
      assert.equal(parsed[0].gap, "supply-gap");
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });
});
