#!/usr/bin/env node
// scripts/coverage/diff.mjs — L1 of the coverage architecture (ADR-020).
//
// Reads the merged lcov (`coverage/lcov.info`) and the working tree's git
// diff against a base ref, then asserts cover-the-diff: every changed
// *executable* line must have execution count > 0.
//
// Output:
//   - stdout: JSON `{ status, summary, uncovered: [{ file, line, kind }] }`
//     (machine-readable for the dispatch loop)
//   - stderr: human summary
// Exit: 0 on pass, 1 on fail.
//
// Usage:
//   pnpm coverage:diff                        # default base: origin/main
//   pnpm coverage:diff -- --base HEAD~1       # override base ref
//   pnpm coverage:diff -- --lcov path/to.info # override lcov path
//   pnpm coverage:diff -- --json              # JSON only (no stderr)
//
// Implementation: zero deps. Pure Node ESM + child_process + fs.

import fs from "node:fs";
import path from "node:path";
import { execSync } from "node:child_process";

/**
 * Files that don't need diff-coverage gating. Test files, configs, docs,
 * lockfiles, generated artifacts. Also covers the per-feature exclude
 * patterns documented in vitest configs (DI bootstrap, interfaces, CMS
 * collections, factories, contracts, UI).
 */
const ALLOWED_GLOBS = [
  // Test artifacts
  /\.test\.(ts|tsx|js|mjs)$/,
  // Storybook story files — excluded from vitest by design; tested in Storybook runner
  /\.stories\.(ts|tsx)$/,
  /\/__factories__\//,
  /\/__contracts__\//,
  /\/__fixtures__\//,
  /\/__seeds__\//,
  // Configs
  /\.config\.(ts|js|mjs|cjs)$/,
  /(^|\/)package\.json$/,
  /(^|\/)tsconfig.*\.json$/,
  /(^|\/)turbo\.json$/,
  // Docs / data
  /\.md$/,
  /\.json$/,
  /\.jsonld$/, // JSON-LD context files (e.g. core-dsr/contexts/user-data.jsonld)
  /\.ya?ml$/,
  /\.gitignore$/,
  /\.prettierignore$/,
  /\.npmrc$/,
  /(^|\/)\.env(\.[^/]+)?$/, // .env, .env.example, .env.local, etc.
  // Shell scripts (not Vitest-covered)
  /\.sh$/,
  /\.bash$/,
  // Dev-tooling scripts — tested via `node --test`, outside vitest's v8 lcov.
  // (Their own test coverage is gated separately via the scripts' own tests.)
  /^scripts\//,
  /^turbo\/generators\//,
  // Per-package coverage excludes (mirror vitest config)
  /\/di\/bind-production\.ts$/,
  /\/application\/repositories\//,
  /\/application\/services\//,
  /\/integrations\/cms\//,
  /\/ui\//,
  // Tooling packages that don't generate a vitest lcov (no @vitest/coverage-v8)
  /^packages\/core-testing\//,
  /^packages\/core-eslint\//,
  // App packages (web-next, web-tanstack, cms) do not configure
  // @vitest/coverage-v8, so their source files never appear in the merged lcov.
  /^apps\//,
  // core-shared Sentry client init files — explicitly excluded from per-package
  // vitest coverage in core-shared/vitest.config.ts ("Sentry client init —
  // browser/node SDK init, tested in apps"); they have test files but coverage
  // is excluded by design (browser SDK calls, not unit-testable in isolation).
  /\/instrumentation\/sentry\//,
  // Pure type-alias / interface files (no executable code)
  /\.d\.ts$/, // ambient declaration files — no runtime code by definition
  /\.interface\.ts$/,
  /\/index\.ts$/, // barrel re-exports — no executable code
  // Build artifacts
  /\.tsbuildinfo$/,
  /\.lock$/,
  /(^|\/)dist\//,
  /(^|\/)\.next\//,
  /(^|\/)\.turbo\//,
  /(^|\/)node_modules\//,
  // Coverage output (anchored to package/app/root, NOT scripts/coverage/)
  /^coverage\//,
  /^packages\/[^/]+\/coverage\//,
  /^apps\/[^/]+\/coverage\//,
];

