/**
 * N6 — the proposal layer: the discovery agent's angle/rationale becomes a PERSISTED
 * entity (step 2, A1-4) and the operator's approval gets a home (step 3, A1-7). Eight
 * additive-optional fields carry the proposal (angle/targetLevel/rationale/relatedIds),
 * the reader-side F7 signal the N7 band-cap gate reads (actionability/verdict), and the
 * F9 reader fields the N7.5 demand-sweep fills (readerQuestion/painPoint/saturation).
 * Plus the `selected` lifecycle state, the `--ids` batch, and the brief's "I produksjon"
 * section. Every field is optional → an existing store reads UNCHANGED (backward-compat).
 */
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";

import {
  emptyStore,
  loadStore,
  saveStore,
  addTrend,
  effectiveStatus,
  setStatus,
  setStatusMany,
} from "../src/store.js";
import { normalizeItem, itemToInput } from "../src/item.js";
import { rankForBrief, renderBrief } from "../src/brief.js";
import { SCHEMA_VERSION } from "../src/types.js";
import type { TrendRecord, TrendStore } from "../src/types.js";

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

// The full proposal payload, reused across the schema tests.
const FULL_INPUT = {
  title: "AI agents reshape public-sector workflows",
  url: "https://example.com/agents",
  source: "tavily",
  capturedAt: "2026-07-20",
  topics: ["ai", "public sector"],
  angle: "What a caseworker actually does differently on Monday",
  targetLevel: "praktiker", // a value from the user's OWN profile span, never a hardcoded enum
  rationale: "The procurement window opens in Q3 — timely for buyers deciding now",
  relatedIds: ["abc123def456", "0011223344ff"],
  actionability: { formulated: true, note: "reader can pilot one workflow this week" },
  verdict: "BÆRENDE" as const,
  readerQuestion: "How do I start without a platform team?",
  painPoint: "competes against the M365 licence they already pay for",
  saturation: "thin — two vendor blogs, no independent walkthrough",
};

