import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { mintContentId } from "../src/id.js";
import type { PublishedRecord } from "../src/ingest.js";
import { assemblePostGraph, type AnalyticsRowInput } from "../src/assemble.js";
import {
  parseRecentPosts,
  reconcileRecentPosts,
  summarizeReconcile,
  loadRecentPosts,
} from "../src/reconcile.js";

const DATE = "2026-05-26";

function rec(body: string, over: Partial<PublishedRecord> = {}): PublishedRecord {
  return {
    id: mintContentId(body),
    provenance: "published",
    published_date: DATE,
    captured_at: "2026-06-23",
    source: "manual",
    specifics: [],
    trends: [],
    body,
    ...over,
  };
}

function row(title: string, over: Partial<AnalyticsRowInput> = {}): AnalyticsRowInput {
  return { title, publishedDate: DATE, metrics: { engagementRate: 4.2 }, ...over };
}

// Bodies whose normalized opener is ≥ HOOK_PREFIX_FLOOR (24) chars.
const BODY_A = "Jeg lærte noe viktig om dømmekraft i dag.\n\nDel 1 av serien.";
const HOOK_A = "Jeg lærte noe viktig om dømmekraft i dag.";
const BODY_B = "En helt annen tekst om automatisering og styring.\n\nDel 2.";
const HOOK_B = "En helt annen tekst om automatisering og styring.";

// The writer's exact entry format (state-updater.mjs:116): - [date] "hook" (chars) - topic
function entry(date: string, hook: string, chars: number, topic: string): string {
  return `- [${date}] "${hook}" (${chars}) - ${topic}`;
}

function stateWith(...entries: string[]): string {
  return [
    "---",
    "posts_this_week: 3",
    "---",
    "",
    "## Recent Posts",
    "",
    ...entries,
    "",
    "## Milestone Log",
    "",
    "- [2026-05] 1048 (+10)",
    "",
  ].join("\n");
}

describe("SB-S3e — parseRecentPosts (writer-format reader)", () => {
  test("extracts {date, hook, charCount, topic} from the ## Recent Posts section", () => {
    const text = stateWith(
      entry("2026-05-26", HOOK_A, 1400, "dømmekraft"),
      entry("2026-05-20", HOOK_B, 900, "automatisering"),
    );
    const posts = parseRecentPosts(text);
    assert.equal(posts.length, 2);
    assert.deepEqual(posts[0], { date: "2026-05-26", hook: HOOK_A, charCount: 1400, topic: "dømmekraft" });
    assert.deepEqual(posts[1], { date: "2026-05-20", hook: HOOK_B, charCount: 900, topic: "automatisering" });
  });

  test("golden-string: a literal writer-produced entry round-trips verbatim", () => {
    // The byte-exact string updatePostTracking emits (state-updater.mjs:116).
    const writerLine = `- [2026-06-01] "${HOOK_A}" (1234) - strategi`;
    const posts = parseRecentPosts(["## Recent Posts", "", writerLine, ""].join("\n"));
    assert.equal(posts.length, 1);
    assert.equal(posts[0].hook, HOOK_A);
    assert.equal(posts[0].charCount, 1234);
    assert.equal(posts[0].topic, "strategi");
  });

  test("stops at the next section (## Milestone Log is not parsed as an entry)", () => {
    const text = stateWith(entry("2026-05-26", HOOK_A, 1400, "dømmekraft"));
    const posts = parseRecentPosts(text);
    assert.equal(posts.length, 1);
    assert.ok(posts.every((p) => /^\d{4}-\d{2}-\d{2}$/.test(p.date)));
  });

  test("a doc with no ## Recent Posts section → []", () => {
    assert.deepEqual(parseRecentPosts("# Some other doc\n\n## Notes\n\n- a line"), []);
  });

  test("$-bearing hook/topic round-trips verbatim (capture on read — no replace, no injection)", () => {
    const dollarHook = "Hvorfor $100 i budsjettkutt endrer alt for $deg";
    const text = ["## Recent Posts", "", entry("2026-05-26", dollarHook, 800, "$penger$"), ""].join("\n");
    const posts = parseRecentPosts(text);
    assert.equal(posts.length, 1);
    assert.equal(posts[0].hook, dollarHook);
    assert.equal(posts[0].topic, "$penger$");
  });
});