function isAllowed(file) {
  return ALLOWED_GLOBS.some((re) => re.test(file));
}

/**
 * Parse lcov into a map of file -> Map<lineNumber, executionCount>.
 * Only DA records are read; LF/LH/BRDA/BRF/BRH/etc. are ignored.
 *
 * lcov is a simple line-oriented format:
 *   SF:<file>
 *   DA:<line>,<count>
 *   ...
 *   end_of_record
 */
export function parseLcov(text) {
  const result = new Map();
  let currentFile = null;
  let currentLines = null;
  for (const line of text.split("\n")) {
    if (line.startsWith("SF:")) {
      currentFile = line.slice(3);
      currentLines = new Map();
      result.set(currentFile, currentLines);
    } else if (line.startsWith("DA:") && currentLines) {
      const [lineNo, count] = line.slice(3).split(",");
      currentLines.set(Number(lineNo), Number(count));
    } else if (line === "end_of_record") {
      currentFile = null;
      currentLines = null;
    }
  }
  return result;
}

/**
 * Parse `git diff --unified=0` output into a map of file -> Set<lineNumber>.
 *
 * Only NEW or MODIFIED lines in the new version are tracked (the `+N,M`
 * portion of `@@ -A,B +N,M @@`). Removed-only hunks contribute no lines
 * to check (the line is gone).
 *
 * Renamed files are tracked by their new path.
 */
export function parseGitDiff(text) {
  const result = new Map();
  let currentFile = null;
  let currentLines = null;
  for (const line of text.split("\n")) {
    if (line.startsWith("+++ ")) {
      const p = line.slice(4).trim();
      if (p === "/dev/null") {
        currentFile = null;
        currentLines = null;
        continue;
      }
      // Strip `b/` prefix git adds
      currentFile = p.startsWith("b/") ? p.slice(2) : p;
      currentLines = new Set();
      result.set(currentFile, currentLines);
    } else if (line.startsWith("@@ ") && currentLines) {
      // @@ -A,B +N,M @@
      const m = /@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
      if (!m) continue;
      const start = Number(m[1]);
      const count = m[2] === undefined ? 1 : Number(m[2]);
      if (count === 0) continue; // hunk has no lines in new version
      for (let i = 0; i < count; i++) {
        currentLines.add(start + i);
      }
    }
  }
  return result;
}

/**
 * Given parsed lcov + parsed diff, return the list of uncovered hits.
 * Each hit: { file, line, kind }
 *   - kind = "uncovered": line is executable in lcov but count is 0
 *   - kind = "no-coverage-data": file is not in lcov at all
 *   - kind = "non-executable": line has no DA record (not flagged; for
 *     visibility only, currently filtered out)
 */
export function computeDiffCoverage(diff, lcov, opts = {}) {
  const repoRoot = opts.repoRoot ?? process.cwd();
  const uncovered = [];
  const fileSummaries = [];

  for (const [file, lines] of diff) {
    if (isAllowed(file)) continue;

    // Match lcov keys (absolute paths) to diff keys (repo-relative)
    let lcovLines = lcov.get(file);
    if (!lcovLines) {
      const abs = path.resolve(repoRoot, file);
      lcovLines = lcov.get(abs);
    }
    if (!lcovLines) {
      // Try matching by suffix in either direction. lcov paths can be:
      //   - absolute (vitest with `coverage.reportsDirectory` at default)
      //   - repo-relative (after `pnpm coverage:aggregate` normalizes)
      //   - package-relative (per-package lcov from `pnpm test -- --coverage`)
      // The diff path is always repo-relative.
      for (const [k, v] of lcov.entries()) {
        if (file.endsWith("/" + k) || k.endsWith("/" + file) || k === file) {
          lcovLines = v;
          break;
        }
      }
    }

    if (!lcovLines) {
      uncovered.push({ file, line: 0, kind: "no-coverage-data" });
      fileSummaries.push({ file, changed: lines.size, missed: lines.size });
      continue;
    }

    let missedInFile = 0;
    for (const line of [...lines].sort((a, b) => a - b)) {
      const count = lcovLines.get(line);
      if (count === undefined) {
        // Line isn't executable per lcov — skip (blank, comment, type-only)
        continue;
      }
      if (count === 0) {
        uncovered.push({ file, line, kind: "uncovered" });
        missedInFile++;
      }
    }
    fileSummaries.push({ file, changed: lines.size, missed: missedInFile });
  }

  return {
    status: uncovered.length === 0 ? "pass" : "fail",
    summary: {
      filesChanged: diff.size,
      filesGated: fileSummaries.length,
      uncoveredCount: uncovered.length,
    },
    fileSummaries,
    uncovered,
  };
}

