import { describe, test } from "node:test";
import assert from "node:assert/strict";

import { proposeDiff, applyDiff, type Candidate, type ProfileDiff } from "../src/consolidate.js";
import { mintEntityId } from "../src/id.js";
import { parseProfile, serializeProfile } from "../src/profile.js";
import type { ProfileDoc, ProfileFact } from "../src/types.js";

const TODAY = "2026-06-23";
const observedId = (key: string) => mintEntityId({ kind: "observed", key });

const fact = (over: Partial<ProfileFact>): ProfileFact => ({
  id: "000000000000",
  value: "v",
  first_seen: "2026-01-01",
  last_seen: TODAY,
  evidence_count: 1,
  provenance: "published",
  status: "active",
  ...over,
});
const doc = (staticF: ProfileFact[], dynamicF: ProfileFact[]): ProfileDoc => ({
  schemaVersion: 1,
  static: staticF,
  dynamic: dynamicF,
});
const cand = (over: Partial<Candidate>): Candidate => ({
  key: "k",
  value: "v",
  provenance: "published",
  source: "manual",
  observed_date: TODAY,
  ...over,
});

describe("proposeDiff classification (SC1a–g)", () => {
  test("SC1a — add: a published candidate with no match becomes a new dynamic fact", () => {
    const diff = proposeDiff({ current: doc([], []), candidates: [cand({ key: "topic", value: "AI safety" })], today: TODAY });
    assert.equal(diff.additions.length, 1);
    assert.equal(diff.additions[0].value, "AI safety");
    assert.equal(diff.additions[0].id, observedId("topic"));
    assert.equal(diff.additions[0].evidence_count, 1);
    assert.equal(diff.additions[0].status, "active");
  });

  test("SC1b — reject: an ai-draft candidate produces an empty diff", () => {
    const diff = proposeDiff({ current: doc([], []), candidates: [cand({ provenance: "ai-draft" })], today: TODAY });
    assert.deepEqual(diff.additions, []);
    assert.deepEqual(diff.evidenceBumps, []);
    assert.deepEqual(diff.promotions, []);
    assert.deepEqual(diff.conflicts, []);
  });

  test("SC1c — evidence-bump: same key+value bumps the existing fact, no addition", () => {
    const id = observedId("topic");
    const cur = doc([], [fact({ id, value: "AI safety", evidence_count: 1 })]);
    const diff = proposeDiff({ current: cur, candidates: [cand({ key: "topic", value: "AI safety" })], today: TODAY });
    assert.equal(diff.additions.length, 0);
    assert.equal(diff.evidenceBumps.length, 1);
    assert.deepEqual(diff.evidenceBumps[0], { id, newCount: 2, last_seen: TODAY });
  });

  test("SC1d — promote: a dynamic fact reaching N=3 is promoted", () => {
    const id = observedId("topic");
    const cur = doc([], [fact({ id, value: "AI safety", evidence_count: 2 })]);
    const diff = proposeDiff({ current: cur, candidates: [cand({ key: "topic", value: "AI safety" })], today: TODAY });
    assert.equal(diff.evidenceBumps[0].newCount, 3);
    assert.deepEqual(diff.promotions, [{ id }]);
  });

  test("SC1d — no promote below N", () => {
    const id = observedId("topic");
    const cur = doc([], [fact({ id, value: "x", evidence_count: 1 })]);
    const diff = proposeDiff({ current: cur, candidates: [cand({ key: "topic", value: "x" })], today: TODAY });
    assert.deepEqual(diff.promotions, []);
  });

  test("SC1e — conflict: different value keeps BOTH with DISTINCT ids, old fact untouched", () => {
    const primaryId = observedId("role");
    const cur = doc([], [fact({ id: primaryId, value: "advisor", evidence_count: 2 })]);
    const diff = proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect" })], today: TODAY });
    assert.equal(diff.conflicts.length, 1);
    assert.equal(diff.conflicts[0].primaryId, primaryId);
    assert.equal(diff.conflicts[0].primaryValue, "advisor");
    assert.notEqual(diff.conflicts[0].altId, primaryId, "alt id must differ from primary id (no duplicate-id corruption)");
    // the alt fact is added; the old fact is NOT bumped
    assert.equal(diff.additions.length, 1);
    assert.equal(diff.additions[0].value, "architect");
    assert.equal(diff.additions[0].id, diff.conflicts[0].altId);
    assert.deepEqual(diff.evidenceBumps, []);
  });

  test("SC1f — decay: a dynamic fact older than 90d is flagged; a static fact is decay-exempt", () => {
    const old = "2026-03-01"; // ~114 days before TODAY
    const dyn = fact({ id: observedId("stale-topic"), value: "old", last_seen: old });
    const stat = fact({ id: observedId("settled"), value: "stable", last_seen: old });
    const cur = doc([stat], [dyn]);
    const diff = proposeDiff({ current: cur, candidates: [], today: TODAY });
    assert.equal(diff.staleFlags.length, 1, "only the dynamic stale fact is flagged");
    assert.equal(diff.staleFlags[0].id, dyn.id);
    assert.ok(diff.staleFlags[0].daysStale > 90);
  });

  test("SC1f — a fresh dynamic fact is not flagged", () => {
    const cur = doc([], [fact({ id: observedId("fresh"), last_seen: "2026-06-20" })]);
    const diff = proposeDiff({ current: cur, candidates: [], today: TODAY });
    assert.deepEqual(diff.staleFlags, []);
  });

  test("SC1g — folded immutable: a candidate overlapping a profile-field seed adds a new observed fact, seed untouched", () => {
    const foldedId = mintEntityId({ kind: "profile-field", key: "role" });
    const folded = fact({ id: foldedId, value: "advisor", provenance: "human" });
    const cur = doc([folded], []);
    const before = serializeProfile(cur);
    const diff = proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect" })], today: TODAY });
    // candidate keys to observed:role (≠ profile-field:role) → no match → ADD, no conflict on the folded seed
    assert.equal(diff.additions.length, 1);
    assert.equal(diff.additions[0].id, observedId("role"));
    assert.equal(diff.conflicts.length, 0);
    assert.equal(serializeProfile(cur), before, "input doc not mutated; folded seed untouched");
  });
});