describe("N6 — schema round-trip (the proposal becomes a persisted entity)", () => {
  test("addTrend persists all eight proposal fields, and load/save round-trips them byte-for-byte", () => {
    const dir = tmp();
    try {
      const path = join(dir, "trends.json");
      const { store } = addTrend(emptyStore(), FULL_INPUT);
      saveStore(path, store);
      const reloaded = loadStore(path);
      const t = reloaded.trends[0];
      assert.equal(t.angle, FULL_INPUT.angle);
      assert.equal(t.targetLevel, FULL_INPUT.targetLevel);
      assert.equal(t.rationale, FULL_INPUT.rationale);
      assert.deepEqual(t.relatedIds, FULL_INPUT.relatedIds);
      assert.deepEqual(t.actionability, FULL_INPUT.actionability);
      assert.equal(t.verdict, "BÆRENDE");
      assert.equal(t.readerQuestion, FULL_INPUT.readerQuestion);
      assert.equal(t.painPoint, FULL_INPUT.painPoint);
      assert.equal(t.saturation, FULL_INPUT.saturation);
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });

  test("the new fields are additive-optional — schema stays v4, no bump (no record needs migrating)", () => {
    assert.equal(SCHEMA_VERSION, 4);
  });

  test("an existing v4 store WITHOUT the new fields loads and re-saves unchanged (backward-compat)", () => {
    const dir = tmp();
    try {
      const path = join(dir, "trends.json");
      // A record shaped exactly like a pre-N6 v4 record (no proposal fields at all).
      const legacy: TrendStore = {
        schemaVersion: 4,
        trends: [
          {
            id: "legacy00",
            title: "Old trend",
            url: "https://example.com/old",
            source: "websearch",
            capturedAt: "2026-06-01",
            topics: ["ai"],
          },
        ],
      };
      writeFileSync(path, JSON.stringify(legacy, null, 2) + "\n", "utf8");
      const loaded = loadStore(path);
      assert.equal(loaded.schemaVersion, 4);
      const t = loaded.trends[0];
      assert.equal(t.angle, undefined);
      assert.equal(t.verdict, undefined);
      assert.equal(Object.prototype.hasOwnProperty.call(t, "angle"), false, "no phantom key added on load");
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });

  test("proposal fields are first-sight — a re-capture with a different angle does NOT overwrite the stored one", () => {
    const first = addTrend(emptyStore(), FULL_INPUT).store;
    const second = addTrend(first, { ...FULL_INPUT, angle: "A completely different angle", topics: ["ai", "govtech"] });
    assert.equal(second.added, false);
    // topics still union (existing discipline); angle is provenance — first sight wins.
    assert.equal(first.trends[0].angle, FULL_INPUT.angle);
    assert.deepEqual(first.trends[0].topics, ["ai", "public sector", "govtech"]);
  });
});

describe("N6 — lifecycle: selected + setStatusMany batch", () => {
  test("effectiveStatus round-trips the new `selected` state", () => {
    const rec: TrendRecord = { id: "x", title: "t", url: "u", source: "s", capturedAt: "2026-07-20", topics: [], status: "selected" };
    assert.equal(effectiveStatus(rec), "selected");
  });

  test("setStatus accepts `selected` (widened TrendStatus)", () => {
    const { store } = addTrend(emptyStore(), FULL_INPUT);
    const id = store.trends[0].id;
    const res = setStatus(store, id, "selected");
    assert.equal(res.found, true);
    assert.equal(store.trends[0].status, "selected");
  });

  test("setStatusMany marks every matched id and reports the unknown ones (partial batch)", () => {
    let store = emptyStore();
    store = addTrend(store, { ...FULL_INPUT, title: "A", url: "https://example.com/a" }).store;
    store = addTrend(store, { ...FULL_INPUT, title: "B", url: "https://example.com/b" }).store;
    const ids = store.trends.map((t) => t.id);
    const res = setStatusMany(store, [ids[0], ids[1], "ghost99"], "selected");
    assert.deepEqual(res.found.sort(), [ids[0], ids[1]].sort());
    assert.deepEqual(res.notFound, ["ghost99"]);
    assert.equal(store.trends[0].status, "selected");
    assert.equal(store.trends[1].status, "selected");
  });

  test("setStatusMany on an all-unknown batch mutates nothing and reports every id not-found", () => {
    const { store } = addTrend(emptyStore(), FULL_INPUT);
    const res = setStatusMany(store, ["nope1", "nope2"], "acted");
    assert.deepEqual(res.found, []);
    assert.deepEqual(res.notFound, ["nope1", "nope2"]);
    assert.equal(effectiveStatus(store.trends[0]), "new");
  });
});

describe("N6 — item validation (typed fields hard-fail, free-text lenient)", () => {
  test("a well-formed item with every proposal field normalizes, and itemToInput carries them all", () => {
    const res = normalizeItem({
      source: "tavily",
      title: "T",
      url: "https://example.com/t",
      topics: ["ai"],
      angle: "the angle",
      targetLevel: "beslutter",
      rationale: "why now",
      relatedIds: ["id1", "id2"],
      actionability: { formulated: true, note: "do X" },
      verdict: "STØTTE",
      readerQuestion: "how?",
      painPoint: "cost",
      saturation: "saturated",
    });
    assert.equal(res.ok, true);
    if (!res.ok) return;
    const input = itemToInput(res.item, "2026-07-20");
    assert.equal(input.angle, "the angle");
    assert.equal(input.targetLevel, "beslutter");
    assert.equal(input.verdict, "STØTTE");
    assert.deepEqual(input.actionability, { formulated: true, note: "do X" });
    assert.deepEqual(input.relatedIds, ["id1", "id2"]);
    assert.equal(input.readerQuestion, "how?");
    assert.equal(input.painPoint, "cost");
    assert.equal(input.saturation, "saturated");
  });

  test("an out-of-vocabulary verdict is a hard error (closed mechanism vocabulary)", () => {
    const res = normalizeItem({ source: "s", title: "t", url: "https://example.com/t", topics: ["ai"], verdict: "MEGABÆRENDE" });
    assert.equal(res.ok, false);
    if (res.ok) return;
    assert.ok(res.errors.some((e) => e.toLowerCase().includes("verdict")), "error names the offending field");
  });

  test("a malformed actionability (formulated not a boolean) is a hard error", () => {
    const res = normalizeItem({ source: "s", title: "t", url: "https://example.com/t", topics: ["ai"], actionability: { formulated: "yes" } });
    assert.equal(res.ok, false);
    if (res.ok) return;
    assert.ok(res.errors.some((e) => e.toLowerCase().includes("actionability")));
  });

  test("blank free-text fields are dropped, not errors (summary-idiom); relatedIds junk is normalized away", () => {
    const res = normalizeItem({
      source: "s",
      title: "t",
      url: "https://example.com/t",
      topics: ["ai"],
      angle: "   ",
      rationale: "",
      relatedIds: ["ok1", "", 42, "ok1"], // blank + non-string dropped, dupe collapsed
    });
    assert.equal(res.ok, true);
    if (!res.ok) return;
    assert.equal(Object.prototype.hasOwnProperty.call(res.item, "angle"), false);
    assert.equal(Object.prototype.hasOwnProperty.call(res.item, "rationale"), false);
    assert.deepEqual(res.item.relatedIds, ["ok1"]);
  });

  test("an item with NONE of the new fields still normalizes (backward-compat)", () => {
    const res = normalizeItem({ source: "s", title: "t", url: "https://example.com/t", topics: ["ai"] });
    assert.equal(res.ok, true);
    if (!res.ok) return;
    assert.equal(res.item.verdict, undefined);
    assert.equal(res.item.actionability, undefined);
  });
});

// ── Brief rendering ──────────────────────────────────────────────────────────
const TODAY = "2026-07-20";
function mk(p: Partial<TrendRecord> & { title: string; url: string; topics: string[] }): TrendRecord {
  return {
    id: p.title + "|" + p.url,
    source: "tavily",
    capturedAt: "2026-07-20",
    ...p,
  } as TrendRecord;
}

describe("N6 — brief renders the proposal fields + the 'I produksjon' section", () => {
  test("a fresh, top-matched candidate renders its angle/målnivå/verdict/reader fields in the brief", () => {
    const store: TrendStore = {
      schemaVersion: 4,
      trends: [
        mk({
          title: "Agents in casework",
          url: "https://example.com/a",
          topics: ["ai", "public sector"],
          publishedAt: "2026-07-20",
          angle: "Monday-morning angle",
          targetLevel: "praktiker",
          rationale: "timely for Q3 buyers",
          actionability: { formulated: true, note: "pilot one workflow" },
          verdict: "BÆRENDE",
          readerQuestion: "How do I start without a platform team?",
          painPoint: "competes with M365",
          saturation: "thin market",
        }),
      ],
    };
    const md = renderBrief(rankForBrief(store, ["ai", "public sector"], TODAY));
    assert.ok(md.includes("Monday-morning angle"), "angle rendered");
    assert.ok(md.includes("praktiker"), "målnivå rendered");
    assert.ok(md.includes("BÆRENDE"), "verdict rendered");
    assert.ok(md.includes("How do I start without a platform team?"), "readerQuestion rendered");
    assert.ok(md.includes("competes with M365"), "painPoint rendered");
    assert.ok(md.includes("thin market"), "saturation rendered");
  });

  test("the 'I produksjon' section lists selected (valgt) and acted (skrevet) trends", () => {
    const store: TrendStore = {
      schemaVersion: 4,
      trends: [
        mk({ title: "Chosen one", url: "https://example.com/s", topics: ["ai"], status: "selected", angle: "selected angle", verdict: "BÆRENDE" }),
        mk({ title: "Written one", url: "https://example.com/w", topics: ["ai"], status: "acted" }),
        mk({ title: "Fresh queue item", url: "https://example.com/n", topics: ["ai"], publishedAt: "2026-07-20" }),
      ],
    };
    const md = renderBrief(rankForBrief(store, ["ai"], TODAY));
    assert.ok(md.includes("I produksjon"), "section header present");
    assert.ok(md.includes("Chosen one") && md.includes("valgt"), "selected trend shown as valgt");
    assert.ok(md.includes("Written one") && md.includes("skrevet"), "acted trend shown as skrevet");
    // A selected/acted trend must NOT leak back into the 'new' queue sections.
    assert.ok(!md.includes("### 1. Chosen one"), "selected trend is not in the ranked queue");
  });

  test("an empty in-production board renders the explicit empty marker", () => {
    const store: TrendStore = {
      schemaVersion: 4,
      trends: [mk({ title: "Only new", url: "https://example.com/n", topics: ["ai"], publishedAt: "2026-07-20" })],
    };
    const md = renderBrief(rankForBrief(store, ["ai"], TODAY));
    assert.ok(md.includes("I produksjon"), "section header always present (stable structure)");
    assert.ok(md.includes("Ingen i produksjon"), "explicit empty marker");
  });

  test("the render is deterministic (same store → byte-identical brief)", () => {
    const store: TrendStore = {
      schemaVersion: 4,
      trends: [
        mk({ title: "A", url: "https://example.com/a", topics: ["ai"], status: "selected", angle: "x" }),
        mk({ title: "B", url: "https://example.com/b", topics: ["ai"], publishedAt: "2026-07-20", verdict: "NYHET" }),
      ],
    };
    const a = renderBrief(rankForBrief(store, ["ai"], TODAY));
    const b = renderBrief(rankForBrief(store, ["ai"], TODAY));
    assert.equal(a, b);
  });
});

// ── CLI: select verb + --ids batch ───────────────────────────────────────────
const trendsRoot = fileURLToPath(new URL("..", import.meta.url));
function run(args: string[], input = ""): { status: number | null; stdout: string } {
  const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", ...args], {
    cwd: trendsRoot,
    input,
    encoding: "utf8",
  });
  return { status: res.status, stdout: res.stdout + res.stderr };
}
function seed(store: string): string[] {
  // capture three trends; return their ids (read via list --json).
  const batch = JSON.stringify([
    { source: "tavily", title: "One", url: "https://example.com/1", topics: ["ai"] },
    { source: "tavily", title: "Two", url: "https://example.com/2", topics: ["ai"] },
    { source: "tavily", title: "Three", url: "https://example.com/3", topics: ["ai"] },
  ]);
  run(["capture", "--store", store], batch);
  const rows = JSON.parse(run(["list", "--store", store, "--json"]).stdout) as Array<{ id: string }>;
  return rows.map((r) => r.id);
}

