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

import {
  rankForBrief,
  renderBrief,
  briefSummary,
  defaultBriefDir,
  surfacedIds,
  diffSurfaced,
  parseSurfacedFrontmatter,
  selectPriorBriefFile,
  temporalSignal,
  BRIEF_SCHEMA_VERSION,
} from "../src/brief.js";
import { SCHEMA_VERSION } from "../src/types.js";
import type { TrendRecord, TrendStore } from "../src/types.js";

const TODAY = "2026-06-24";

type TestScore = {
  mode: "kortform" | "long-form";
  dimensions: Record<string, number>;
  composite: number;
  priority: "Immediate" | "High" | "Medium" | "Low" | "Skip";
};

function mkTrend(
  p: {
    title: string;
    url: string;
    topics: string[];
    capturedAt: string;
    publishedAt?: string;
    source?: string;
    summary?: string;
    score?: TestScore;
    status?: "new" | "acted" | "skipped";
    surfacedCount?: number;
    lastSurfacedAt?: string;
  },
): TrendRecord {
  return {
    id: p.title + "|" + p.url,
    title: p.title,
    url: p.url,
    source: p.source ?? "tavily",
    capturedAt: p.capturedAt,
    ...(p.publishedAt !== undefined ? { publishedAt: p.publishedAt } : {}),
    topics: p.topics,
    ...(p.summary !== undefined ? { summary: p.summary } : {}),
    ...(p.score !== undefined ? { score: p.score } : {}),
    ...(p.status !== undefined ? { status: p.status } : {}),
    ...(p.surfacedCount !== undefined ? { surfacedCount: p.surfacedCount } : {}),
    ...(p.lastSurfacedAt !== undefined ? { lastSurfacedAt: p.lastSurfacedAt } : {}),
  };
}

/** A composite-bearing score for the rank tests (mode/priority kept consistent for render asserts). */
function mkScore(composite: number, priority: TestScore["priority"], mode: TestScore["mode"] = "kortform"): TestScore {
  return { mode, dimensions: { pillar: 5, audience: 5, timing: 5, angle: 5, authority: 5 }, composite, priority };
}
function mkStore(trends: TrendRecord[]): TrendStore {
  return { schemaVersion: 2, trends };
}

