import { test, describe } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseLcov, parseGitDiff, computeDiffCoverage } from "./diff.mjs";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURES = path.join(__dirname, "__fixtures__");

const lcovText = fs.readFileSync(path.join(FIXTURES, "sample.lcov"), "utf8");
const diffText = fs.readFileSync(
  path.join(FIXTURES, "sample-diff.patch"),
  "utf8",
);

describe("parseLcov", () => {
  test("groups DA records by SF file", () => {
    const lcov = parseLcov(lcovText);
    assert.equal(lcov.size, 3);
    assert.ok(
      lcov.has(
        "/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts",
      ),
    );
    assert.ok(lcov.has("/repo/packages/auth/src/entities/models/user.ts"));
  });

  test("preserves per-line execution counts", () => {
    const lcov = parseLcov(lcovText);
    const lines = lcov.get(
      "/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts",
    );
    assert.equal(lines.get(1), 5);
    assert.equal(lines.get(5), 0);
    assert.equal(lines.get(8), 3);
  });

  test("ignores non-DA records (LF, LH, BRDA)", () => {
    const lcov = parseLcov(lcovText);
    const lines = lcov.get("/repo/packages/auth/src/entities/models/user.ts");
    assert.equal(lines.size, 3); // Only DA records, not LF/LH counters
  });
});

describe("parseGitDiff", () => {
  test("extracts new + modified line numbers per file from the new version", () => {
    const diff = parseGitDiff(diffText);
    const signIn = diff.get(
      "packages/auth/src/application/use-cases/sign-in.use-case.ts",
    );
    // Hunks: +2,2 (lines 2,3) and +5,2 (lines 5,6)
    assert.deepEqual(
      [...signIn].sort((a, b) => a - b),
      [2, 3, 5, 6],
    );
  });

  test("strips the b/ prefix from new-version paths", () => {
    const diff = parseGitDiff(diffText);
    assert.ok(diff.has("packages/auth/src/entities/models/user.ts"));
    assert.ok(!diff.has("b/packages/auth/src/entities/models/user.ts"));
  });

  test("handles single-line hunks (no comma in +N,M)", () => {
    const diff = parseGitDiff(diffText);
    const blog = diff.get(
      "packages/blog/src/application/use-cases/get-article.use-case.ts",
    );
    // @@ -11,0 +12 @@  -> single line at 12
    assert.deepEqual([...blog], [12]);
  });

  test("handles new files (entire file is in the diff)", () => {
    const diff = parseGitDiff(diffText);
    const upload = diff.get(
      "packages/media/src/application/use-cases/upload.use-case.ts",
    );
    // @@ -0,0 +1,5 @@ -> lines 1-5
    assert.deepEqual(
      [...upload].sort((a, b) => a - b),
      [1, 2, 3, 4, 5],
    );
  });
});

