#!/usr/bin/env node
/**
 * Pre-commit guard: refuses the commit if a new runtime dependency in a
 * feature- or core-tier package lacks a staged approved library trace.
 *
 * Exit codes:
 *   0 — all staged deps have approved traces (or are app-tier / devDeps / peerDeps)
 *   1 — one or more feature/core deps are missing an approved trace
 */
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseFrontmatter } from "./schema.mjs";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_REPO_ROOT = path.resolve(__dirname, "..", "..");

/** Derive package tier from its repo-relative path. */
function deriveTier(relPath) {
  if (relPath.startsWith("apps/")) return "app";
  if (relPath.startsWith("packages/core-")) return "core";
  if (relPath.startsWith("packages/")) return "feature";
  return "skip"; // root package.json or unknown path
}

function stagedFilesList(repoRoot, baseRef) {
  const cmd = baseRef
    ? `git diff ${baseRef}...HEAD --name-only`
    : "git diff --cached --name-only";
  return execSync(cmd, { cwd: repoRoot, encoding: "utf8" })
    .split("\n")
    .filter(Boolean);
}

/**
 * Return the names of runtime deps that are new in the staged version of
 * relPath compared to HEAD. Returns [] when the file can't be read.
 *
 * When baseRef is set, compares HEAD against baseRef instead of the index.
 */
function getNewRuntimeDeps(relPath, repoRoot, baseRef) {
  const currentRef = baseRef ? `HEAD:${relPath}` : `:${relPath}`;
  const ancestorRef = baseRef ? `${baseRef}:${relPath}` : `HEAD:${relPath}`;

  let staged;
  try {
    staged = JSON.parse(
      execSync(`git show "${currentRef}"`, { cwd: repoRoot, encoding: "utf8" }),
    );
  } catch {
    return [];
  }
  let base = {};
  try {
    base = JSON.parse(
      execSync(`git show "${ancestorRef}"`, {
        cwd: repoRoot,
        encoding: "utf8",
      }),
    );
  } catch {
    // New file or initial commit — treat all deps as new
  }
  const baseDeps = new Set(Object.keys(base.dependencies ?? {}));
  const stagedDeps = staged.dependencies ?? {};
  return Object.keys(stagedDeps).filter(
    (d) =>
      !baseDeps.has(d) &&
      // Workspace-protocol entries are internal monorepo packages — not
      // third-party libraries, so they don't require a library trace.
      !String(stagedDeps[d]).startsWith("workspace:"),
  );
}

/**
 * Search the staged-files list for a trace file whose name ends with
 * `-<depName>.md` inside docs/library-decisions/.
 */
function findStagedTrace(depName, staged) {
  const safe = depName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  const re = new RegExp(`^docs/library-decisions/[^/]+-${safe}\\.md$`);
  return staged.find((f) => re.test(f)) ?? null;
}

// ---------------------------------------------------------------------------
// Renovate-PR mode helpers
// ---------------------------------------------------------------------------

function getCurrentBranch(repoRoot) {
  try {
    return execSync("git rev-parse --abbrev-ref HEAD", {
      cwd: repoRoot,
      encoding: "utf8",
    }).trim();
  } catch {
    return null;
  }
}

function getLockfileDiff(repoRoot) {
  try {
    return execSync("git diff origin/main -- pnpm-lock.yaml", {
      cwd: repoRoot,
      encoding: "utf8",
    });
  } catch {
    return "";
  }
}

/**
 * Matches a pnpm lockfile diff line for a package version entry.
 * Handles both pnpm v6 format (`/pkg@ver:`) and v9 format (`  pkg@ver: {}`),
 * and both scoped (`@scope/name`) and unscoped packages.
 */
const LOCKFILE_LINE_RE =
  /^([+-])\s*\/?(@[\w.-]+\/[\w.-]+|[\w.-]+)@(\d[-\w.+]*)(?=\s|:|$)/;

/** Parse a lockfile diff and return a Map of { depName → { from, to } }. */
function parseLockfileDiff(diff) {
  const removed = new Map();
  const added = new Map();
  for (const line of diff.split("\n")) {
    const m = LOCKFILE_LINE_RE.exec(line);
    if (!m) continue;
    const [, sign, name, version] = m;
    if (sign === "-") removed.set(name, version);
    else added.set(name, version);
  }
  const bumped = new Map();
  for (const [name, to] of added) {
    const from = removed.get(name);
    if (from && from !== to) bumped.set(name, { from, to });
  }
  return bumped;
}

/** Classify a version bump as "major", "minor", "patch", or "unknown". */
function classifyBump(from, to) {
  const m1 = from.match(/^(\d+)\.(\d+)/);
  const m2 = to.match(/^(\d+)\.(\d+)/);
  if (!m1 || !m2) return "unknown";
  const fromMaj = +m1[1],
    toMaj = +m2[1];
  const fromMin = +m1[2],
    toMin = +m2[2];
  if (toMaj > fromMaj) return "major";
  if (toMaj === fromMaj && toMin > fromMin) return "minor";
  return "patch";
}