describe("proposeDiff purity + applyDiff round-trip (SC2, SC3, SC4)", () => {
  test("SC2 — proposeDiff does not mutate its inputs", () => {
    const cur = doc([], [fact({ id: observedId("topic"), value: "x" })]);
    const candidates = [cand({ key: "topic", value: "x" }), cand({ key: "new", value: "y" })];
    const snapCur = JSON.stringify(cur);
    const snapCand = JSON.stringify(candidates);
    proposeDiff({ current: cur, candidates, today: TODAY });
    assert.equal(JSON.stringify(cur), snapCur, "current unchanged");
    assert.equal(JSON.stringify(candidates), snapCand, "candidates unchanged");
  });

  test("SC3 — applyDiff(current, proposeDiff(...)) round-trips through parse/serialize", () => {
    const cur = doc([fact({ id: mintEntityId({ kind: "profile-field", key: "name" }), value: "KTG", provenance: "human" })], [fact({ id: observedId("topic"), value: "AI safety", evidence_count: 2 })]);
    const candidates = [
      cand({ key: "topic", value: "AI safety" }), // bump→promote
      cand({ key: "new-topic", value: "governance" }), // add
      cand({ key: "topic", value: "alignment" }), // conflict on topic → alt
    ];
    const diff = proposeDiff({ current: cur, candidates, today: TODAY });
    const next = applyDiff(cur, diff);
    const round = parseProfile(serializeProfile(next));
    assert.deepEqual(round, next, "applied doc round-trips exactly");
    // every fact id is unique (no duplicate-id corruption)
    const ids = [...round.static, ...round.dynamic].map((f) => f.id);
    assert.equal(new Set(ids).size, ids.length, "all fact ids unique");
  });

  test("SC4 — idempotent: re-running propose→apply with the same candidates adds no duplicate facts", () => {
    const cur = doc([], []);
    const candidates = [cand({ key: "topic", value: "AI safety" })];
    const once = applyDiff(cur, proposeDiff({ current: cur, candidates, today: TODAY }));
    const twice = applyDiff(once, proposeDiff({ current: once, candidates, today: TODAY }));
    const factsOnce = [...once.static, ...once.dynamic].length;
    const factsTwice = [...twice.static, ...twice.dynamic].length;
    assert.equal(factsTwice, factsOnce, "no duplicate fact on re-run (bump only)");
  });
});

