// Pure / injectable-logic tests for Daily Digest episode synthesis. The
// relay round-trip and FS cache write-back aren't exercised here (a fake
// provider stands in, save:false skips disk); this nails prompt shaping,
// the operator-string scrub backstop, and the get-or-generate cache gate.

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

import {
  buildOverviewPrompt,
  scrubOperatorStrings,
  synthesizeEpisodeOverview,
  getOrCreateEpisodeOverview,
  selectDigestEpisodes,
  scopeForUser,
  nextDigestWatermark,
} from "../daily-digest.js";
import { renderDigestEmail } from "../email-template.js";

const record = (over = {}) => ({
  id: "1700000000000-abc",
  title: "How Markets Work",
  type: "podcast",
  chunks: [
    { title: "Supply & demand", summary: "Prices clear where the two curves meet." },
    { title: "Information", summary: "Asymmetry distorts outcomes." },
  ],
  ...over,
});

// A provider stub recording calls, returning a fixed analyze result.
const fakeProvider = (text, sink = {}) => ({
  async analyzeText(args) {
    sink.calls = (sink.calls || 0) + 1;
    sink.lastArgs = args;
    return { text };
  },
});

describe("buildOverviewPrompt", () => {
  test("includes title, type, and each topic's title + summary", () => {
    const p = buildOverviewPrompt(record());
    assert.match(p, /"How Markets Work"/);
    assert.match(p, /podcast episode/);
    assert.match(p, /- Supply & demand: Prices clear/);
    assert.match(p, /- Information: Asymmetry distorts/);
    assert.match(p, /1–2 paragraph/);
  });

  test("a topic with no summary still contributes its title", () => {
    const p = buildOverviewPrompt(
      record({ chunks: [{ title: "Loose ends" }] }),
    );
    assert.match(p, /- Loose ends/);
    assert.doesNotMatch(p, /Loose ends:/); // no trailing colon when summary absent
  });

  test("null-safe: missing fields fall back", () => {
    const p = buildOverviewPrompt({});
    assert.match(p, /"Untitled"/);
    assert.match(p, /recording/); // unknown type
  });
});

describe("scrubOperatorStrings", () => {
  test("removes operator/infra tokens", () => {
    const out = scrubOperatorStrings(
      "Routed via Spark Control and the vLLM box on Parakeet.",
    );
    assert.doesNotMatch(out, /spark control/i);
    assert.doesNotMatch(out, /vllm/i);
    assert.doesNotMatch(out, /parakeet/i);
  });

  test("strips LAN hosts and private IPs, keeps public/content data", () => {
    assert.doesNotMatch(
      scrubOperatorStrings("see http://immense-voyage.local/admin"),
      /\.local/,
    );
    assert.doesNotMatch(scrubOperatorStrings("host 192.168.1.42 here"), /192\.168/);
    // A public dotted quad in content is the user's data, not a leak.
    assert.match(scrubOperatorStrings("DNS is 8.8.8.8"), /8\.8\.8\.8/);
  });

  test("leaves ordinary prose intact", () => {
    const clean = "The episode covers supply, demand, and information costs.";
    assert.equal(scrubOperatorStrings(clean), clean);
  });

  test("null-safe", () => {
    assert.equal(scrubOperatorStrings(null), "");
    assert.equal(scrubOperatorStrings(""), "");
  });
});

describe("synthesizeEpisodeOverview", () => {
  test("scrubs the model result and passes a stable per-episode jobId", async () => {
    const sink = {};
    const out = await synthesizeEpisodeOverview(record(), {
      provider: fakeProvider("A clear overview from Spark Control.", sink),
    });
    assert.doesNotMatch(out, /spark control/i);
    assert.equal(sink.calls, 1);
    assert.equal(sink.lastArgs.jobId, "digest-1700000000000-abc");
  });

  test("throws when there are no topics", async () => {
    await assert.rejects(
      () => synthesizeEpisodeOverview(record({ chunks: [] }), { provider: fakeProvider("x") }),
      /no topic summaries/,
    );
  });

  test("throws when the model returns nothing usable", async () => {
    await assert.rejects(
      () => synthesizeEpisodeOverview(record(), { provider: fakeProvider("   ") }),
      /empty synthesis result/,
    );
  });
});