// ---- CLI ----

function parseArgs(argv) {
  const out = { base: "origin/main", lcov: "coverage/lcov.info", json: false };
  for (let i = 2; i < argv.length; i++) {
    const a = argv[i];
    if (a === "--base") out.base = argv[++i];
    else if (a === "--lcov") out.lcov = argv[++i];
    else if (a === "--json") out.json = true;
    else if (a === "--help" || a === "-h") {
      console.log(
        "Usage: pnpm coverage:diff [-- --base <ref>] [--lcov <path>] [--json]",
      );
      process.exit(0);
    }
  }
  return out;
}

function main() {
  const args = parseArgs(process.argv);
  const repoRoot = process.cwd();

  // Load lcov
  const lcovPath = path.resolve(repoRoot, args.lcov);
  if (!fs.existsSync(lcovPath)) {
    process.stderr.write(
      `[coverage:diff] lcov file not found at ${lcovPath}\n` +
        `Run \`pnpm test -- --coverage\` first, then \`pnpm coverage:aggregate\`.\n`,
    );
    // Emit JSON anyway so the dispatch loop can read it
    process.stdout.write(
      JSON.stringify({ status: "error", reason: "lcov-missing", lcovPath }) +
        "\n",
    );
    process.exit(1);
  }
  const lcov = parseLcov(fs.readFileSync(lcovPath, "utf8"));

  // Get diff
  let diffText;
  try {
    diffText = execSync(`git diff --unified=0 --no-color ${args.base}...HEAD`, {
      cwd: repoRoot,
      encoding: "utf8",
      maxBuffer: 64 * 1024 * 1024,
    });
  } catch (err) {
    process.stderr.write(
      `[coverage:diff] git diff against base "${args.base}" failed: ${err.message}\n`,
    );
    process.stdout.write(
      JSON.stringify({ status: "error", reason: "git-diff-failed" }) + "\n",
    );
    process.exit(1);
  }
  const diff = parseGitDiff(diffText);

  // Compute
  const result = computeDiffCoverage(diff, lcov, { repoRoot });

  // Emit JSON to stdout
  process.stdout.write(JSON.stringify(result, null, 2) + "\n");

  // Emit human summary to stderr (unless --json)
  if (!args.json) {
    const { status, summary, uncovered } = result;
    if (status === "pass") {
      process.stderr.write(
        `[coverage:diff] PASS — ${summary.filesGated} file(s) gated, all changed lines covered.\n`,
      );
    } else {
      process.stderr.write(
        `[coverage:diff] FAIL — ${summary.uncoveredCount} uncovered hit(s) across ${summary.filesGated} file(s):\n`,
      );
      const byFile = new Map();
      for (const u of uncovered) {
        if (!byFile.has(u.file)) byFile.set(u.file, []);
        byFile.get(u.file).push(u);
      }
      for (const [file, hits] of byFile) {
        const noData = hits[0]?.kind === "no-coverage-data";
        if (noData) {
          process.stderr.write(
            `  ${file}\n    no coverage data (new untested file?)\n`,
          );
        } else {
          const lines = hits.map((h) => h.line).join(", ");
          process.stderr.write(`  ${file}\n    uncovered lines: ${lines}\n`);
        }
      }
    }
  }

  process.exit(result.status === "pass" ? 0 : 1);
}

// Only run main when invoked directly (not when imported by tests)
const invokedDirectly = import.meta.url === `file://${process.argv[1]}`;
if (invokedDirectly) {
  main();
}
