#!/usr/bin/env bun
/**
 * release-notes.ts — turn a commit range into capability-level release notes
 * using the TRACES graph instead of raw commit subjects.
 *
 * Usage:
 *   bun run scripts/release-notes.ts [<range>]
 *   bun run scripts/release-notes.ts v0.0.15..HEAD
 *
 * With no argument it uses <latest tag>..HEAD (or the whole history if untagged).
 *
 * How it works:
 *   1. `git diff --name-only <range>` → files the range changed.
 *   2. Read each changed file's `TRACES:` comments → requirement IDs.
 *   3. Resolve IDs to descriptions from docs/requirements.md.
 *   4. Group: UR → Features, DR/IR → Improvements. Deduped, so many commits
 *      touching one requirement collapse to one line.
 *
 * This is a drafting aid for docs/release-checklist.md — review the output,
 * it does not invent descriptions for untraced changes (those are listed
 * separately so nothing is silently dropped).
 */

import { execSync } from "node:child_process";
import { readFileSync, existsSync } from "node:fs";

const TRACE_RE = /TRACES:\s*([^\n*]+)/g;
const ID_RE = /\b(UR|IR|DR|JA|UT|IT)-\d+\b/g;
const REQ_ROW_RE = /^\|\s*((?:UR|IR|DR|JA)-\d+)\s*\|\s*([^|]+?)\s*\|/;

function sh(cmd: string): string {
  return execSync(cmd, { encoding: "utf8" }).trim();
}

function defaultRange(): string {
  try {
    const tag = sh("git describe --tags --abbrev=0");
    return `${tag}..HEAD`;
  } catch {
    return ""; // no tags: fall through to whole-history diff
  }
}

/** Map requirement ID → human description, parsed from docs/requirements.md. */
function loadRequirementDescriptions(): Map<string, string> {
  const map = new Map<string, string>();
  const text = readFileSync("docs/requirements.md", "utf8");
  for (const line of text.split("\n")) {
    const m = line.match(REQ_ROW_RE);
    // First definition wins: the descriptive tables come before the later
    // cross-reference tables, whose cells hold linked IDs (or "-"), not prose.
    if (m && !map.has(m[1])) map.set(m[1], m[2].trim());
  }
  return map;
}

function changedFiles(range: string): string[] {
  const cmd = range
    ? `git diff --name-only ${range}`
    : "git ls-files"; // untagged repo: describe everything currently traced
  return sh(cmd)
    .split("\n")
    .filter((f) => f && existsSync(f));
}

/** Collect requirement IDs referenced by TRACES comments in the given files. */
function idsFromFiles(files: string[]): Set<string> {
  const ids = new Set<string>();
  for (const file of files) {
    let content: string;
    try {
      content = readFileSync(file, "utf8");
    } catch {
      continue;
    }
    for (const trace of content.matchAll(TRACE_RE)) {
      for (const id of trace[1].matchAll(ID_RE)) ids.add(id[0]);
    }
  }
  return ids;
}

function main() {
  const range = process.argv[2] ?? defaultRange();
  const descriptions = loadRequirementDescriptions();
  const files = changedFiles(range);
  const ids = idsFromFiles(files);

  const features: string[] = []; // UR
  const improvements: string[] = []; // DR / IR
  const unknown: string[] = []; // traced but not in requirements.md

  for (const id of [...ids].sort()) {
    const desc = descriptions.get(id);
    if (id.startsWith("UT") || id.startsWith("IT")) continue; // tests aren't notes
    if (!desc) {
      if (!id.startsWith("UT") && !id.startsWith("IT")) unknown.push(id);
      continue;
    }
    const line = `- ${desc} (${id})`;
    if (id.startsWith("UR")) features.push(line);
    else improvements.push(line);
  }

  const header = range || "(entire history — no tags found)";
  const out: string[] = [`## Release notes — ${header}`, ""];

  if (features.length) out.push("### ✨ Features", ...features, "");
  if (improvements.length) out.push("### 🚀 Improvements", ...improvements, "");
  if (unknown.length)
    out.push(
      "### ⚠️ Traced IDs missing from requirements.md",
      ...unknown.map((id) => `- ${id}`),
      "",
    );

  const untraced = files.filter((f) => {
    try {
      return !/TRACES:/.test(readFileSync(f, "utf8"));
    } catch {
      return false;
    }
  });
  if (untraced.length)
    out.push(
      `### 📝 Changed files without TRACES (${untraced.length}) — review manually`,
      ...untraced.map((f) => `- ${f}`),
      "",
    );

  if (!features.length && !improvements.length)
    out.push("_No traced requirements in this range._", "");

  console.log(out.join("\n"));
}

main();