describe("N6 — CLI select verb + --ids batch", () => {
  test("select --id sets `selected`; read back via list --json", () => {
    const dir = tmp();
    try {
      const store = join(dir, "trends.json");
      const [id] = seed(store);
      assert.equal(run(["select", "--id", id, "--store", store]).status, 0);
      const rows = JSON.parse(run(["list", "--store", store, "--json"]).stdout) as Array<{ id: string; status?: string }>;
      assert.equal(rows.find((r) => r.id === id)!.status, "selected");
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });

  test("--ids marks a whole batch in one call (ten candidates, one call)", () => {
    const dir = tmp();
    try {
      const store = join(dir, "trends.json");
      const ids = seed(store);
      const res = run(["act", "--ids", ids.join(","), "--store", store]);
      assert.equal(res.status, 0);
      const rows = JSON.parse(run(["list", "--store", store, "--json"]).stdout) as Array<{ status?: string }>;
      assert.ok(rows.every((r) => r.status === "acted"), "every seeded trend is acted");
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });

  test("a partial batch (some ids unknown) saves the matches, exits 0, and reports the misses", () => {
    const dir = tmp();
    try {
      const store = join(dir, "trends.json");
      const ids = seed(store);
      const res = run(["skip", "--ids", `${ids[0]},ghost`, "--store", store]);
      assert.equal(res.status, 0, "partial success is exit 0");
      assert.ok(res.stdout.toLowerCase().includes("ghost") || res.stdout.toLowerCase().includes("not found"), "misses reported");
      const rows = JSON.parse(run(["list", "--store", store, "--json"]).stdout) as Array<{ id: string; status?: string }>;
      assert.equal(rows.find((r) => r.id === ids[0])!.status, "skipped");
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });

  test("an all-unknown batch changes nothing and exits 2", () => {
    const dir = tmp();
    try {
      const store = join(dir, "trends.json");
      seed(store);
      assert.equal(run(["select", "--ids", "nope1,nope2", "--store", store]).status, 2);
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });
});