describe("getOrCreateEpisodeOverview", () => {
  test("cache hit returns stored overview without calling the provider", async () => {
    const sink = {};
    const res = await getOrCreateEpisodeOverview({
      record: record({ digestOverview: "Already done." }),
      provider: fakeProvider("fresh", sink),
      save: false,
    });
    assert.equal(res.cached, true);
    assert.equal(res.overview, "Already done.");
    assert.equal(sink.calls || 0, 0);
  });

  test("cache miss synthesizes (save:false skips disk)", async () => {
    const sink = {};
    const res = await getOrCreateEpisodeOverview({
      record: record(),
      provider: fakeProvider("A fresh overview.", sink),
      save: false,
    });
    assert.equal(res.cached, false);
    assert.equal(res.overview, "A fresh overview.");
    assert.equal(sink.calls, 1);
  });
});

describe("selectDigestEpisodes", () => {
  const at = (iso, id) => ({ id, title: id, type: "youtube", url: "", createdAt: iso });
  const sessions = [
    at("2026-06-14T00:00:00.000Z", "old"),
    at("2026-06-15T09:00:00.000Z", "a"),
    at("2026-06-15T10:00:00.000Z", "b"),
  ];
  const watermark = new Date("2026-06-15T08:00:00.000Z").getTime();

  test("keeps only recaps created after the watermark, oldest first", () => {
    const { episodes, total, overflow } = selectDigestEpisodes(sessions, watermark);
    assert.deepEqual(episodes.map((e) => e.id), ["a", "b"]);
    assert.equal(total, 2);
    assert.equal(overflow, 0);
  });

  test("caps the list and reports the overflow", () => {
    const many = Array.from({ length: 13 }, (_, i) =>
      at(`2026-06-15T1${i % 10}:00:00.000Z`, `e${i}`),
    );
    const { episodes, overflow, total } = selectDigestEpisodes(many, 0, 10);
    assert.equal(episodes.length, 10);
    assert.equal(overflow, 3);
    assert.equal(total, 13);
  });

  test("empty / malformed-date inputs", () => {
    assert.deepEqual(selectDigestEpisodes([], watermark).episodes, []);
    assert.deepEqual(selectDigestEpisodes(null, watermark).episodes, []);
    assert.deepEqual(
      selectDigestEpisodes([at("not-a-date", "x")], 0).episodes,
      [],
    );
  });

  test("null watermark treats everything as fresh", () => {
    assert.equal(selectDigestEpisodes(sessions, null).total, 3);
  });
});

describe("nextDigestWatermark", () => {
  const t = (iso) => new Date(iso).getTime();
  const e1 = "2026-06-15T09:00:00.000Z";
  const e2 = "2026-06-15T10:00:00.000Z";
  const e3 = "2026-06-15T11:00:00.000Z";

  test("all sent → newest sent createdAt", () => {
    assert.equal(nextDigestWatermark([e1, e2, e3], []), t(e3));
  });

  test("never advances past the oldest failure (so it's retried)", () => {
    // sent e1 & e3, e2 failed → watermark just before e2, NOT now/e3.
    assert.equal(nextDigestWatermark([e1, e3], [e2]), t(e2) - 1);
  });

  test("failures newer than everything sent don't pull the watermark back", () => {
    assert.equal(nextDigestWatermark([e1, e2], [e3]), t(e2));
  });

  test("nothing sent → null (caller must not advance)", () => {
    assert.equal(nextDigestWatermark([], [e1]), null);
    assert.equal(nextDigestWatermark(null, null), null);
  });
});

describe("scopeForUser", () => {
  test("admin keeps the owner scope; everyone else is their id", () => {
    assert.equal(scopeForUser({ id: "u1", is_admin: 1 }), "owner");
    assert.equal(scopeForUser({ id: "u1", is_admin: 0 }), "u1");
  });
});

describe("renderDigestEmail", () => {
  const episodes = [
    { title: "First", type: "podcast", url: "https://x/1", overview: "Ov one." },
    { title: "Second", type: "youtube", url: "", overview: "Ov two." },
  ];

  test("subject reflects count; body carries overviews + unsubscribe link", () => {
    const m = renderDigestEmail({
      episodes,
      overflowCount: 3,
      manageUrl: "https://recaps.cc/",
      unsubscribeUrl: "https://recaps.cc/api/digest/unsubscribe?token=tok",
    });
    assert.match(m.subject, /2 new recaps/);
    assert.match(m.html, /Ov one\./);
    assert.match(m.html, /unsubscribe\?token=tok/);
    assert.match(m.html, /3 more/);
    assert.match(m.text, /Unsubscribe: https:\/\/recaps\.cc/);
  });

  test("singular subject for one recap", () => {
    const m = renderDigestEmail({
      episodes: [episodes[0]],
      manageUrl: "https://recaps.cc/",
      unsubscribeUrl: "https://recaps.cc/u",
    });
    assert.match(m.subject, /1 new recap\b/);
  });
});