/**
 * Return the set of direct runtime dependencies declared by all feature- and
 * core-tier packages (packages/* on disk). App-tier deps are excluded.
 */
function getFeatureCoreDeps(repoRoot) {
  const deps = new Set();
  const packagesDir = path.join(repoRoot, "packages");
  if (!fs.existsSync(packagesDir)) return deps;
  let entries;
  try {
    entries = fs.readdirSync(packagesDir);
  } catch {
    return deps;
  }
  for (const pkg of entries) {
    const pkgJsonPath = path.join(packagesDir, pkg, "package.json");
    if (!fs.existsSync(pkgJsonPath)) continue;
    try {
      const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
      for (const dep of Object.keys(pkgJson.dependencies ?? {})) {
        deps.add(dep);
      }
    } catch {
      // skip malformed package.json
    }
  }
  return deps;
}

/**
 * Locate an existing (committed) trace file for depName under
 * docs/library-decisions/ inside repoRoot. Returns the absolute path or null.
 */
function findExistingTrace(depName, repoRoot) {
  const traceDir = path.join(repoRoot, "docs", "library-decisions");
  if (!fs.existsSync(traceDir)) return null;
  let entries;
  try {
    entries = fs.readdirSync(traceDir);
  } catch {
    return null;
  }

  if (depName.startsWith("@")) {
    // Scoped package: @scope/name → look for a dir *-@scope, then name.md inside
    const slashIdx = depName.indexOf("/");
    const scope = depName.slice(0, slashIdx); // e.g. "@sentry"
    const name = depName.slice(slashIdx + 1); // e.g. "node"
    for (const entry of entries) {
      if (!entry.endsWith(`-${scope}`)) continue;
      const entryPath = path.join(traceDir, entry);
      try {
        if (!fs.statSync(entryPath).isDirectory()) continue;
      } catch {
        continue;
      }
      const tracePath = path.join(entryPath, `${name}.md`);
      if (fs.existsSync(tracePath)) return tracePath;
    }
  } else {
    // Unscoped package: look for *-<name>.md
    const safe = depName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    const re = new RegExp(`^[^/]+-${safe}\\.md$`);
    for (const entry of entries) {
      if (!re.test(entry)) continue;
      const entryPath = path.join(traceDir, entry);
      try {
        if (!fs.statSync(entryPath).isFile()) continue;
      } catch {
        continue;
      }
      return entryPath;
    }
  }
  return null;
}

/**
 * Renovate-PR mode: for every feature/core-tier dep that receives a major
 * version bump, the corresponding trace must have lastRevalidated === today.
 *
 * Returns an array of error objects; empty array means clean.
 *   { dep, from, to, reason: "stale" | "no-trace" | "parse-error", ... }
 */
export function checkRenovatePr(
  repoRoot = DEFAULT_REPO_ROOT,
  { branch, diff, today } = {},
) {
  const effectiveBranch =
    branch ?? process.env.GITHUB_HEAD_REF ?? getCurrentBranch(repoRoot);

  if (!effectiveBranch?.startsWith("renovate/")) return [];

  const lockfileDiff = diff ?? getLockfileDiff(repoRoot);
  const todayStr = today ?? new Date().toISOString().slice(0, 10);
  const bumped = parseLockfileDiff(lockfileDiff);
  const featureCoreDeps = getFeatureCoreDeps(repoRoot);
  const errors = [];

  for (const [depName, { from, to }] of bumped) {
    if (!featureCoreDeps.has(depName)) continue;
    if (classifyBump(from, to) !== "major") continue;

    const traceFile = findExistingTrace(depName, repoRoot);
    if (!traceFile) {
      errors.push({ dep: depName, from, to, reason: "no-trace" });
      continue;
    }

    try {
      const content = fs.readFileSync(traceFile, "utf8");
      const fm = parseFrontmatter(content);
      if (fm.lastRevalidated !== todayStr) {
        errors.push({
          dep: depName,
          from,
          to,
          reason: "stale",
          lastRevalidated: fm.lastRevalidated ?? null,
          tracePath: path.relative(repoRoot, traceFile),
        });
      }
    } catch (e) {
      errors.push({
        dep: depName,
        from,
        to,
        reason: "parse-error",
        detail: String(e.message),
      });
    }
  }

  return errors;
}

/**
 * Run the library-decisions check against repoRoot.
 *
 * Returns an array of error objects, each with:
 *   { pkgJson, dep, reason: "no-trace" | "not-approved" | "parse-error", ... }
 *
 * An empty array means the commit is clean.
 */