describe("rankForBrief — grouping (SC1)", () => {
  const pillars = ["AI", "gov"];
  const store = mkStore([
    mkTrend({ title: "T1 top", url: "https://e/1", topics: ["ai", "gov", "x"], publishedAt: "2026-06-22", capturedAt: "2026-06-23" }),
    mkTrend({ title: "T2 single", url: "https://e/2", topics: ["AI"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
    mkTrend({ title: "T3 older1", url: "https://e/3", topics: ["gov"], publishedAt: "2026-06-01", capturedAt: "2026-06-01" }),
    mkTrend({ title: "T4 noise", url: "https://e/4", topics: ["unrelated"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" }),
    mkTrend({ title: "T5 older2", url: "https://e/5", topics: ["ai", "gov"], publishedAt: "2026-05-01", capturedAt: "2026-05-01" }),
  ]);
  const r = rankForBrief(store, pillars, TODAY);

  test("topMatches = overlap>=2 & fresh only", () => {
    assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["T1 top"]);
  });
  test("singleMatches = overlap===1 & fresh only", () => {
    assert.deepEqual(r.singleMatches.map((e) => e.trend.title), ["T2 single"]);
  });
  test("olderMatched = overlap>=1 & stale, sorted overlap desc", () => {
    assert.deepEqual(r.olderMatched.map((e) => e.trend.title), ["T5 older2", "T3 older1"]);
  });
  test("overlap===0 excluded everywhere", () => {
    const all = [...r.topMatches, ...r.singleMatches, ...r.olderMatched].map((e) => e.trend.title);
    assert.ok(!all.includes("T4 noise"));
  });
  test("totals reflect trends/matched/fresh", () => {
    assert.deepEqual(r.totals, { trends: 5, matched: 4, fresh: 2 });
  });
  test("matchedPillars preserve pillar case (case-insensitive match)", () => {
    assert.deepEqual(r.topMatches[0].matchedPillars, ["AI", "gov"]);
  });
});

describe("rankForBrief — within-group total order (SC1)", () => {
  test("effectiveDate desc orders before title (both neutral so the RE-R3d temporal key ties)", () => {
    // RE-R3d: both entries are neutral (surfaced 0, ageDays 4-6 > firstMoverDays 2), so the temporal
    // key ties and effectiveDate is the deciding key; titles disagree with date to isolate effectiveDate.
    const store = mkStore([
      mkTrend({ title: "Alpha", url: "https://e/a1", topics: ["a", "b"], publishedAt: "2026-06-18", capturedAt: "2026-06-18" }),
      mkTrend({ title: "Bravo", url: "https://e/b1", topics: ["a", "b"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
    ]);
    const r = rankForBrief(store, ["a", "b"], TODAY);
    assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Bravo", "Alpha"]);
  });
  test("same title+effectiveDate+overlap -> url asc tie-break (total order)", () => {
    const store = mkStore([
      mkTrend({ title: "Same", url: "https://e/zzz", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
      mkTrend({ title: "Same", url: "https://e/aaa", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
    ]);
    const r = rankForBrief(store, ["a", "b"], TODAY);
    assert.deepEqual(r.topMatches.map((e) => e.trend.url), ["https://e/aaa", "https://e/zzz"]);
  });
});

describe("rankForBrief — freshness (SC2)", () => {
  const pillars = ["a"];
  test("effectiveDate = publishedAt when present (fresh despite old capturedAt)", () => {
    const r = rankForBrief(mkStore([mkTrend({ title: "P", url: "https://e/p", topics: ["a"], publishedAt: "2026-06-22", capturedAt: "2026-01-01" })]), pillars, TODAY);
    assert.equal(r.singleMatches.length, 1);
    assert.equal(r.singleMatches[0].effectiveDate, "2026-06-22");
    assert.equal(r.olderMatched.length, 0);
  });
  test("fallback to capturedAt when publishedAt absent", () => {
    const r = rankForBrief(mkStore([mkTrend({ title: "C", url: "https://e/c", topics: ["a"], capturedAt: "2026-06-22" })]), pillars, TODAY);
    assert.equal(r.singleMatches.length, 1);
    assert.equal(r.singleMatches[0].effectiveDate, "2026-06-22");
  });
  test("stale when capturedAt old and no publishedAt", () => {
    const r = rankForBrief(mkStore([mkTrend({ title: "S", url: "https://e/s", topics: ["a"], capturedAt: "2026-01-01" })]), pillars, TODAY);
    assert.equal(r.olderMatched.length, 1);
    assert.equal(r.singleMatches.length, 0);
  });
  test("boundary: ageDays === freshDays is fresh (<=)", () => {
    const r = rankForBrief(mkStore([mkTrend({ title: "B", url: "https://e/bd", topics: ["a"], publishedAt: "2026-06-17", capturedAt: "2026-06-17" })]), pillars, TODAY, { freshDays: 7 });
    assert.equal(r.singleMatches.length, 1, "7d with freshDays 7 must be fresh");
    assert.equal(r.singleMatches[0].ageDays, 7);
  });
  test("freshDays configurable: 10d fresh at 14, stale at 7", () => {
    const t = mkTrend({ title: "X", url: "https://e/x", topics: ["a"], publishedAt: "2026-06-14", capturedAt: "2026-06-14" });
    assert.equal(rankForBrief(mkStore([t]), pillars, TODAY, { freshDays: 14 }).singleMatches.length, 1);
    assert.equal(rankForBrief(mkStore([t]), pillars, TODAY, { freshDays: 7 }).olderMatched.length, 1);
  });
});

describe("renderBrief + briefSummary (SC3)", () => {
  const pillars = ["AI", "gov"];
  const store = mkStore([
    mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", summary: "A short summary" }),
    mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
    mkTrend({ title: "Gamma", url: "https://e/g", topics: ["gov"], publishedAt: "2026-05-01", capturedAt: "2026-05-01" }),
  ]);
  const r = rankForBrief(store, pillars, TODAY);
  const md = renderBrief(r);

  test("starts with YAML frontmatter", () => {
    assert.ok(md.startsWith("---\n"), "brief must open with YAML frontmatter");
  });
  test("frontmatter carries date, store, schemaVersion", () => {
    assert.match(md, /\ndate: 2026-06-24\n/);
    assert.match(md, new RegExp("\\nschemaVersion: " + BRIEF_SCHEMA_VERSION + "\\n"));
    assert.match(md, /\nstore:/);
  });
  test("frontmatter summary === briefSummary(ranking); single line, no quote/newline", () => {
    const summary = briefSummary(r);
    assert.ok(!summary.includes('"'), "summary must not contain a double-quote");
    assert.ok(!summary.includes("\n"), "summary must be a single line");
    const m = md.match(/^summary: (.*)$/m);
    assert.ok(m, "frontmatter has a summary line");
    assert.equal(m![1], summary);
  });
  test("summary names the top entry when fresh matches exist", () => {
    assert.ok(briefSummary(r).includes("Alpha"));
  });
  test("body has the three section markers", () => {
    assert.ok(md.includes("Topp-treff"), "top section");
    assert.ok(md.includes("Enkelt-treff"), "single section");
    assert.ok(md.includes("Eldre i lager"), "older section");
  });
  test("deterministic: identical input -> identical bytes", () => {
    assert.equal(renderBrief(r), renderBrief(rankForBrief(store, pillars, TODAY)));
  });
  test("empty ranking renders a valid no-fresh brief", () => {
    const empty = rankForBrief(mkStore([]), pillars, TODAY);
    const emd = renderBrief(empty);
    assert.ok(emd.startsWith("---\n"));
    assert.ok(briefSummary(empty).startsWith("Ingen ferske"), "no-fresh summary line");
    const m = emd.match(/^summary: (.*)$/m);
    assert.equal(m![1], briefSummary(empty));
  });
});

describe("rankForBrief — composite primary within bucket (RE-R3a / SC5)", () => {
  const pillars = ["a", "b"];

  test("higher composite sorts first at the same overlap + freshness", () => {
    const store = mkStore([
      mkTrend({ title: "Low", url: "https://e/low", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(6.0, "High") }),
      mkTrend({ title: "High", url: "https://e/high", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
    ]);
    const r = rankForBrief(store, pillars, TODAY);
    assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["High", "Low"], "composite 9 before composite 6");
  });

  test("an unscored record sorts after every scored record in its bucket (sentinel -1)", () => {
    const store = mkStore([
      mkTrend({ title: "Unscored", url: "https://e/u", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
      mkTrend({ title: "Scored low", url: "https://e/s", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(2.0, "Low") }),
    ]);
    const r = rankForBrief(store, pillars, TODAY);
    assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Scored low", "Unscored"], "any scored beats unscored");
  });

  test("both-unscored same-title/diff-url pair falls back to url asc (total order intact)", () => {
    const store = mkStore([
      mkTrend({ title: "Same", url: "https://e/zzz", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
      mkTrend({ title: "Same", url: "https://e/aaa", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
    ]);
    const r = rankForBrief(store, pillars, TODAY);
    assert.deepEqual(r.topMatches.map((e) => e.trend.url), ["https://e/aaa", "https://e/zzz"]);
  });

  test("composite overrides effectiveDate within the bucket (composite is the leading key)", () => {
    const store = mkStore([
      mkTrend({ title: "Fresher lower", url: "https://e/fl", topics: ["a", "b"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", score: mkScore(5.0, "Medium") }),
      mkTrend({ title: "Older higher", url: "https://e/oh", topics: ["a", "b"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(9.0, "Immediate") }),
    ]);
    const r = rankForBrief(store, pillars, TODAY);
    assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Older higher", "Fresher lower"]);
  });

  test("deterministic: identical scored input -> identical ranking order", () => {
    const trends = [
      mkTrend({ title: "A", url: "https://e/a", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
      mkTrend({ title: "B", url: "https://e/b", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(6.0, "High") }),
    ];
    const r1 = rankForBrief(mkStore(trends), pillars, TODAY);
    const r2 = rankForBrief(mkStore(trends), pillars, TODAY);
    assert.deepEqual(r1.topMatches.map((e) => e.trend.title), r2.topMatches.map((e) => e.trend.title));
  });
});

describe("renderBrief — band + mode surfacing (RE-R3a / SC6)", () => {
  const pillars = ["AI", "gov"];

  test("scored top-entry meta line is the full pinned shape (· <priority> (<mode>) between age and Pillarer)", () => {
    // neutral age (4d > firstMoverDays 2) so the RE-R3d temporal overlay adds no badge — isolates the score token.
    const store = mkStore([
      mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(9.0, "Immediate") }),
    ]);
    const md = renderBrief(rankForBrief(store, pillars, TODAY));
    assert.ok(
      md.includes("- Kilde: tavily · Publisert: 2026-06-20 (4d) · Immediate (kortform) · Pillarer: AI, gov"),
      "scored top-entry meta line must carry · <priority> (<mode>) between (<age>d) and · Pillarer",
    );
  });

  test("unscored top-entry meta line is UNCHANGED (no token)", () => {
    const store = mkStore([
      mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
    ]);
    const md = renderBrief(rankForBrief(store, pillars, TODAY));
    assert.ok(
      md.includes("- Kilde: tavily · Publisert: 2026-06-20 (4d) · Pillarer: AI, gov"),
      "unscored neutral top-entry meta line carries no score and no temporal token",
    );
  });

  test("scored bullet (single match) is the full pinned shape (· <priority> (<mode>) before · 🔗)", () => {
    const store = mkStore([
      mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(6.0, "High") }),
    ]);
    const md = renderBrief(rankForBrief(store, pillars, TODAY));
    assert.ok(
      md.includes("- **Beta** — «AI» · 2026-06-20 (4d) · High (kortform) · 🔗 https://e/b"),
      "scored bullet must carry · <priority> (<mode>) before · 🔗",
    );
  });

  test("unscored bullet (single match) is UNCHANGED (no token)", () => {
    const store = mkStore([
      mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
    ]);
    const md = renderBrief(rankForBrief(store, pillars, TODAY));
    assert.ok(
      md.includes("- **Beta** — «AI» · 2026-06-20 (4d) · 🔗 https://e/b"),
      "unscored bullet must be unchanged",
    );
  });

  test("briefSummary names the band (no mode) on a scored top", () => {
    // neutral age (4d) so the RE-R3d first-mover marker stays off — isolates the band token.
    const store = mkStore([
      mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(9.0, "Immediate") }),
    ]);
    const s = briefSummary(rankForBrief(store, pillars, TODAY));
    assert.ok(s.includes("Topp: «Alpha» (AI · Immediate · 4d)."), `summary should carry the band: ${s}`);
    assert.ok(!s.includes("kortform"), "summary must not carry the mode");
  });

  test("briefSummary omits the band token on an unscored top", () => {
    const store = mkStore([
      mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
    ]);
    const s = briefSummary(rankForBrief(store, pillars, TODAY));
    assert.ok(s.includes("Topp: «Alpha» (AI · 4d)."), `unscored summary should omit the band: ${s}`);
  });

  test("briefSummary stays one line, no double-quote, even when the top title contains a guillemet", () => {
    const store = mkStore([
      mkTrend({ title: "«Quoted» take", url: "https://e/q", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
    ]);
    const s = briefSummary(rankForBrief(store, pillars, TODAY));
    assert.ok(!s.includes('"'), "summary must not contain a double-quote");
    assert.ok(!s.includes("\n"), "summary must be a single line");
    assert.ok(s.includes("Immediate"), "summary still carries the band");
  });

  test("single-pillar unscored top -> summary renders with no · <priority> token, one line", () => {
    const store = mkStore([
      mkTrend({ title: "Solo", url: "https://e/solo", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
    ]);
    const s = briefSummary(rankForBrief(store, ["AI"], TODAY));
    assert.ok(!s.includes("·  ·"), "no empty priority slot");
    assert.ok(s.includes("Topp: «Solo» (AI · 4d)."), `single-pillar unscored summary: ${s}`);
    assert.ok(!s.includes("\n"));
  });

  test("ranking: descriptor equals the exact pinned string", () => {
    const store = mkStore([
      mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
    ]);
    const md = renderBrief(rankForBrief(store, pillars, TODAY, { freshDays: 7 }));
    assert.ok(
      md.includes("ranking: composite desc, then pillar-overlap desc, then temporal (first-mover↑/saturated↓), then publishedAt desc (capturedAt fallback); freshDays 7"),
      "the ranking descriptor must carry the RE-R3d temporal key (RE-R3a base + RE-R3d)",
    );
  });

  test("deterministic: identical scored input -> identical bytes", () => {
    const store = mkStore([
      mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
      mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(6.0, "High") }),
    ]);
    const r = rankForBrief(store, pillars, TODAY);
    assert.equal(renderBrief(r), renderBrief(rankForBrief(store, pillars, TODAY)));
  });
});

describe("defaultBriefDir", () => {
  test("ends with trends/morning-brief and honors LINKEDIN_STUDIO_DATA (derived from defaultStorePath)", () => {
    const prev = process.env.LINKEDIN_STUDIO_DATA;
    process.env.LINKEDIN_STUDIO_DATA = "/tmp/lis-brief-root";
    try {
      assert.equal(defaultBriefDir(), join("/tmp/lis-brief-root", "trends", "morning-brief"));
    } finally {
      if (prev === undefined) delete process.env.LINKEDIN_STUDIO_DATA;
      else process.env.LINKEDIN_STUDIO_DATA = prev;
    }
  });
});

describe("RE-R3b — exclude acted/skipped (A3)", () => {
  const pillars = ["AI", "gov"];
  const store = mkStore([
    mkTrend({ title: "New top", url: "https://e/n", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" }),
    mkTrend({ title: "Acted top", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", status: "acted" }),
    mkTrend({ title: "Skipped single", url: "https://e/s", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", status: "skipped" }),
  ]);
  const r = rankForBrief(store, pillars, TODAY);

  test("RED: acted/skipped are dropped from every bucket", () => {
    assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["New top"]);
    assert.deepEqual(r.singleMatches.map((e) => e.trend.title), []);
    assert.deepEqual(r.olderMatched.map((e) => e.trend.title), []);
  });
  test("RED: totals.trends counts the full inventory (incl. handled); matched is post-filter", () => {
    assert.equal(r.totals.trends, 3, "full store count");
    assert.equal(r.totals.matched, 1, "only the new record is matched");
  });
  test("RED: a store whose only matches are handled → no fresh + the empty summary", () => {
    const s = mkStore([
      mkTrend({ title: "A", url: "https://e/aa", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", status: "acted" }),
      mkTrend({ title: "B", url: "https://e/bb", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", status: "skipped" }),
    ]);
    const rr = rankForBrief(s, pillars, TODAY);
    assert.equal(rr.totals.fresh, 0);
    assert.match(briefSummary(rr), /Ingen ferske tema-signaler/);
  });
});

describe("RE-R3b — render id + surfaced marker + descriptor (D4/D5)", () => {
  const pillars = ["AI", "gov"];

  test("RED: a top entry carries the id in backticks (copy-paste-ready for act/skip)", () => {
    const s = mkStore([mkTrend({ title: "Top", url: "https://e/t", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" })]);
    const md = renderBrief(rankForBrief(s, pillars, TODAY));
    assert.ok(md.includes("· `Top|https://e/t`"), "top entry meta line must end with the id in backticks");
  });
  test("RED: a single-match bullet carries the id in backticks", () => {
    const s = mkStore([mkTrend({ title: "Single", url: "https://e/sg", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" })]);
    const md = renderBrief(rankForBrief(s, pillars, TODAY));
    assert.ok(md.includes("· `Single|https://e/sg`"), "bullet must end with the id in backticks");
  });
  test("· sett Nx appears only when surfacedCount >= 2 (warming badge, RE-R3b contract preserved by RE-R3d)", () => {
    const s = mkStore([
      mkTrend({ title: "Seen", url: "https://e/seen", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 2 }),
      mkTrend({ title: "Once", url: "https://e/once", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 1 }),
    ]);
    const md = renderBrief(rankForBrief(s, pillars, TODAY));
    assert.ok(md.includes("· sett 2x"), "surfacedCount 2 → warming → · sett 2x");
    assert.ok(!md.includes("sett 1x"), "surfacedCount 1 → no marker (the preserved ≥2 gate)");
  });
  test("RED: the ranking: descriptor names the acted/skipped/selected exclusion (N6)", () => {
    const md = renderBrief(rankForBrief(mkStore([]), pillars, TODAY));
    assert.match(
      md,
      /\nranking: composite desc, then pillar-overlap desc, then temporal \(first-mover↑\/saturated↓\), then publishedAt desc \(capturedAt fallback\); freshDays 7; excludes acted\/skipped\/selected \(selected\+acted shown in I produksjon\)\n/,
    );
  });
});

describe("RE-R3b — surfacedIds (D7, Phase B)", () => {
  test("RED: surfacedIds = topMatches ∪ singleMatches ∪ olderMatched.slice(0,5)", () => {
    const olders = Array.from({ length: 7 }, (_, i) =>
      mkTrend({ title: "O" + i, url: "https://e/o" + i, topics: ["ai", "gov"], publishedAt: "2026-05-01", capturedAt: "2026-05-01" }),
    );
    const s = mkStore([
      mkTrend({ title: "Top", url: "https://e/t", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" }),
      mkTrend({ title: "Sg", url: "https://e/sg", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
      ...olders,
    ]);
    const r = rankForBrief(s, ["AI", "gov"], TODAY);
    const expected = [...r.topMatches, ...r.singleMatches, ...r.olderMatched.slice(0, 5)].map((e) => e.trend.id);
    assert.deepEqual(surfacedIds(r), expected);
    assert.equal(surfacedIds(r).length, 1 + 1 + 5, "older capped at 5");
  });
});

// ── RE-R3d: temporal overlay (first-mover + saturation) ──

describe("temporalSignal — first-mover detection (RE-R3d / SC1)", () => {
  const opts = { firstMoverDays: 2, saturationAt: 3 };
  test("recent + unsurfaced is first-mover (rank 3); undefined surfacedCount counts as 0", () => {
    for (const a of [0, 1, 2]) {
      const s = temporalSignal(a, 0, opts);
      assert.equal(s.tier, "first-mover", `ageDays ${a} surfaced 0`);
      assert.equal(s.firstMover, true);
      assert.equal(s.rank, 3);
    }
    assert.equal(temporalSignal(1, undefined, opts).tier, "first-mover");
  });
  test("past the window is neutral (rank 2)", () => {
    const s = temporalSignal(3, 0, opts);
    assert.equal(s.tier, "neutral");
    assert.equal(s.firstMover, false);
    assert.equal(s.rank, 2);
  });
  test("recent but already surfaced is NOT first-mover (warming)", () => {
    const s = temporalSignal(1, 1, opts);
    assert.equal(s.tier, "warming");
    assert.equal(s.firstMover, false);
  });
  test("future publishedAt (negative ageDays) is NOT first-mover (>=0 guard)", () => {
    const s = temporalSignal(-1, 0, opts);
    assert.equal(s.tier, "neutral");
    assert.equal(s.firstMover, false);
  });
});

describe("temporalSignal — saturation grading + clamp (RE-R3d / SC2, SC9)", () => {
  const opts = { firstMoverDays: 2, saturationAt: 3 };
  test("surfacings >= saturationAt is saturated (rank 0, inclusive)", () => {
    for (const c of [3, 4]) {
      const s = temporalSignal(5, c, opts);
      assert.equal(s.tier, "saturated", `surfaced ${c}`);
      assert.equal(s.rank, 0);
      assert.equal(s.surfacings, c);
    }
  });
  test("1..saturationAt-1 is warming (rank 1)", () => {
    for (const c of [1, 2]) {
      const s = temporalSignal(5, c, opts);
      assert.equal(s.tier, "warming", `surfaced ${c}`);
      assert.equal(s.rank, 1);
    }
  });
  test("surfaced 0 (not recent) is neutral (rank 2)", () => {
    assert.equal(temporalSignal(5, 0, opts).tier, "neutral");
  });
  test("defensive clamp: saturationAt 0 does NOT mark every non-first-mover saturated", () => {
    assert.equal(temporalSignal(5, 5, { firstMoverDays: 2, saturationAt: 0 }).tier, "saturated");
    assert.equal(temporalSignal(5, 0, { firstMoverDays: 2, saturationAt: 0 }).tier, "neutral");
  });
  test("pure: same inputs -> same output", () => {
    assert.deepEqual(temporalSignal(2, 1, opts), temporalSignal(2, 1, opts));
  });
});

describe("rankForBrief — temporal overlay re-orders within tier, composite dominates (RE-R3d / SC3)", () => {
  const pillars = ["a", "b"];
  // DISAGREEMENT fixture: temporal.rank and effectiveDate-desc disagree, so the new key is what decides.
  // (surfacedCount correlates with age, so a naive first-mover-vs-saturated fixture would already be ordered
  // correctly by the existing effectiveDate key — that test would pass WITHOUT the feature.)
  const store = mkStore([
    // A: neutral (surfaced 0, ageDays 5 > firstMoverDays), OLDER date, composite 7.0
    mkTrend({ title: "A", url: "https://e/a", topics: ["a", "b"], publishedAt: "2026-06-19", capturedAt: "2026-06-19", score: mkScore(7.0, "High") }),
    // B: warming (surfaced 2), NEWER date, composite 7.0
    mkTrend({ title: "B", url: "https://e/b", topics: ["a", "b"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 2, score: mkScore(7.0, "High") }),
    // Z: saturated (surfaced 4) but HIGHER composite 8.5
    mkTrend({ title: "Z", url: "https://e/z", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", surfacedCount: 4, score: mkScore(8.5, "Immediate") }),
  ]);
  test("[Z, A, B]: composite primary (Z); then temporal (A neutral > B warming) over newer-date B", () => {
    const r = rankForBrief(store, pillars, TODAY);
    assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Z", "A", "B"]);
  });
  test("total order: deterministic regardless of store insertion order", () => {
    const reordered = mkStore([store.trends[2], store.trends[0], store.trends[1]]);
    assert.deepEqual(rankForBrief(reordered, pillars, TODAY).topMatches.map((e) => e.trend.title), ["Z", "A", "B"]);
  });
});

describe("renderBrief — temporal badges + the >=2 boundary (RE-R3d / SC4)", () => {
  const pillars = ["AI", "gov"];
  const render = (p: Parameters<typeof mkTrend>[0]): string => renderBrief(rankForBrief(mkStore([mkTrend(p)]), pillars, TODAY));
  test("first-mover entry carries · 🥇 først ute", () => {
    const md = render({ title: "FM", url: "https://e/fm", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" });
    assert.ok(md.includes("· 🥇 først ute"), "ageDays 1 + unsurfaced -> first-mover badge");
  });
  test("saturated entry carries · 🔁 mettet (3x)", () => {
    const md = render({ title: "Sat", url: "https://e/sat", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", surfacedCount: 3 });
    assert.ok(md.includes("· 🔁 mettet (3x)"), "surfacedCount 3 -> saturated badge");
  });
  test("warming entry with surfacedCount 2 carries · sett 2x", () => {
    const md = render({ title: "Warm", url: "https://e/warm", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", surfacedCount: 2 });
    assert.ok(md.includes("· sett 2x"), "surfacedCount 2 -> warming badge (>=2)");
  });
  test("warming entry with surfacedCount 1 carries NO badge (preserves the R3b >=2 contract)", () => {
    const md = render({ title: "Once", url: "https://e/once", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", surfacedCount: 1 });
    assert.ok(!md.includes("sett 1x"), "surfacedCount 1 -> no marker");
    assert.ok(!md.includes("mettet"), "surfacedCount 1 is not saturated");
  });
  test("neutral entry carries none of the temporal badges", () => {
    const md = render({ title: "Neu", url: "https://e/neu", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" });
    assert.ok(!md.includes("først ute") && !md.includes("mettet") && !md.includes("· sett "), "ageDays 4 + unsurfaced -> neutral");
  });
});

describe("briefSummary — first-mover marker (RE-R3d / SC5)", () => {
  const pillars = ["AI", "gov"];
  test("first-mover top carries · 🥇 først ute and stays regex-safe", () => {
    const s = briefSummary(rankForBrief(mkStore([
      mkTrend({ title: "Fresh", url: "https://e/f", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", score: mkScore(9.0, "Immediate") }),
    ]), pillars, TODAY));
    assert.ok(s.includes("· 🥇 først ute"), `first-mover summary marker: ${s}`);
    assert.ok(!s.includes('"') && !s.includes("\n"), "summary stays single-line, no double-quote");
  });
  test("non-first-mover top omits the marker", () => {
    const s = briefSummary(rankForBrief(mkStore([
      mkTrend({ title: "Old", url: "https://e/o", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(9.0, "Immediate") }),
    ]), pillars, TODAY));
    assert.ok(!s.includes("først ute"), `neutral top, no marker: ${s}`);
  });
});

describe("rankForBrief — no schema/score mutation (RE-R3d / SC8)", () => {
  test("ranking does not mutate score.composite; schema versions unchanged", () => {
    const t = mkTrend({ title: "X", url: "https://e/x", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(8.0, "Immediate") });
    const before = t.score!.composite;
    rankForBrief(mkStore([t]), ["AI"], TODAY);
    assert.equal(t.score!.composite, before, "rankForBrief must not mutate the stored composite");
    assert.equal(BRIEF_SCHEMA_VERSION, 2); // RE-R3e: artifact frontmatter gained surfaced: (1->2); store SCHEMA_VERSION stays 4
    assert.equal(SCHEMA_VERSION, 4);
  });
});

describe("rankForBrief — prior-day surfacings exclude today (RE-R3d / same-day re-run idempotency)", () => {
  const pillars = ["AI", "gov"];
  test("a first-mover trend already surfaced TODAY stays first-mover (today excluded from the prior count)", () => {
    // surfacedCount 1 but lastSurfacedAt === today -> prior-day count 0 -> still first-mover, so a
    // same-day re-render (which loads the post-mark count) is byte-identical to the first run (RE-R3c SC7).
    const s = mkStore([
      mkTrend({ title: "FM", url: "https://e/fm", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 1, lastSurfacedAt: TODAY }),
    ]);
    const e = rankForBrief(s, pillars, TODAY).topMatches[0];
    assert.equal(e.temporal.tier, "first-mover", "today's own surfacing must not demote it out of first-mover");
    assert.equal(e.temporal.surfacings, 0, "prior-day surfacings exclude today");
  });
  test("the same count surfaced on a PRIOR day is warming (today not excluded)", () => {
    const s = mkStore([
      mkTrend({ title: "W", url: "https://e/w", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 1, lastSurfacedAt: "2026-06-23" }),
    ]);
    const e = rankForBrief(s, pillars, TODAY).topMatches[0];
    assert.equal(e.temporal.tier, "warming", "a prior-day surfacing counts");
    assert.equal(e.temporal.surfacings, 1);
  });
});

// --- RE-R3e: brief history + day-over-day diff ---

describe("diffSurfaced — partitions + order + empty prior (RE-R3e / SC1)", () => {
  test("symmetric set difference, order-stable", () => {
    const d = diffSurfaced(["a", "b", "c"], ["b", "c", "d"], "2026-06-25");
    assert.deepEqual(d, { priorDate: "2026-06-25", added: ["a"], carried: ["b", "c"], dropped: ["d"] });
  });
  test("empty prior ⇒ everything added, nothing dropped", () => {
    const d = diffSurfaced(["a", "b"], [], null);
    assert.deepEqual(d, { priorDate: null, added: ["a", "b"], carried: [], dropped: [] });
  });
  test("the three partitions are mutually disjoint (cross-partition exclusivity)", () => {
    const d = diffSurfaced(["a", "b", "c"], ["b", "c", "d"], "2026-06-25");
    const all = [...d.added, ...d.carried, ...d.dropped];
    assert.equal(new Set(all).size, all.length, "no id appears in two buckets");
  });
  test("pure: same inputs → same output", () => {
    assert.deepEqual(diffSurfaced(["x"], ["y"], "2026-06-20"), diffSurfaced(["x"], ["y"], "2026-06-20"));
  });
});

describe("parseSurfacedFrontmatter — read + degrade (RE-R3e / SC2)", () => {
  const block = (surfaced: string) =>
    `---\ndate: 2026-06-26\nsummary: hi\nsurfaced: ${surfaced}\nstore: { trends: 3 }\nschemaVersion: 2\n---\nbody`;
  test("parses a csv of ids", () => {
    assert.deepEqual(parseSurfacedFrontmatter(block("1a2b,3c4d,5e6f")), ["1a2b", "3c4d", "5e6f"]);
  });
  test("trims whitespace around ids", () => {
    assert.deepEqual(parseSurfacedFrontmatter(block(" a , b ,c ")), ["a", "b", "c"]);
  });
  test("a blank surfaced: line → []", () => {
    assert.deepEqual(parseSurfacedFrontmatter(block("")), []);
  });
  test("an absent surfaced: line (a pre-R3e brief) → []", () => {
    assert.deepEqual(parseSurfacedFrontmatter(`---\ndate: 2026-06-26\nsummary: hi\nschemaVersion: 1\n---\nbody`), []);
  });
  test("line-anchored: a summary: with commas does not leak in", () => {
    assert.deepEqual(parseSurfacedFrontmatter(`---\ndate: 2026-06-26\nsummary: a,b,c\nschemaVersion: 1\n---`), []);
  });
  test("never throws on malformed input", () => {
    assert.doesNotThrow(() => parseSurfacedFrontmatter("not a brief at all"));
  });
});

describe("selectPriorBriefFile — strict-prior selection (RE-R3e / SC3)", () => {
  const files = ["2026-06-24.md", "2026-06-25.md", "2026-06-26.md", "README.md", "2026-06-30.md"];
  test("greatest dated file strictly < today (excludes today + future, ignores non-dated)", () => {
    assert.equal(selectPriorBriefFile(files, "2026-06-26"), "2026-06-25.md");
  });
  test("no file < today → null", () => {
    assert.equal(selectPriorBriefFile(["2026-06-26.md", "2026-06-30.md"], "2026-06-26"), null);
  });
  test("empty list → null", () => {
    assert.equal(selectPriorBriefFile([], "2026-06-26"), null);
  });
});

describe("renderBrief/briefSummary — surfaced: + diff section + marker (RE-R3e / SC4–SC8)", () => {
  const pillars = ["AI", "gov"];
  const store = mkStore([
    mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
    mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(7.0, "High") }),
  ]);
  const r = rankForBrief(store, pillars, TODAY);
  const ids = surfacedIds(r);
  const emptyDiff = { priorDate: null, added: [], carried: [], dropped: [] };

  test("SC4: one surfaced: line = surfacedIds(r).join(','), before schemaVersion: 2", () => {
    const md = renderBrief(r, emptyDiff);
    const m = md.match(/^surfaced: (.*)$/m);
    assert.ok(m, "a surfaced: frontmatter line exists");
    assert.equal(m![1], ids.join(","));
    assert.ok(md.indexOf("\nsurfaced:") < md.indexOf("\nschemaVersion:"), "surfaced: precedes schemaVersion:");
    assert.match(md, /\nschemaVersion: 2\n/);
  });
  test("SC4: round-trips via parseSurfacedFrontmatter", () => {
    assert.deepEqual(parseSurfacedFrontmatter(renderBrief(r, emptyDiff)), ids);
  });
  test("SC4: an empty store → a blank surfaced: line", () => {
    const empty = rankForBrief(mkStore([]), pillars, TODAY);
    assert.match(renderBrief(empty, emptyDiff), /\nsurfaced: \n/);
  });

  test("SC5: section header present + precedes Topp-treff (delta leads)", () => {
    const md = renderBrief(r, emptyDiff);
    assert.ok(md.includes("## 🆕 Nytt siden sist"), "section header present");
    assert.ok(md.indexOf("## 🆕 Nytt siden sist") < md.indexOf("## 🎯 Topp-treff"), "delta precedes the ranked list");
  });
  test("SC5: first brief with added → 'Første brief — alt nedenfor er nytt'", () => {
    assert.ok(renderBrief(r, { priorDate: null, added: ids, carried: [], dropped: [] }).includes("Første brief — alt nedenfor er nytt"));
  });
  test("SC5: empty first brief → 'Første brief.'", () => {
    assert.ok(renderBrief(r, emptyDiff).includes("_Første brief._"));
  });
  test("SC5: prior + added → the added title + the carried/dropped tally", () => {
    const md = renderBrief(r, { priorDate: "2026-06-23", added: [ids[0]], carried: [ids[1]], dropped: ["gone"] });
    assert.ok(md.includes("Alpha"), "the added entry's title is rendered");
    assert.ok(md.includes("1 båret over, 1 ikke vist i dag"), "the carried/dropped tally");
  });
  test("SC5: prior + no added → 'Ingenting nytt siden <date>' + tally", () => {
    const md = renderBrief(r, { priorDate: "2026-06-23", added: [], carried: ids, dropped: [] });
    assert.ok(md.includes("Ingenting nytt siden 2026-06-23"));
    assert.ok(md.includes("båret over"));
  });

  test("SC6: marker ' N nye siden sist.' when prior + added", () => {
    const s = briefSummary(r, { priorDate: "2026-06-25", added: ["x", "y"], carried: [], dropped: [] });
    assert.ok(s.endsWith(" 2 nye siden sist."), `marker appended: ${s}`);
  });
  test("SC6: no marker on the first brief or when nothing new", () => {
    assert.ok(!briefSummary(r, { priorDate: null, added: ["x"], carried: [], dropped: [] }).includes("nye siden sist"));
    assert.ok(!briefSummary(r, { priorDate: "2026-06-25", added: [], carried: [], dropped: [] }).includes("nye siden sist"));
  });
  test("SC6: briefSummary(r) === briefSummary(r, emptyDiff) (keeps the :166 summary-equality test green)", () => {
    assert.equal(briefSummary(r), briefSummary(r, emptyDiff));
  });
  test("SC6: the marker carries no quote/newline", () => {
    const s = briefSummary(r, { priorDate: "2026-06-25", added: ["x"], carried: [], dropped: [] });
    assert.ok(!s.includes('"') && !s.includes("\n"));
  });

  test("SC7: BRIEF_SCHEMA_VERSION === 2; SCHEMA_VERSION === 4", () => {
    assert.equal(BRIEF_SCHEMA_VERSION, 2);
    assert.equal(SCHEMA_VERSION, 4);
  });

  test("SC8: same (r, diff) → byte-identical render", () => {
    const d = { priorDate: "2026-06-23", added: [ids[0]], carried: [ids[1]], dropped: [] };
    assert.equal(renderBrief(r, d), renderBrief(r, d));
  });
});
