import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { createGoliveAdapter, type GitRunner } from "./golive";

const FIXTURE_DIR = join(import.meta.dir, "../../test/fixtures/golive");

function loadFixture(name: string): string {
  return readFileSync(join(FIXTURE_DIR, name), "utf8");
}

const BASIC = loadFixture("basic.md");
const NOW_MD = loadFixture("now.md");
const SEVEN_DAYS_AGO_MD = loadFixture("seven-days-ago.md");
const VERDICTS_MD = loadFixture("verdicts.md");

const FIXED_NOW_MS = Date.parse("2026-07-17T00:00:00.000Z");
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
const CUTOFF_ISO = new Date(FIXED_NOW_MS - SEVEN_DAYS_MS).toISOString();

function fakeReadFile(files: Record<string, string>): (path: string) => string {
  return (path: string) => {
    const content = files[path];
    if (content === undefined) {
      throw new Error(`ENOENT: no such file, open '${path}'`);
    }
    return content;
  };
}

/** Never answers a git call — exercises the "git history unavailable" tolerance path. */
function unreachableGit(): GitRunner {
  return async (args, cwd) => {
    throw new Error(`unexpected git call: ${cwd} :: ${args.join(" ")}`);
  };
}

function keyedGit(responses: Record<string, string>): {
  run: GitRunner;
  calls: Array<{ args: string[]; cwd: string }>;
} {
  const calls: Array<{ args: string[]; cwd: string }> = [];
  const run: GitRunner = async (args, cwd) => {
    calls.push({ args, cwd });
    const key = `${cwd}::${args.join(" ")}`;
    const response = responses[key];
    if (response === undefined) {
      throw new Error(`unmocked git call: ${key}`);
    }
    return response;
  };
  return { run, calls };
}

