#!/usr/bin/env bun
/**
 * Extract TRACES from source code and generate requirement mapping
 *
 * Usage:
 *   bun run scripts/extract-traces.ts
 *   bun run scripts/extract-traces.ts --format json
 *   bun run scripts/extract-traces.ts --format markdown > docs/traceability.md
 */

import * as fs from "fs";
import * as path from "path";
import { execSync } from "child_process";

interface TraceEntry {
  file: string;
  line: number;
  context: string;
  requirements: string[];
}

interface RequirementMapping {
  [reqId: string]: TraceEntry[];
}

interface TracesData {
  timestamp: string;
  totalFiles: number;
  totalTraces: number;
  requirements: RequirementMapping;
  byType: {
    UR: string[];
    IR: string[];
    DR: string[];
    JA: string[];
  };
  /** Requirements *defined* in requirements.md — the coverage denominators. */
  defined?: { UR: number; IR: number; DR: number; JA: number; total: number };
  coverage?: CoverageResult;
}

// Repo root, derived from this script's location (scripts/ -> repo root).
// Must NOT be hardcoded to a developer's machine, or CI checkouts see no files.
//
// `import.meta.dir` is a Bun extension and is undefined when this module is
// imported by vitest (which runs it as an ordinary ESM module), so fall back to
// import.meta.url — this file must stay importable for extract-traces.test.ts.
const SCRIPT_DIR =
  import.meta.dir ?? path.dirname(new URL(import.meta.url).pathname);
const BASE_DIR = path.resolve(SCRIPT_DIR, "..");

const TRACES_PATTERN = /TRACES:\s*([^\n]+)/gi;
const REQ_ID_PATTERN = /([A-Z]{2})-(\d{3})/g;

function extractRequirementIds(tracesString: string): string[] {
  const matches = [...tracesString.matchAll(REQ_ID_PATTERN)];
  return matches.map((m) => `${m[1]}-${m[2]}`);
}

function getAllSourceFiles(): string[] {
  const baseDir = BASE_DIR;
  // `scripts` is scanned too: build tooling implements requirements (e.g.
  // DR-093, the coverage engine itself) and would otherwise be invisible to the
  // very matrix it generates.
  const patterns = ["src", "src-tauri/src", "scripts"];
  const files: string[] = [];

  function walkDir(dir: string) {
    try {
      const entries = fs.readdirSync(dir, { withFileTypes: true });
      for (const entry of entries) {
        const fullPath = path.join(dir, entry.name);
        const relativePath = path.relative(baseDir, fullPath);

        // Skip node_modules, target, build
        if (
          relativePath.includes("node_modules") ||
          relativePath.includes("target") ||
          relativePath.includes("build") ||
          relativePath.includes(".git")
        ) {
          continue;
        }

        if (entry.isDirectory()) {
          walkDir(fullPath);
        } else if (
          entry.name.endsWith(".ts") ||
          entry.name.endsWith(".svelte") ||
          entry.name.endsWith(".rs")
        ) {
          files.push(fullPath);
        }
      }
    } catch (error) {
      // Skip directories we can't read
    }
  }

  for (const pattern of patterns) {
    const dir = path.join(baseDir, pattern);
    if (fs.existsSync(dir)) {
      walkDir(dir);
    }
  }

  return files;
}

function extractTraces(): TracesData {
  const requirementMap: RequirementMapping = {};
  const byType: Record<string, Set<string>> = {
    UR: new Set(),
    IR: new Set(),
    DR: new Set(),
    JA: new Set(),
  };

  let totalTraces = 0;
  const baseDir = BASE_DIR;

  const files = getAllSourceFiles();

  for (const fullPath of files) {
    try {
      const content = fs.readFileSync(fullPath, "utf-8");
      const lines = content.split("\n");
      const relativePath = path.relative(baseDir, fullPath);

      let match;
      TRACES_PATTERN.lastIndex = 0;

      while ((match = TRACES_PATTERN.exec(content)) !== null) {
        const tracesStr = match[1];
        const reqIds = extractRequirementIds(tracesStr);

        if (reqIds.length === 0) continue;

        // Find line number
        const beforeMatch = content.substring(0, match.index);
        const lineNum = beforeMatch.split("\n").length - 1;

        // Get context (function/class name if available)
        let context = "Unknown";
        for (let i = lineNum; i >= Math.max(0, lineNum - 10); i--) {
          const line = lines[i];
          if (
            line.includes("function ") ||
            line.includes("export const ") ||
            line.includes("pub fn ") ||
            line.includes("pub enum ") ||
            line.includes("pub struct ") ||
            line.includes("impl ") ||
            line.includes("async function ") ||
            line.includes("class ") ||
            line.includes("export type ")
          ) {
            context = line
              .trim()
              .replace(/^\s*\/\/\s*/, "")
              .replace(/^\s*\/\*\*\s*/, "");
            break;
          }
        }

        const entry: TraceEntry = {
          file: relativePath,
          line: lineNum + 1,
          context,
          requirements: reqIds,
        };

        for (const reqId of reqIds) {
          if (!requirementMap[reqId]) {
            requirementMap[reqId] = [];
          }
          requirementMap[reqId].push(entry);

          // Track by type
          const type = reqId.substring(0, 2);
          if (byType[type]) {
            byType[type].add(reqId);
          }
        }

        totalTraces++;
      }
    } catch (error) {
      // Skip files we can't read
    }
  }

  return {
    timestamp: new Date().toISOString(),
    totalFiles: files.length,
    totalTraces,
    requirements: requirementMap,
    byType: {
      UR: Array.from(byType["UR"]).sort(),
      IR: Array.from(byType["IR"]).sort(),
      DR: Array.from(byType["DR"]).sort(),
      JA: Array.from(byType["JA"]).sort(),
    },
  };
}