describe("supersede arm (SB-S3b)", () => {
  test("S3b-SC1 — routing: a supersede candidate retires the active prior, no keep-both", () => {
    const id = observedId("role");
    const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2 })]);
    const diff = proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect", supersedes: "role" })], today: TODAY });
    assert.equal(diff.supersedes.length, 1, "one supersede op");
    assert.equal(diff.conflicts.length, 0, "supersede routes away from keep-both");
    assert.equal(diff.supersedes[0].oldId, id);
    assert.equal(diff.supersedes[0].oldValue, "advisor");
    assert.equal(diff.supersedes[0].winner.value, "architect");
    assert.equal(diff.supersedes[0].winner.id, observedId("role"), "winner takes the canonical key-id");
    assert.equal(diff.additions.length, 0, "winner is carried in the op, not additions");
  });

  test("S3b-SC2 — apply: retired fact superseded+re-minted, winner at key-id, both retained", () => {
    const id = observedId("role");
    const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2, first_seen: "2026-01-01" })]);
    const next = applyDiff(cur, proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect", supersedes: "role" })], today: TODAY }));
    const all = [...next.static, ...next.dynamic];
    const winner = all.find((f) => f.value === "architect")!;
    const retired = all.find((f) => f.value === "advisor")!;
    assert.ok(winner && retired, "both winner + retired present");
    assert.equal(winner.id, id, "winner takes the canonical key-id");
    assert.equal(winner.status, "active");
    assert.equal(winner.evidence_count, 1, "winner re-earns evidence");
    assert.equal(retired.status, "superseded");
    assert.notEqual(retired.id, id, "retired fact re-minted off the key-id (archival)");
    assert.equal(retired.first_seen, "2026-01-01", "retired fact keeps its OWN first_seen (audit)");
    const ids = all.map((f) => f.id);
    assert.equal(new Set(ids).size, ids.length, "no duplicate id");
  });

  test("S3b-SC3a — propose-idempotent: a re-sent supersede whose target already holds the new value bumps, no new supersession", () => {
    const id = observedId("role");
    const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2 })]);
    const once = applyDiff(cur, proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect", supersedes: "role" })], today: TODAY }));
    // re-extract the same candidate (value now matches the winner) WITH the stale signal still set
    const diff2 = proposeDiff({ current: once, candidates: [cand({ key: "role", value: "architect", supersedes: "role" })], today: TODAY });
    assert.equal(diff2.supersedes.length, 0, "no self-supersede when the target already holds the value");
    assert.equal(diff2.evidenceBumps.length, 1, "it bumps the winner instead");
  });

  test("S3b-SC3b — apply-idempotent: re-applying the same supersede diff is a no-op (value-matched skip)", () => {
    const id = observedId("role");
    const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2 })]);
    const diff = proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect", supersedes: "role" })], today: TODAY });
    const once = applyDiff(cur, diff);
    const twice = applyDiff(once, diff);
    assert.deepEqual(twice, once, "re-applying the same supersede diff changes nothing");
  });

  test("S3b-SC4 — graceful no-target: a supersede signal with no active prior degrades to a plain add", () => {
    const cur = doc([], []);
    const diff = proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect", supersedes: "role" })], today: TODAY });
    assert.equal(diff.supersedes.length, 0);
    assert.equal(diff.additions.length, 1, "degrades to a plain add");
    assert.equal(diff.additions[0].value, "architect");
    assert.equal(diff.additions[0].id, observedId("role"));
  });

  test("S3b-SC5 — round-trip: a superseded fact produced by applyDiff parses/serializes identically", () => {
    const id = observedId("role");
    const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2 })]);
    const next = applyDiff(cur, proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect", supersedes: "role" })], today: TODAY }));
    const round = parseProfile(serializeProfile(next));
    assert.deepEqual(round, next, "superseded fact round-trips through the grammar");
    const ids = [...round.static, ...round.dynamic].map((f) => f.id);
    assert.equal(new Set(ids).size, ids.length, "all ids unique");
  });

  test("S3b-SC7 — no regression: a candidate WITHOUT the signal still keeps-both (no supersede)", () => {
    const id = observedId("role");
    const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2 })]);
    const diff = proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect" })], today: TODAY });
    assert.equal(diff.supersedes.length, 0);
    assert.equal(diff.conflicts.length, 1, "keep-both unchanged when no signal");
  });

  test("S3b-SC8 — intra-batch: two supersede candidates for the same key resolve deterministically (first wins)", () => {
    const id = observedId("role");
    const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2 })]);
    const diff = proposeDiff({
      current: cur,
      candidates: [
        cand({ key: "role", value: "architect", supersedes: "role" }),
        cand({ key: "role", value: "principal", supersedes: "role" }),
      ],
      today: TODAY,
    });
    assert.equal(diff.supersedes.length, 1, "only the first supersede for the key is taken");
    assert.equal(diff.supersedes[0].winner.value, "architect");
    const next = applyDiff(cur, diff);
    const ids = [...next.static, ...next.dynamic].map((f) => f.id);
    assert.equal(new Set(ids).size, ids.length, "no duplicate id after intra-batch supersede");
  });

  test("S3b-SC11 — decay excludes superseded: a retained superseded dynamic fact is not stale-flagged", () => {
    const old = "2026-03-01"; // ~114 days before TODAY
    const superseded = fact({ id: observedId("old-role"), value: "advisor", last_seen: old, status: "superseded" });
    const active = fact({ id: observedId("topic"), value: "x", last_seen: old, status: "active" });
    const cur = doc([], [superseded, active]);
    const diff = proposeDiff({ current: cur, candidates: [], today: TODAY });
    assert.equal(diff.staleFlags.length, 1, "only the active stale fact is flagged");
    assert.equal(diff.staleFlags[0].id, active.id);
  });

  test("S3b-SC12 — supersede wins over a same-id bump in one diff", () => {
    const id = observedId("role");
    const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2 })]);
    const winner = fact({ id, value: "architect", evidence_count: 1, status: "active" });
    const diff: ProfileDiff = {
      additions: [],
      promotions: [],
      conflicts: [],
      staleFlags: [],
      evidenceBumps: [{ id, newCount: 3, last_seen: TODAY }], // a stale bump targeting the same id
      supersedes: [{ oldId: id, oldValue: "advisor", winner }],
    };
    const next = applyDiff(cur, diff);
    const all = [...next.static, ...next.dynamic];
    const w = all.find((f) => f.value === "architect")!;
    const r = all.find((f) => f.value === "advisor")!;
    assert.equal(r.status, "superseded", "the old fact is superseded, not bumped");
    assert.equal(w.evidence_count, 1, "winner unaffected by the stale bump");
  });
});