describe("SB-S3e — reconcileRecentPosts (coverage tiers)", () => {
  test("in-graph: created post matches a published record that HAS analytics", () => {
    const r = rec(BODY_A);
    const graph = assemblePostGraph({ records: [r], analytics: [row(HOOK_A)] });
    const recentPosts = parseRecentPosts(
      ["## Recent Posts", "", entry("2026-05-26", HOOK_A, 1400, "dømmekraft"), ""].join("\n"),
    );
    const nodes = reconcileRecentPosts({ recentPosts, records: [r], graph });
    assert.equal(nodes.length, 1);
    assert.equal(nodes[0].tier, "in-graph");
    assert.equal(nodes[0].contentId, r.id);
  });

  test("in-brain-only: created post matches a record but the record has NO analytics", () => {
    const r = rec(BODY_B);
    const graph = assemblePostGraph({ records: [r], analytics: [] }); // no analytics → match none
    const recentPosts = parseRecentPosts(
      ["## Recent Posts", "", entry("2026-05-26", HOOK_B, 900, "automatisering"), ""].join("\n"),
    );
    const nodes = reconcileRecentPosts({ recentPosts, records: [r], graph });
    assert.equal(nodes[0].tier, "in-brain-only");
    assert.equal(nodes[0].contentId, r.id);
  });

  test("orphaned-in-state: created post matches NO published record (never ingested)", () => {
    const r = rec(BODY_A);
    const graph = assemblePostGraph({ records: [r], analytics: [row(HOOK_A)] });
    const recentPosts = parseRecentPosts(
      ["## Recent Posts", "", entry("2026-05-19", "En post som aldri ble ingestet i hjernen", 700, "annet"), ""].join("\n"),
    );
    const nodes = reconcileRecentPosts({ recentPosts, records: [r], graph });
    assert.equal(nodes[0].tier, "orphaned-in-state");
    assert.equal(nodes[0].contentId, undefined);
  });

  test("truncated-hook (…) degradation: a '…'-suffixed preview still prefix-matches", () => {
    const r = rec(BODY_A);
    const graph = assemblePostGraph({ records: [r], analytics: [row(HOOK_A)] });
    const recentPosts = parseRecentPosts(
      ["## Recent Posts", "", entry("2026-05-26", "Jeg lærte noe viktig om dømmekraft i…", 1400, "dømmekraft"), ""].join("\n"),
    );
    const nodes = reconcileRecentPosts({ recentPosts, records: [r], graph });
    assert.equal(nodes[0].tier, "in-graph");
  });

  test("below-floor hook → orphaned (too short to discriminate, never a false match)", () => {
    const r = rec(BODY_A);
    const graph = assemblePostGraph({ records: [r], analytics: [row(HOOK_A)] });
    const recentPosts = parseRecentPosts(
      ["## Recent Posts", "", entry("2026-05-26", "Jeg lærte", 1400, "dømmekraft"), ""].join("\n"),
    );
    const nodes = reconcileRecentPosts({ recentPosts, records: [r], graph });
    assert.equal(nodes[0].tier, "orphaned-in-state");
  });

  test("summary counts created / in-graph / in-brain-only / orphaned", () => {
    const rA = rec(BODY_A);
    const rB = rec(BODY_B);
    const graph = assemblePostGraph({ records: [rA, rB], analytics: [row(HOOK_A)] });
    const recentPosts = parseRecentPosts(
      [
        "## Recent Posts",
        "",
        entry("2026-05-26", HOOK_A, 1400, "a"), // in-graph (rA has analytics)
        entry("2026-05-26", HOOK_B, 900, "b"), // in-brain-only (rB no analytics)
        entry("2026-05-10", "Helt ukjent post som ingen record dekker", 600, "c"), // orphaned
        "",
      ].join("\n"),
    );
    const nodes = reconcileRecentPosts({ recentPosts, records: [rA, rB], graph });
    const sum = summarizeReconcile(nodes);
    assert.deepEqual(sum, { created: 3, inGraph: 1, inBrainOnly: 1, orphaned: 1 });
  });

  test("empty ## Recent Posts → empty report, no throw", () => {
    const nodes = reconcileRecentPosts({ recentPosts: [], records: [rec(BODY_A)], graph: [] });
    assert.deepEqual(nodes, []);
    assert.deepEqual(summarizeReconcile(nodes), { created: 0, inGraph: 0, inBrainOnly: 0, orphaned: 0 });
  });
});

describe("SB-S3e — loadRecentPosts (state-file IO seam, STATE_FILE precedence)", () => {
  test("reads the state file pointed at by STATE_FILE; unset/absent → []", () => {
    const dir = mkdtempSync(join(tmpdir(), "ls-recon-"));
    const file = join(dir, "linkedin-studio.local.md");
    writeFileSync(file, stateWith(entry("2026-05-26", HOOK_A, 1400, "dømmekraft")));
    const prev = process.env.STATE_FILE;
    try {
      process.env.STATE_FILE = file;
      const posts = loadRecentPosts();
      assert.equal(posts.length, 1);
      assert.equal(posts[0].hook, HOOK_A);

      // Absent file via STATE_FILE → [] (fresh-clone safe, no throw).
      process.env.STATE_FILE = join(dir, "does-not-exist.md");
      assert.deepEqual(loadRecentPosts(), []);
    } finally {
      if (prev === undefined) delete process.env.STATE_FILE;
      else process.env.STATE_FILE = prev;
      rmSync(dir, { recursive: true, force: true });
    }
  });
});