export function checkLibraryDecisions(
  repoRoot = DEFAULT_REPO_ROOT,
  { stagedAgainst } = {},
) {
  const staged = stagedFilesList(repoRoot, stagedAgainst);
  const pkgJsons = staged.filter(
    (f) => f === "package.json" || f.endsWith("/package.json"),
  );
  const errors = [];

  for (const relPath of pkgJsons) {
    const tier = deriveTier(relPath);
    if (tier === "app" || tier === "skip") continue;

    for (const dep of getNewRuntimeDeps(relPath, repoRoot, stagedAgainst)) {
      const stagedTrace = findStagedTrace(dep, staged);
      if (!stagedTrace) {
        // Fall back to an already-committed trace — if one exists and is
        // approved, the dep was previously evaluated and doesn't need
        // re-staging just because a new package adopts it.
        const committedTrace = findExistingTrace(dep, repoRoot);
        if (committedTrace) {
          try {
            const content = fs.readFileSync(committedTrace, "utf8");
            const fm = parseFrontmatter(content);
            if (fm.decision !== "approved") {
              errors.push({
                pkgJson: relPath,
                dep,
                reason: "not-approved",
                decision: fm.decision,
              });
            }
          } catch (e) {
            errors.push({
              pkgJson: relPath,
              dep,
              reason: "parse-error",
              detail: String(e.message),
            });
          }
          continue;
        }
        errors.push({ pkgJson: relPath, dep, reason: "no-trace" });
        continue;
      }
      try {
        const traceRef = stagedAgainst
          ? `HEAD:${stagedTrace}`
          : `:${stagedTrace}`;
        const content = execSync(`git show "${traceRef}"`, {
          cwd: repoRoot,
          encoding: "utf8",
        });
        const fm = parseFrontmatter(content);
        if (fm.decision !== "approved") {
          errors.push({
            pkgJson: relPath,
            dep,
            reason: "not-approved",
            decision: fm.decision,
          });
        }
      } catch (e) {
        errors.push({
          pkgJson: relPath,
          dep,
          reason: "parse-error",
          detail: String(e.message),
        });
      }
    }
  }

  return errors;
}

// CLI entry point — only runs when executed directly, not when imported.
if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const args = process.argv.slice(2);

  if (args.includes("--renovate-pr")) {
    let branch;
    const branchIdx = args.indexOf("--branch");
    if (branchIdx !== -1) {
      branch = args[branchIdx + 1];
      if (!branch || branch.startsWith("--")) {
        console.error("Error: --branch requires a branch name argument");
        process.exit(1);
      }
    }

    const renovateErrors = checkRenovatePr(DEFAULT_REPO_ROOT, { branch });
    if (!renovateErrors.length) process.exit(0);

    const today = new Date().toISOString().slice(0, 10);
    console.error(
      "✗ library-decisions/check: major version bump requires re-evaluation.\n",
    );
    for (const e of renovateErrors) {
      console.error(`  ${e.dep} (${e.from} → ${e.to}):`);
      if (e.reason === "stale") {
        console.error(
          `    ✗ trace lastRevalidated is "${e.lastRevalidated}" — must be today (${today})`,
        );
        console.error(`    Trace: ${e.tracePath}`);
      } else if (e.reason === "no-trace") {
        console.error(`    ✗ no trace found in docs/library-decisions/`);
      } else {
        console.error(`    ✗ trace error — ${e.detail ?? ""}`);
      }
      console.error(
        `    Run the evaluate-library skill: .claude/skills/evaluate-library/SKILL.md`,
      );
    }
    process.exit(1);
  }

  let stagedAgainst;
  const flagIdx = args.indexOf("--staged-against");
  if (flagIdx !== -1) {
    stagedAgainst = args[flagIdx + 1];
    if (!stagedAgainst || stagedAgainst.startsWith("--")) {
      console.error("Error: --staged-against requires a base ref argument");
      process.exit(1);
    }
  }

  const errors = checkLibraryDecisions(DEFAULT_REPO_ROOT, { stagedAgainst });
  if (!errors.length) process.exit(0);

  console.error(
    "✗ library-decisions/check: new runtime deps require an approved trace.\n",
  );

  const groups = {};
  for (const e of errors) {
    (groups[e.pkgJson] ??= []).push(e);
  }
  for (const [pkg, errs] of Object.entries(groups)) {
    console.error(`  ${pkg}:`);
    for (const e of errs) {
      const msg =
        e.reason === "no-trace"
          ? "no staged trace in docs/library-decisions/"
          : e.reason === "not-approved"
            ? `trace decision is "${e.decision}" (expected "approved")`
            : `trace parse error — ${e.detail}`;
      console.error(`    ✗ ${e.dep}: ${msg}`);
    }
  }
  console.error(
    "\n  Evaluate the library first: .claude/skills/evaluate-library/SKILL.md",
  );
  process.exit(1);
}