// ---------------------------------------------------------------------------
// Coverage: how many *defined* requirements are actually traced.
//
// The denominators MUST be derived from requirements.md, never hardcoded. The
// CI gate previously divided by frozen literals (UR/39, IR/24, DR/48, JA/3,
// total 114) while the real file had grown to 211 requirements, so it reported
// 158% coverage and the 50% threshold became unreachable — the gate could not
// fail. See docs/specs/traceability-gate-repair.md.
//
// TRACES: | DR-093
// ---------------------------------------------------------------------------

export interface DefinedRequirements {
  UR: number;
  IR: number;
  DR: number;
  JA: number;
  total: number;
  ids: Set<string>;
}

export interface CoverageResult {
  covered: number;
  total: number;
  percent: number;
  /** Traced in code but not defined in requirements.md (typo, or deleted req). */
  orphaned: string[];
}

/**
 * A requirement is *defined* only where its ID is the leading cell of a markdown
 * table row: `| DR-001 | … |`.
 *
 * This deliberately ignores IDs in the "Traces To" column and in prose — a
 * naive scan for /DR-\d{3}/ counts those as definitions and inflates the
 * denominator. IDs are deduplicated because requirements.md lists each UR twice
 * (once in §1 as a definition, again in §3's traceability matrix), which would
 * otherwise double the UR count from 61 to 121.
 *
 * TRACES: | DR-093
 */
export function countDefinedRequirements(markdown: string): DefinedRequirements {
  const ids = new Set<string>();
  const ROW_ID = /^\|\s*(UR|IR|DR|JA)-(\d{3})\s*\|/;

  for (const line of markdown.split("\n")) {
    const match = line.match(ROW_ID);
    if (match) ids.add(`${match[1]}-${match[2]}`);
  }

  const countOf = (type: string) =>
    [...ids].filter((id) => id.startsWith(`${type}-`)).length;

  return {
    UR: countOf("UR"),
    IR: countOf("IR"),
    DR: countOf("DR"),
    JA: countOf("JA"),
    total: ids.size,
    ids,
  };
}

/**
 * Coverage is the *intersection* of traced and defined IDs over defined IDs.
 *
 * Using the raw traced count as the numerator is what lets a ratio exceed 100%:
 * a TRACES comment naming a requirement that no longer exists would count as
 * covered. Those IDs are reported as `orphaned` so they get fixed rather than
 * silently counted or silently dropped.
 *
 * TRACES: | DR-093
 */
export function computeCoverage(
  tracedIds: string[],
  defined: DefinedRequirements
): CoverageResult {
  // Only the four *requirement* types participate in coverage. UT/IT are test
  // identifiers defined in §4 of requirements.md — a different taxonomy, and
  // flagging them as orphans would bury real typos in ~60 lines of noise.
  const isRequirement = (id: string) => /^(UR|IR|DR|JA)-\d{3}$/.test(id);

  const traced = new Set(tracedIds.filter(isRequirement));
  const covered = [...traced].filter((id) => defined.ids.has(id));
  const orphaned = [...traced].filter((id) => !defined.ids.has(id)).sort();

  return {
    covered: covered.length,
    total: defined.total,
    percent:
      defined.total === 0
        ? 0
        : Math.round((covered.length / defined.total) * 100),
    orphaned,
  };
}

/** Read requirements.md from the repo and count what it defines. */
export function readDefinedRequirements(): DefinedRequirements {
  const reqPath = path.join(BASE_DIR, "docs", "requirements.md");
  return countDefinedRequirements(fs.readFileSync(reqPath, "utf-8"));
}