describe("createGoliveAdapter", () => {
  test("id defaults to golive with a sane default interval", () => {
    const adapter = createGoliveAdapter({ readFileImpl: fakeReadFile({}), runGit: unreachableGit() });
    expect(adapter.id).toBe("golive");
    expect(adapter.interval).toBeGreaterThan(0);
  });

  test("counts works/total from checkbox lines, excluding a decoy inside a fenced code block", async () => {
    const repo = "/repos/basic-repo";
    const adapter = createGoliveAdapter({
      repos: [repo],
      readFileImpl: fakeReadFile({ [join(repo, "GOLIVE.md")]: BASIC }),
      runGit: unreachableGit(),
      now: () => FIXED_NOW_MS,
    });

    const result = await adapter.poll();

    expect(result.items).toEqual([]);
    expect(result.panels).toHaveLength(1);
    expect(result.panels[0]?.id).toBe("scoreboard");
    const data = result.panels[0]?.data as { repos: Array<Record<string, unknown>> };
    expect(data.repos).toHaveLength(1);
    // BASIC has AC-01..AC-04 real checkboxes (2 checked) plus two decoys inside a
    // fence that must not be counted — total stays 4, not 6.
    expect(data.repos[0]?.works).toBe(2);
    expect(data.repos[0]?.total).toBe(4);
  });

  test("parses verdict tags: works=WORKS-count, 4-way breakdown, per-criterion rows, fenced decoy excluded", async () => {
    const repo = "/repos/verdicts-repo";
    const adapter = createGoliveAdapter({
      repos: [repo],
      readFileImpl: fakeReadFile({ [join(repo, "GOLIVE.md")]: VERDICTS_MD }),
      runGit: unreachableGit(),
      now: () => FIXED_NOW_MS,
    });

    const result = await adapter.poll();
    const data = result.panels[0]?.data as { repos: Array<Record<string, unknown>> };
    const score = data.repos[0] as {
      works: number;
      total: number;
      verdicts: Record<string, number>;
      title: string | null;
      summary: string | null;
      criteria: Array<{ id: string; text: string; verdict: string; evidence: string | null; section: string | null; reachedAt: string | null }>;
    };

    // boxes are all [ ]; the verdict lives in the tag, so works counts WORKS, not [x].
    expect(score.works).toBe(1);
    expect(score.total).toBe(4);
    expect(score.verdicts).toEqual({ works: 1, broken: 1, missing: 1, unverified: 1 });
    expect(score.title).toBe("sample-verdicts");
    expect(score.summary).toBe("1 WORKS / 1 BROKEN / 1 MISSING / 1 UNVERIFIED (4 criteria)");

    expect(score.criteria).toHaveLength(4); // AC-99 decoy inside the fence is not counted
    expect(score.criteria[0]).toMatchObject({
      id: "AC-01",
      text: "Homepage serves",
      verdict: "works",
      evidence: "prod 200 at example.com.",
      section: "Production",
      reachedAt: null, // unreachable git -> blame unavailable
    });
    expect(score.criteria[2]).toMatchObject({
      id: "AC-03",
      verdict: "missing",
      evidence: null,
      section: "Money path",
    });
    expect(score.criteria.map((c) => c.verdict)).toEqual(["works", "broken", "missing", "unverified"]);
  });

  test("reachedAt: committed criterion line gets its commit date; uncommitted (zero-SHA) line stays null", async () => {
    const repo = "/repos/blame-repo";
    const md = "# GOLIVE — t\n- [ ] AC-01 [WORKS] — a\n- [ ] AC-02 [BROKEN] — b\n";
    // git blame --line-porcelain: full commit block repeated per line. Line 2 committed
    // (committer-time 1700000000 -> 2023-11-14); line 3 is the all-zero "not committed yet" SHA.
    const porcelain = [
      "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 1 1 1",
      "committer-time 1000000000",
      "filename GOLIVE.md",
      "\t# GOLIVE — t",
      "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 2 2 1",
      "committer-time 1700000000",
      "filename GOLIVE.md",
      "\t- [ ] AC-01 [WORKS] — a",
      "0000000000000000000000000000000000000000 3 3 1",
      "committer-time 1752000000",
      "filename GOLIVE.md",
      "\t- [ ] AC-02 [BROKEN] — b",
      "",
    ].join("\n");
    const { run } = keyedGit({ [`${repo}::blame --line-porcelain -- GOLIVE.md`]: porcelain });
    const adapter = createGoliveAdapter({
      repos: [repo],
      readFileImpl: fakeReadFile({ [join(repo, "GOLIVE.md")]: md }),
      runGit: run, // trend/churn log calls are unmocked -> tolerated (caught); only blame is mocked
      now: () => FIXED_NOW_MS,
    });

    const result = await adapter.poll();
    const data = result.panels[0]?.data as {
      repos: Array<{ criteria: Array<{ id: string; reachedAt: string | null }> }>;
    };
    const criteria = data.repos[0]?.criteria ?? [];
    expect(criteria.find((c) => c.id === "AC-01")?.reachedAt).toBe("2023-11-14");
    expect(criteria.find((c) => c.id === "AC-02")?.reachedAt).toBeNull();
  });

  test("computes 7-day trend from a fake git log: works 7 days ago vs now", async () => {
    const repo = "/repos/trend-repo";
    const { run } = keyedGit({
      [`${repo}::log -1 --format=%H --until=${CUTOFF_ISO} -- GOLIVE.md`]: "commit7dago\n",
      [`${repo}::show commit7dago:./GOLIVE.md`]: SEVEN_DAYS_AGO_MD,
      [`${repo}::log --format=%H -- GOLIVE.md`]: "commit7dago\n",
      [`${repo}::rev-list --count commit7dago..HEAD`]: "0\n",
    });
    const adapter = createGoliveAdapter({
      repos: [repo],
      readFileImpl: fakeReadFile({ [join(repo, "GOLIVE.md")]: NOW_MD }),
      runGit: run,
      now: () => FIXED_NOW_MS,
    });

    const result = await adapter.poll();

    const data = result.panels[0]?.data as { repos: Array<Record<string, unknown>> };
    expect(data.repos[0]).toMatchObject({
      repo: "trend-repo",
      works: 3,
      total: 4,
      trend7d: 2, // NOW_MD works(3) - SEVEN_DAYS_AGO_MD works(1)
      churn: false,
      commitsSinceLastWorksDelta: 0,
    });
  });

  test("no commit found before the 7-day cutoff: trend falls back to current works count", async () => {
    const repo = "/repos/new-repo";
    const { run } = keyedGit({
      [`${repo}::log -1 --format=%H --until=${CUTOFF_ISO} -- GOLIVE.md`]: "",
      [`${repo}::log --format=%H -- GOLIVE.md`]: "onlyCommit\n",
      [`${repo}::show onlyCommit:./GOLIVE.md`]: NOW_MD,
      [`${repo}::rev-list --count onlyCommit..HEAD`]: "3\n",
    });
    const adapter = createGoliveAdapter({
      repos: [repo],
      readFileImpl: fakeReadFile({ [join(repo, "GOLIVE.md")]: NOW_MD }),
      runGit: run,
      now: () => FIXED_NOW_MS,
    });

    const result = await adapter.poll();
    const data = result.panels[0]?.data as { repos: Array<Record<string, unknown>> };
    expect(data.repos[0]?.trend7d).toBe(3);
  });

  test("churn threshold: commitsSinceLastWorksDelta at threshold is not churn", async () => {
    const repo = "/repos/churn-repo";
    const { run } = keyedGit({
      [`${repo}::log -1 --format=%H --until=${CUTOFF_ISO} -- GOLIVE.md`]: "",
      [`${repo}::log --format=%H -- GOLIVE.md`]: "hNew\nhOld\n",
      [`${repo}::show hNew:./GOLIVE.md`]: NOW_MD,
      [`${repo}::show hOld:./GOLIVE.md`]: SEVEN_DAYS_AGO_MD,
      [`${repo}::rev-list --count hNew..HEAD`]: "10\n",
    });
    const adapter = createGoliveAdapter({
      repos: [repo],
      readFileImpl: fakeReadFile({ [join(repo, "GOLIVE.md")]: NOW_MD }),
      runGit: run,
      churnThreshold: 10,
      now: () => FIXED_NOW_MS,
    });

    const result = await adapter.poll();
    const data = result.panels[0]?.data as { repos: Array<Record<string, unknown>> };
    expect(data.repos[0]?.commitsSinceLastWorksDelta).toBe(10);
    expect(data.repos[0]?.churn).toBe(false);
  });

  test("churn threshold: commitsSinceLastWorksDelta over threshold fires churn", async () => {
    const repo = "/repos/churn-repo";
    const { run } = keyedGit({
      [`${repo}::log -1 --format=%H --until=${CUTOFF_ISO} -- GOLIVE.md`]: "",
      [`${repo}::log --format=%H -- GOLIVE.md`]: "hNew\nhOld\n",
      [`${repo}::show hNew:./GOLIVE.md`]: NOW_MD,
      [`${repo}::show hOld:./GOLIVE.md`]: SEVEN_DAYS_AGO_MD,
      [`${repo}::rev-list --count hNew..HEAD`]: "11\n",
    });
    const adapter = createGoliveAdapter({
      repos: [repo],
      readFileImpl: fakeReadFile({ [join(repo, "GOLIVE.md")]: NOW_MD }),
      runGit: run,
      churnThreshold: 10,
      now: () => FIXED_NOW_MS,
    });

    const result = await adapter.poll();
    const data = result.panels[0]?.data as { repos: Array<Record<string, unknown>> };
    expect(data.repos[0]?.commitsSinceLastWorksDelta).toBe(11);
    expect(data.repos[0]?.churn).toBe(true);
  });

  test("a missing or unreadable GOLIVE.md drops that repo from the panel without throwing; other repos survive", async () => {
    const goodRepo = "/repos/good-repo";
    const badRepo = "/repos/missing-golive-repo";
    const { run } = keyedGit({
      [`${goodRepo}::log -1 --format=%H --until=${CUTOFF_ISO} -- GOLIVE.md`]: "",
      [`${goodRepo}::log --format=%H -- GOLIVE.md`]: "",
    });
    const adapter = createGoliveAdapter({
      repos: [badRepo, goodRepo],
      readFileImpl: fakeReadFile({ [join(goodRepo, "GOLIVE.md")]: BASIC }),
      runGit: run,
      now: () => FIXED_NOW_MS,
    });

    const result = await adapter.poll();
    const data = result.panels[0]?.data as { repos: Array<Record<string, unknown>> };
    expect(data.repos).toHaveLength(1);
    expect(data.repos[0]?.repo).toBe("good-repo");
  });

  test("empty git history (untracked GOLIVE.md) is tolerated: zero trend, no churn", async () => {
    const repo = "/repos/untracked-repo";
    const { run } = keyedGit({
      [`${repo}::log -1 --format=%H --until=${CUTOFF_ISO} -- GOLIVE.md`]: "",
      [`${repo}::log --format=%H -- GOLIVE.md`]: "",
    });
    const adapter = createGoliveAdapter({
      repos: [repo],
      readFileImpl: fakeReadFile({ [join(repo, "GOLIVE.md")]: BASIC }),
      runGit: run,
      now: () => FIXED_NOW_MS,
    });

    const result = await adapter.poll();
    const data = result.panels[0]?.data as { repos: Array<Record<string, unknown>> };
    expect(data.repos[0]?.trend7d).toBe(2); // no commit before cutoff -> current works count
    expect(data.repos[0]?.churn).toBe(false);
    expect(data.repos[0]?.commitsSinceLastWorksDelta).toBe(0);
  });

  test("sourceUpdatedAt: newest GOLIVE.md commit date, normalised to an ISO instant", async () => {
    const repo = "/repos/source-repo";
    const { run } = keyedGit({
      [`${repo}::log -1 --format=%cI -- GOLIVE.md`]: "2026-07-22T01:31:02+07:00\n",
    });
    const adapter = createGoliveAdapter({
      repos: [repo],
      readFileImpl: fakeReadFile({ [join(repo, "GOLIVE.md")]: BASIC }),
      runGit: run,
      now: () => FIXED_NOW_MS,
    });

    const result = await adapter.poll();
    const data = result.panels[0]?.data as { repos: Array<Record<string, unknown>> };
    expect(data.repos[0]?.sourceUpdatedAt).toBe("2026-07-21T18:31:02.000Z");
  });

  test("sourceUpdatedAt: never-committed GOLIVE.md stays null", async () => {
    const repo = "/repos/source-repo";
    const { run } = keyedGit({
      [`${repo}::log -1 --format=%cI -- GOLIVE.md`]: "\n",
    });
    const adapter = createGoliveAdapter({
      repos: [repo],
      readFileImpl: fakeReadFile({ [join(repo, "GOLIVE.md")]: BASIC }),
      runGit: run,
      now: () => FIXED_NOW_MS,
    });

    const result = await adapter.poll();
    const data = result.panels[0]?.data as { repos: Array<Record<string, unknown>> };
    expect(data.repos[0]?.sourceUpdatedAt).toBeNull();
  });

  test("scores from origin/main when the remote branch exists — working tree never shadows it", async () => {
    const repo = "/repos/canonical-repo";
    const { run } = keyedGit({
      [`${repo}::fetch --quiet origin`]: "",
      [`${repo}::rev-parse --verify --quiet origin/main^{commit}`]: "abc123\n",
      [`${repo}::show origin/main:./GOLIVE.md`]: VERDICTS_MD,
    });
    const adapter = createGoliveAdapter({
      repos: [repo],
      // Stale working tree: plain checkboxes, all works — must NOT be what gets scored.
      readFileImpl: fakeReadFile({ [join(repo, "GOLIVE.md")]: BASIC }),
      runGit: run,
      now: () => FIXED_NOW_MS,
    });

    const result = await adapter.poll();
    const data = result.panels[0]?.data as { repos: Array<Record<string, unknown>> };
    expect(data.repos[0]?.works).toBe(1); // VERDICTS_MD, not BASIC's 2
    expect(data.repos[0]?.total).toBe(4);
  });

  test("origin/main derivation anchors history reads to the ref", async () => {
    const repo = "/repos/canonical-repo";
    const { run, calls } = keyedGit({
      [`${repo}::fetch --quiet origin`]: "",
      [`${repo}::rev-parse --verify --quiet origin/main^{commit}`]: "abc123\n",
      [`${repo}::show origin/main:./GOLIVE.md`]: NOW_MD,
      [`${repo}::log -1 origin/main --format=%H --until=${CUTOFF_ISO} -- GOLIVE.md`]:
        "commit7dago\n",
      [`${repo}::show commit7dago:./GOLIVE.md`]: SEVEN_DAYS_AGO_MD,
      [`${repo}::log origin/main --format=%H -- GOLIVE.md`]: "commitnow\ncommit7dago\n",
      [`${repo}::show commitnow:./GOLIVE.md`]: NOW_MD,
      [`${repo}::rev-list --count commitnow..origin/main`]: "0\n",
      [`${repo}::log -1 origin/main --format=%cI -- GOLIVE.md`]: "2026-07-16T00:00:00+00:00\n",
      [`${repo}::blame --line-porcelain origin/main -- GOLIVE.md`]: "",
    });
    const adapter = createGoliveAdapter({
      repos: [repo],
      readFileImpl: fakeReadFile({}),
      runGit: run,
      now: () => FIXED_NOW_MS,
    });

    const result = await adapter.poll();
    const data = result.panels[0]?.data as { repos: Array<Record<string, unknown>> };
    expect(data.repos[0]?.sourceUpdatedAt).toBe("2026-07-16T00:00:00.000Z");
    for (const call of calls) {
      expect(call.args.join(" ")).not.toContain("..HEAD");
    }
  });

  test("no remote branch: falls back to the working tree and HEAD-anchored history", async () => {
    const repo = "/repos/offline-repo";
    const adapter = createGoliveAdapter({
      repos: [repo],
      readFileImpl: fakeReadFile({ [join(repo, "GOLIVE.md")]: BASIC }),
      runGit: unreachableGit(),
      now: () => FIXED_NOW_MS,
    });

    const result = await adapter.poll();
    const data = result.panels[0]?.data as { repos: Array<Record<string, unknown>> };
    expect(data.repos[0]?.works).toBe(2); // BASIC via readFileImpl
  });

  test("emits no items — panel only", async () => {
    const repo = "/repos/basic-repo";
    const adapter = createGoliveAdapter({
      repos: [repo],
      readFileImpl: fakeReadFile({ [join(repo, "GOLIVE.md")]: BASIC }),
      runGit: unreachableGit(),
      now: () => FIXED_NOW_MS,
    });

    const result = await adapter.poll();
    expect(result.items).toEqual([]);
  });

  test("uses argv-array git invocation, never a shell string", async () => {
    const repo = "/repos/trend-repo";
    const { run, calls } = keyedGit({
      [`${repo}::log -1 --format=%H --until=${CUTOFF_ISO} -- GOLIVE.md`]: "commit7dago\n",
      [`${repo}::show commit7dago:./GOLIVE.md`]: SEVEN_DAYS_AGO_MD,
      [`${repo}::log --format=%H -- GOLIVE.md`]: "commit7dago\n",
      [`${repo}::rev-list --count commit7dago..HEAD`]: "0\n",
    });
    const adapter = createGoliveAdapter({
      repos: [repo],
      readFileImpl: fakeReadFile({ [join(repo, "GOLIVE.md")]: NOW_MD }),
      runGit: run,
      now: () => FIXED_NOW_MS,
    });

    await adapter.poll();

    expect(calls.length).toBeGreaterThan(0);
    for (const call of calls) {
      expect(Array.isArray(call.args)).toBe(true);
      for (const arg of call.args) {
        expect(arg.includes("&&")).toBe(false);
        expect(arg.includes(";")).toBe(false);
      }
    }
  });
});