describe("computeDiffCoverage", () => {
  test("passes when changed executable lines are all covered", () => {
    const lcov = parseLcov(lcovText);
    const diff = new Map([
      ["/repo/packages/auth/src/entities/models/user.ts", new Set([1, 2, 3])],
    ]);
    const result = computeDiffCoverage(diff, lcov);
    assert.equal(result.status, "pass");
    assert.equal(result.uncovered.length, 0);
  });

  test("fails when a changed line is in lcov with count 0", () => {
    const lcov = parseLcov(lcovText);
    const diff = new Map([
      [
        "/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts",
        new Set([5, 6]),
      ],
    ]);
    const result = computeDiffCoverage(diff, lcov);
    assert.equal(result.status, "fail");
    assert.equal(result.uncovered.length, 2);
    assert.deepEqual(result.uncovered.map((u) => u.line).sort(), [5, 6]);
    assert.ok(result.uncovered.every((u) => u.kind === "uncovered"));
  });

  test("ignores lines without DA records (non-executable)", () => {
    const lcov = parseLcov(lcovText);
    // Line 4 has no DA record in the fixture
    const diff = new Map([
      [
        "/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts",
        new Set([4]),
      ],
    ]);
    const result = computeDiffCoverage(diff, lcov);
    assert.equal(result.status, "pass");
  });

  test("flags files with no coverage data (new untested file)", () => {
    const lcov = parseLcov(lcovText);
    const diff = new Map([
      [
        "packages/media/src/application/use-cases/upload.use-case.ts",
        new Set([1, 2, 3, 4, 5]),
      ],
    ]);
    const result = computeDiffCoverage(diff, lcov);
    assert.equal(result.status, "fail");
    assert.equal(result.uncovered.length, 1);
    assert.equal(result.uncovered[0].kind, "no-coverage-data");
  });

  test("skips allowed extensions (.md, .json, .test.ts, configs)", () => {
    const lcov = parseLcov(lcovText);
    const diff = new Map([
      ["CLAUDE.md", new Set([1, 2])],
      ["package.json", new Set([1])],
      ["packages/auth/vitest.config.ts", new Set([1])],
      [
        "packages/auth/src/application/use-cases/sign-in.use-case.test.ts",
        new Set([11]),
      ],
    ]);
    const result = computeDiffCoverage(diff, lcov);
    assert.equal(result.status, "pass");
    assert.equal(result.summary.filesGated, 0);
    assert.equal(result.summary.filesChanged, 4);
  });

  test("skips JSON-LD context files (.jsonld)", () => {
    const lcov = parseLcov(lcovText);
    const diff = new Map([
      // JSON-LD context files are static data assets with no executable code
      // (e.g. packages/core-dsr/src/contexts/user-data.jsonld). v8 coverage
      // never sees them, so they must be exempted from the no-coverage-data gate.
      ["packages/core-dsr/src/contexts/user-data.jsonld", new Set([1, 2, 3])],
    ]);
    const result = computeDiffCoverage(diff, lcov);
    assert.equal(result.status, "pass");
    assert.equal(result.summary.filesGated, 0);
    assert.equal(result.summary.filesChanged, 1);
  });

  test("skips TypeScript ambient declaration files (.d.ts)", () => {
    const lcov = parseLcov(lcovText);
    const diff = new Map([
      // Ambient declaration files have no runtime code — v8 coverage never
      // sees them, so they must be exempted from the no-coverage-data gate.
      [
        "packages/core-shared/src/payload/payload-custom-ambient.d.ts",
        new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
      ],
      ["packages/foo/src/bar/some-types.d.ts", new Set([1, 2])],
    ]);
    const result = computeDiffCoverage(diff, lcov);
    assert.equal(result.status, "pass");
    assert.equal(result.summary.filesGated, 0);
    assert.equal(result.summary.filesChanged, 2);
  });

  test("skips dotfile ignore configs (.prettierignore, .gitignore)", () => {
    const lcov = parseLcov(lcovText);
    const diff = new Map([
      [".prettierignore", new Set([1, 2])],
      [".gitignore", new Set([1])],
    ]);
    const result = computeDiffCoverage(diff, lcov);
    assert.equal(result.status, "pass");
    assert.equal(result.summary.filesGated, 0);
    assert.equal(result.summary.filesChanged, 2);
  });

  test("skips .env template files (.env, .env.example, .env.local)", () => {
    const lcov = parseLcov(lcovText);
    const diff = new Map([
      [".env.example", new Set([1, 2, 3])],
      [".env.local", new Set([1])],
      [".env", new Set([1])],
    ]);
    const result = computeDiffCoverage(diff, lcov);
    assert.equal(result.status, "pass");
    assert.equal(result.summary.filesGated, 0);
    assert.equal(result.summary.filesChanged, 3);
  });

  test("skips Storybook story files (.stories.ts/.stories.tsx — excluded from vitest coverage)", () => {
    const lcov = parseLcov(lcovText);
    const diff = new Map([
      [
        "packages/core-ui/src/cookie-consent-banner/cookie-consent-banner.stories.tsx",
        new Set([1, 2, 3, 4, 5]),
      ],
      ["packages/core-ui/src/atoms/button/button.stories.ts", new Set([1, 2])],
    ]);
    const result = computeDiffCoverage(diff, lcov);
    assert.equal(result.status, "pass");
    assert.equal(result.summary.filesGated, 0);
    assert.equal(result.summary.filesChanged, 2);
  });

  test("skips packages/core-testing/ (tooling package, no lcov generated)", () => {
    const lcov = parseLcov(lcovText);
    const diff = new Map([
      [
        "packages/core-testing/src/instrumentation/recording-consent.ts",
        new Set([1, 2, 3, 4, 5]),
      ],
      [
        "packages/core-testing/src/factory/define-factory.ts",
        new Set([1, 2, 3]),
      ],
    ]);
    const result = computeDiffCoverage(diff, lcov);
    assert.equal(result.status, "pass");
    assert.equal(result.summary.filesGated, 0);
    assert.equal(result.summary.filesChanged, 2);
  });

  test("skips apps/ files (app packages don't configure @vitest/coverage-v8)", () => {
    const lcov = parseLcov(lcovText);
    const diff = new Map([
      // web-next and web-tanstack have no @vitest/coverage-v8 — never in lcov
      ["apps/web-next/instrumentation-client.ts", new Set([1, 2, 3, 4, 5, 6])],
      ["apps/web-next/middleware.ts", new Set([1, 2, 3, 4, 5])],
      ["apps/web-next/src/app/layout.tsx", new Set([1, 2, 3])],
      [
        "apps/web-tanstack/src/instrumentation-client.ts",
        new Set([1, 2, 3, 4, 5]),
      ],
      ["apps/web-tanstack/app.config.ts", new Set([1, 2, 3])],
    ]);
    const result = computeDiffCoverage(diff, lcov);
    assert.equal(result.status, "pass");
    assert.equal(result.summary.filesGated, 0);
    assert.equal(result.summary.filesChanged, 5);
  });

  test("skips core-shared sentry init files (excluded from vitest coverage by design)", () => {
    const lcov = parseLcov(lcovText);
    const diff = new Map([
      // core-shared/vitest.config.ts excludes src/instrumentation/sentry/**
      [
        "packages/core-shared/src/instrumentation/sentry/init-client.ts",
        new Set([1, 2, 3, 4, 5]),
      ],
      [
        "packages/core-shared/src/instrumentation/sentry/init-client-react.ts",
        new Set([1, 2, 3, 4, 5]),
      ],
    ]);
    const result = computeDiffCoverage(diff, lcov);
    assert.equal(result.status, "pass");
    assert.equal(result.summary.filesGated, 0);
    assert.equal(result.summary.filesChanged, 2);
  });

  test("end-to-end fixture: mixed pass/fail/skip/no-data", () => {
    const lcov = parseLcov(lcovText);
    const diff = parseGitDiff(diffText);
    const result = computeDiffCoverage(diff, lcov, { repoRoot: "/repo" });

    assert.equal(result.status, "fail");
    // CLAUDE.md, sign-in.use-case.test.ts -> skipped (allowlist)
    // sign-in.use-case.ts lines 2,3,5,6 -> 5,6 are uncovered, 2,3 don't have DA records (not in lcov for those lines) so they don't count
    // user.ts line 2 -> covered (count: 1)
    // get-article.use-case.ts line 12 -> uncovered (count: 0)
    // upload.use-case.ts -> no coverage data
    // The expected uncovered set: sign-in lines 5,6 + get-article line 12 + upload (no-data)
    const uncoveredKinds = result.uncovered.map((u) => u.kind);
    assert.ok(uncoveredKinds.includes("no-coverage-data"));
    assert.ok(uncoveredKinds.includes("uncovered"));
  });

  test("resolves repo-relative diff paths against lcov absolute paths", () => {
    const lcov = parseLcov(lcovText);
    const diff = new Map([
      // Diff uses repo-relative; lcov has absolute. Suffix match should
      // bridge them.
      [
        "packages/auth/src/application/use-cases/sign-in.use-case.ts",
        new Set([8]),
      ],
    ]);
    const result = computeDiffCoverage(diff, lcov, { repoRoot: "/repo" });
    assert.equal(result.status, "pass");
  });
});
