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

import { normalizeItem, normalizeItems, itemToInput } from "../src/item.js";
import type { TrendItem } from "../src/item.js";
import { normalizeField } from "../src/store.js";
import { scoreEnvelope } from "../src/score.js";

describe("trends item normalizer (RE-R1 / B1)", () => {
  describe("normalizeItem — well-formed", () => {
    test("a well-formed raw item normalizes to a canonical item (string fields verbatim)", () => {
      const raw = {
        source: "tavily",
        title: "OpenAI ships a new reasoning model",
        url: "https://example.com/Article-Path",
        topics: ["ai", "reasoning"],
        summary: "A short summary.",
      };
      const res = normalizeItem(raw);
      assert.equal(res.ok, true);
      if (!res.ok) return;
      assert.equal(res.item.source, "tavily");
      assert.equal(res.item.title, "OpenAI ships a new reasoning model"); // verbatim, case preserved
      assert.equal(res.item.url, "https://example.com/Article-Path"); // verbatim, case-sensitive path
      assert.equal(res.item.summary, "A short summary.");
      assert.deepEqual(res.item.topics, ["ai", "reasoning"]);
    });

    test("topics are normalized (lowercase + whitespace) and deduped, order-stable", () => {
      const res = normalizeItem({
        source: "manual",
        title: "T",
        url: "https://example.com/t",
        topics: ["AI", "  Machine   Learning ", "ai", "Machine Learning"],
      });
      assert.equal(res.ok, true);
      if (!res.ok) return;
      // "AI"/"ai" dedupe -> "ai"; "  Machine   Learning "/"Machine Learning" dedupe -> "machine learning"
      assert.deepEqual(res.item.topics, ["ai", "machine learning"]);
      // each topic equals store.normalizeField of the raw form (the same normalization)
      assert.equal(res.item.topics[1], normalizeField("  Machine   Learning "));
    });

    test("the canonical item carries NO id (the store derives it via addTrend->trendId)", () => {
      const res = normalizeItem({
        source: "tavily",
        title: "No id here",
        url: "https://example.com/x",
        topics: ["x"],
      });
      assert.equal(res.ok, true);
      if (!res.ok) return;
      assert.equal((res.item as Record<string, unknown>).id, undefined);
      assert.equal(Object.prototype.hasOwnProperty.call(res.item, "id"), false);
    });

    test("summary is optional — absent -> no summary key", () => {
      const res = normalizeItem({ source: "manual", title: "T", url: "https://example.com/t", topics: ["x"] });
      assert.equal(res.ok, true);
      if (!res.ok) return;
      assert.equal("summary" in res.item, false);
    });

    test("topics absent -> empty topics array", () => {
      const res = normalizeItem({ source: "manual", title: "T", url: "https://example.com/t" });
      assert.equal(res.ok, true);
      if (!res.ok) return;
      assert.deepEqual(res.item.topics, []);
    });
  });

  describe("normalizeItem — required-field validation", () => {
    for (const field of ["source", "title", "url"] as const) {
      test(`missing ${field} -> {ok:false} naming the field`, () => {
        const base: Record<string, unknown> = {
          source: "tavily",
          title: "T",
          url: "https://example.com/t",
          topics: ["x"],
        };
        delete base[field];
        const res = normalizeItem(base);
        assert.equal(res.ok, false);
        if (res.ok) return;
        assert.ok(
          res.errors.some((e) => e.includes(field)),
          `error should name ${field}: ${res.errors.join("; ")}`,
        );
      });

      test(`empty/whitespace ${field} -> {ok:false} naming the field (no silent partial)`, () => {
        const base: Record<string, unknown> = {
          source: "tavily",
          title: "T",
          url: "https://example.com/t",
          topics: ["x"],
        };
        base[field] = "   ";
        const res = normalizeItem(base);
        assert.equal(res.ok, false);
        if (res.ok) return;
        assert.ok(res.errors.some((e) => e.includes(field)));
      });
    }

    test("a non-object raw -> {ok:false}", () => {
      const res = normalizeItem("not an object" as unknown);
      assert.equal(res.ok, false);
    });
  });

  describe("normalizeItem — publishedAt", () => {
    test("present and valid ISO date -> kept", () => {
      const res = normalizeItem({
        source: "tavily",
        title: "T",
        url: "https://example.com/t",
        topics: ["x"],
        publishedAt: "2026-06-20",
      });
      assert.equal(res.ok, true);
      if (!res.ok) return;
      assert.equal(res.item.publishedAt, "2026-06-20");
    });

    test("absent -> undefined (no key)", () => {
      const res = normalizeItem({ source: "tavily", title: "T", url: "https://example.com/t", topics: ["x"] });
      assert.equal(res.ok, true);
      if (!res.ok) return;
      assert.equal(res.item.publishedAt, undefined);
      assert.equal("publishedAt" in res.item, false);
    });

    test("present but invalid -> {ok:false} naming publishedAt", () => {
      const res = normalizeItem({
        source: "tavily",
        title: "T",
        url: "https://example.com/t",
        topics: ["x"],
        publishedAt: "not-a-date",
      });
      assert.equal(res.ok, false);
      if (res.ok) return;
      assert.ok(res.errors.some((e) => e.includes("publishedAt")));
    });

    test("present but impossible calendar date -> {ok:false}", () => {
      const res = normalizeItem({
        source: "tavily",
        title: "T",
        url: "https://example.com/t",
        topics: ["x"],
        publishedAt: "2026-13-45",
      });
      assert.equal(res.ok, false);
    });
  });

  describe("normalizeItems — batch partition", () => {
    test("partitions a batch into {items, errors} with error indices", () => {
      const raw = [
        { source: "tavily", title: "Good A", url: "https://example.com/a", topics: ["x"] },
        { source: "tavily", title: "", url: "https://example.com/b", topics: ["y"] }, // bad: empty title
        { source: "manual", title: "Good C", url: "https://example.com/c", topics: ["z", "z"] },
      ];
      const { items, errors } = normalizeItems(raw);
      assert.equal(items.length, 2);
      assert.equal(errors.length, 1);
      assert.equal(errors[0].index, 1);
      assert.ok(errors[0].errors.some((e) => e.includes("title")));
      assert.deepEqual(
        items.map((i) => i.title),
        ["Good A", "Good C"],
      );
      assert.deepEqual(items[1].topics, ["z"]); // deduped
    });

    test("an empty batch -> empty partition", () => {
      const { items, errors } = normalizeItems([]);
      assert.deepEqual(items, []);
      assert.deepEqual(errors, []);
    });
  });

  describe("itemToInput — bridge to the store input (RE-R2a / B-bridge)", () => {
    // Build a realistic envelope via the normalizer (the actual upstream path).
    const mkItem = (extra: Record<string, unknown> = {}) => {
      const r = normalizeItem({
        source: "tavily",
        title: "OpenAI ships a reasoning model",
        url: "https://example.com/Article",
        topics: ["ai", "reasoning"],
        summary: "A short summary.",
        ...extra,
      });
      assert.equal(r.ok, true);
      if (!r.ok) throw new Error("fixture item failed to normalize");
      return r.item;
    };

    test("injects capturedAt and carries source/title/url/topics/summary/publishedAt verbatim", () => {
      const input = itemToInput(mkItem({ publishedAt: "2026-06-20" }), "2026-06-24");
      assert.equal(input.capturedAt, "2026-06-24");
      assert.equal(input.source, "tavily");
      assert.equal(input.title, "OpenAI ships a reasoning model");
      assert.equal(input.url, "https://example.com/Article");
      assert.deepEqual(input.topics, ["ai", "reasoning"]);
      assert.equal(input.summary, "A short summary.");
      assert.equal(input.publishedAt, "2026-06-20");
    });

    test("derives NO id (the store owns id via addTrend->trendId)", () => {
      const input = itemToInput(mkItem(), "2026-06-24") as Record<string, unknown>;
      assert.equal("id" in input, false);
    });

    test("an item without publishedAt -> input omits the key (not undefined-valued)", () => {
      const input = itemToInput(mkItem(), "2026-06-24"); // mkItem carries no publishedAt
      assert.equal("publishedAt" in input, false);
    });

    test("field-confusion guard: capturedAt (injected) is distinct from publishedAt (carried)", () => {
      const input = itemToInput(mkItem({ publishedAt: "2026-06-20" }), "2026-06-24");
      assert.notEqual(input.capturedAt, input.publishedAt);
      assert.equal(input.publishedAt, "2026-06-20");
      assert.equal(input.capturedAt, "2026-06-24");
    });
  });

  describe("normalizeItem — score validation (RE-R3a / SC2)", () => {
    const base = {
      source: "tavily",
      title: "Scored item",
      url: "https://example.com/s",
      topics: ["ai"],
    };
    const validDims = { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 };

    test("a valid kortform score -> carried with the validated dims", () => {
      const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: validDims } });
      assert.equal(res.ok, true);
      if (!res.ok) return;
      assert.deepEqual(res.item.score, { mode: "kortform", dimensions: validDims });
    });

    test("a valid long-form score -> carried with its five dims", () => {
      const longDims = { pillar: 9, depth: 8, angle: 7, authority: 6, currency: 5 };
      const res = normalizeItem({ ...base, score: { mode: "long-form", dimensions: longDims } });
      assert.equal(res.ok, true);
      if (!res.ok) return;
      assert.deepEqual(res.item.score, { mode: "long-form", dimensions: longDims });
    });

    test("absent score -> key omitted", () => {
      const res = normalizeItem(base);
      assert.equal(res.ok, true);
      if (!res.ok) return;
      assert.equal("score" in res.item, false);
    });

    test("a bad mode -> structured error (no throw)", () => {
      const res = normalizeItem({ ...base, score: { mode: "bogus", dimensions: validDims } });
      assert.equal(res.ok, false);
      if (res.ok) return;
      assert.ok(res.errors.some((e) => e.includes("invalid score")), res.errors.join("; "));
    });

    test("a missing dimension -> structured error (no throw)", () => {
      const { authority, ...missing } = validDims;
      const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: missing } });
      assert.equal(res.ok, false);
      if (res.ok) return;
      assert.ok(res.errors.some((e) => e.includes("invalid score")));
    });

    test("a dimension out of [1,10] (0 or 11) -> structured error (no throw)", () => {
      const lo = normalizeItem({ ...base, score: { mode: "kortform", dimensions: { ...validDims, pillar: 0 } } });
      assert.equal(lo.ok, false);
      const hi = normalizeItem({ ...base, score: { mode: "kortform", dimensions: { ...validDims, pillar: 11 } } });
      assert.equal(hi.ok, false);
    });

    test("a non-object score -> structured error (no throw)", () => {
      const res = normalizeItem({ ...base, score: "high" });
      assert.equal(res.ok, false);
      if (res.ok) return;
      assert.ok(res.errors.some((e) => e.includes("invalid score")));
    });

    test("an array dimensions -> structured error (no throw)", () => {
      const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: [9, 8, 9, 7, 6] } });
      assert.equal(res.ok, false);
      if (res.ok) return;
      assert.ok(res.errors.some((e) => e.includes("invalid score")));
    });

    test("an array score -> structured error (no throw)", () => {
      const res = normalizeItem({ ...base, score: [] });
      assert.equal(res.ok, false);
    });
  });

  describe("itemToInput — score bridge + the throw contract (RE-R3a / SC2)", () => {
    const validDims = { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 };

    test("a scored item -> input.score equals scoreEnvelope(mode, dimensions)", () => {
      const item: TrendItem = {
        source: "tavily",
        title: "T",
        url: "https://example.com/t",
        topics: ["ai"],
        score: { mode: "kortform", dimensions: validDims },
      };
      const input = itemToInput(item, "2026-06-24");
      assert.deepEqual(input.score, scoreEnvelope("kortform", validDims));
    });

    test("an unscored item -> no score key", () => {
      const item: TrendItem = {
        source: "tavily",
        title: "T",
        url: "https://example.com/t",
        topics: ["ai"],
      };
      const input = itemToInput(item, "2026-06-24") as Record<string, unknown>;
      assert.equal("score" in input, false);
    });

    test("called directly with an out-of-range dim -> throws by contract (defense-in-depth)", () => {
      const item: TrendItem = {
        source: "tavily",
        title: "T",
        url: "https://example.com/t",
        topics: ["ai"],
        score: { mode: "kortform", dimensions: { ...validDims, timing: 99 } },
      };
      assert.throws(() => itemToInput(item, "2026-06-24"));
    });
  });

  // The F7 gate (score.ts capForActionability) is applied on the capture path so the PERSISTED
  // priority reflects the reader-side verdict — an Immediate composite with no formulated grip
  // is stored as High, not Immediate. Proven end-to-end through itemToInput (unit-tested purely
  // in score.test.ts; here it must actually bite the store input).
  describe("itemToInput — F7 band-cap gate (MR-F7, N7)", () => {
    const immediateDims = { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 }; // composite 8.15 -> Immediate
    const mkScored = (extra: Partial<TrendItem>): TrendItem => ({
      source: "tavily",
      title: "T",
      url: "https://example.com/t",
      topics: ["ai"],
      score: { mode: "kortform", dimensions: immediateDims },
      ...extra,
    });

    test("Immediate score + actionability.formulated=false -> persisted priority capped to High", () => {
      assert.equal(scoreEnvelope("kortform", immediateDims).priority, "Immediate"); // precondition
      const input = itemToInput(mkScored({ actionability: { formulated: false } }), "2026-06-24");
      assert.equal(input.score?.priority, "High");
      assert.equal(input.score?.composite, scoreEnvelope("kortform", immediateDims).composite); // real score unchanged
    });

    test("Immediate score + actionability.formulated=true -> priority stays Immediate", () => {
      const input = itemToInput(mkScored({ actionability: { formulated: true } }), "2026-06-24");
      assert.equal(input.score?.priority, "Immediate");
    });

    test("Immediate score + no actionability -> uncapped (equals scoreEnvelope, legacy-safe)", () => {
      const input = itemToInput(mkScored({}), "2026-06-24");
      assert.deepEqual(input.score, scoreEnvelope("kortform", immediateDims));
    });
  });
});