function generateMarkdown(data: TracesData): string {
  let md = `# Code Traceability Matrix

**Generated:** ${new Date(data.timestamp).toLocaleString()}

## Summary

- **Total Files Scanned:** ${data.totalFiles}
- **Total TRACES Found:** ${data.totalTraces}
- **Requirements Covered:**
  - User Requirements (UR): ${data.byType.UR.length}
  - Integration Requirements (IR): ${data.byType.IR.length}
  - Development Requirements (DR): ${data.byType.DR.length}
  - Jellyfin API Requirements (JA): ${data.byType.JA.length}

## Requirements by Type

### User Requirements (UR)
\`\`\`
${data.byType.UR.join(", ")}
\`\`\`

### Integration Requirements (IR)
\`\`\`
${data.byType.IR.join(", ")}
\`\`\`

### Development Requirements (DR)
\`\`\`
${data.byType.DR.join(", ")}
\`\`\`

### Jellyfin API Requirements (JA)
\`\`\`
${data.byType.JA.join(", ")}
\`\`\`

## Detailed Mapping

`;

  // Sort requirements by ID
  const sortedReqs = Object.keys(data.requirements).sort((a, b) => {
    const typeA = a.substring(0, 2);
    const typeB = b.substring(0, 2);
    const typeOrder = { UR: 0, IR: 1, DR: 2, JA: 3 };
    if (typeOrder[typeA] !== typeOrder[typeB]) {
      return (typeOrder[typeA] || 4) - (typeOrder[typeB] || 4);
    }
    return a.localeCompare(b);
  });

  for (const reqId of sortedReqs) {
    const entries = data.requirements[reqId];
    md += `### ${reqId}\n\n`;
    md += `**Locations:** ${entries.length} file(s)\n\n`;

    for (const entry of entries) {
      md += `- **File:** [\`${entry.file}\`](${entry.file}#L${entry.line})\n`;
      md += `  - **Line:** ${entry.line}\n`;
      const contextPreview = entry.context.substring(0, 70);
      md += `  - **Context:** \`${contextPreview}${entry.context.length > 70 ? "..." : ""}\`\n`;
    }
    md += "\n";
  }

  return md;
}

function generateJson(data: TracesData): string {
  return JSON.stringify(data, null, 2);
}

/**
 * Human-readable coverage report; exits non-zero below the threshold so this is
 * runnable as a local gate (`bun run traces:coverage`), not just in CI.
 *
 * TRACES: | DR-093
 */
function reportCoverage(data: TracesData, minThreshold: number): number {
  const defined = data.defined!;
  const cov = data.coverage!;

  const definedIds = readDefinedRequirements().ids;

  console.log("📋 Requirement coverage (traced / defined):");
  for (const type of ["UR", "IR", "DR", "JA"] as const) {
    const traced = data.byType[type].filter((id) => definedIds.has(id)).length;
    console.log(`   ${type}: ${traced} / ${defined[type]}`);
  }
  console.log("");
  console.log(`📈 Overall: ${cov.covered} / ${cov.total} (${cov.percent}%)`);

  if (cov.orphaned.length > 0) {
    console.log("");
    console.log(
      `⚠️  Traced but not defined in requirements.md: ${cov.orphaned.join(", ")}`
    );
    console.log("   Fix the TRACES comment or add the requirement.");
  }

  // A ratio above 100% means the computation is broken (the condition that hid
  // the stale-denominator bug for so long). Fail loudly rather than report it.
  if (cov.percent > 100) {
    console.log("");
    console.log(`❌ Coverage > 100% — the gate is miscomputing.`);
    return 1;
  }

  if (cov.percent < minThreshold) {
    console.log("");
    console.log(`❌ Coverage (${cov.percent}%) is below minimum (${minThreshold}%)`);
    return 1;
  }

  console.log("");
  console.log(`✅ Coverage is acceptable (${cov.percent}% >= ${minThreshold}%)`);
  return 0;
}

// Main — guarded so this module stays importable from extract-traces.test.ts.
if (import.meta.main) {
  const args = process.argv.slice(2);
  const format = args.includes("--format")
    ? args[args.indexOf("--format") + 1]
    : "markdown";

  console.error("🔍 Extracting TRACES from codebase...");
  const data = extractTraces();

  const defined = readDefinedRequirements();
  const allTraced = Object.keys(data.requirements);
  data.defined = {
    UR: defined.UR,
    IR: defined.IR,
    DR: defined.DR,
    JA: defined.JA,
    total: defined.total,
  };
  data.coverage = computeCoverage(allTraced, defined);

  if (format === "json") {
    console.log(generateJson(data));
  } else if (format === "coverage") {
    process.exit(reportCoverage(data, 50));
  } else {
    console.log(generateMarkdown(data));
  }

  console.error(
    `\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files`
  );
}
