/**
 * The diagnostic scaffolding (L2 oracle): a standing taxonomy of the
 * mechanical bug classes AI codegen predictably injects — imports, wiring,
 * types, env vars, stubs — implemented as deterministic detectors, not model
 * judgment. Detectors run globally on every sweep, which is what lets RHA
 * both localize a finding to a feature node and generalize it: the same
 * class is checked across every sibling node in the tree in the same pass.
 */
import fs from 'node:fs';
import path from 'node:path';
import type { Finding, IRManifest } from './types';
import { classifyTsc, runTsc } from './sandbox';

/** Map a workspace-relative file to the feature that owns it. */
export function buildOwnershipIndex(manifest: IRManifest): Map<string, string> {
  const idx = new Map<string, string>();
  for (const f of manifest.features) {
    for (const file of f.files) idx.set(normalize(file), f.id);
    idx.set(normalize(f.testFile), f.id);
  }
  return idx;
}

function normalize(p: string): string {
  return p.replace(/\\/g, '/').replace(/^\.\//, '');
}

export function ownerOf(file: string, idx: Map<string, string>): string | null {
  return idx.get(normalize(file)) ?? null;
}

/** Run every deterministic detector. Returns findings bound to feature nodes. */
export function sweepTaxonomy(dir: string, manifest: IRManifest): Finding[] {
  const idx = buildOwnershipIndex(manifest);
  const findings: Finding[] = [];

  // --- compiler-backed classes: syntax, unresolved-import, undefined-symbol,
  // --- type-mismatch
  for (const d of runTsc(dir)) {
    findings.push({
      bugClass: classifyTsc(d),
      file: normalize(d.file),
      line: d.line,
      message: `TS${d.code}: ${d.message}`,
      featureId: ownerOf(d.file, idx),
    });
  }

  // --- source-scan classes
  for (const feature of manifest.features) {
    for (const rel of feature.files) {
      const abs = path.join(dir, rel);
      if (!fs.existsSync(abs)) {
        findings.push({
          bugClass: 'unwired-export',
          file: normalize(rel),
          line: null,
          message: `declared implementation file is missing`,
          featureId: feature.id,
        });
        continue;
      }
      const src = fs.readFileSync(abs, 'utf8');

      // todo-stub: committed placeholders
      const stub = src.match(/(TODO|FIXME|not implemented|NotImplemented)/i);
      if (stub && /throw new Error\(|\/\/\s*(TODO|FIXME)/i.test(src)) {
        const line = src.slice(0, stub.index).split('\n').length;
        findings.push({
          bugClass: 'todo-stub',
          file: normalize(rel),
          line,
          message: `stub/placeholder left in implementation: "${stub[0]}"`,
          featureId: feature.id,
        });
      }

      // env-var-unset: process.env.X referenced but not declared
      const declared = readDeclaredEnv(dir);
      const envRe = /process\.env\.([A-Z0-9_]+)/g;
      let em: RegExpExecArray | null;
      while ((em = envRe.exec(src)) !== null) {
        const name = em[1];
        if (!declared.has(name) && !feature.envVars.includes(name)) {
          findings.push({
            bugClass: 'env-var-unset',
            file: normalize(rel),
            line: src.slice(0, em.index).split('\n').length,
            message: `reads process.env.${name} which is neither declared in .env.example nor in the feature contract`,
            featureId: feature.id,
          });
        }
      }
    }

    // unwired-export: the feature contract says a symbol must be exported
    for (const ex of feature.exports) {
      const abs = path.join(dir, ex.file);
      if (!fs.existsSync(abs)) continue; // missing-file finding already emitted
      const src = fs.readFileSync(abs, 'utf8');
      if (!exportsSymbol(src, ex.symbol)) {
        findings.push({
          bugClass: 'unwired-export',
          file: normalize(ex.file),
          line: null,
          message: `contract requires export "${ex.symbol}" but it is not exported (wired to nothing)`,
          featureId: feature.id,
        });
      }
    }

    // test-orphan: frozen test imports a symbol the implementation never exports
    const testAbs = path.join(dir, feature.testFile);
    if (fs.existsSync(testAbs)) {
      const testSrc = fs.readFileSync(testAbs, 'utf8');
      const importRe = /import\s*\{([^}]+)\}\s*from\s*['"](\.[^'"]+)['"]/g;
      let im: RegExpExecArray | null;
      while ((im = importRe.exec(testSrc)) !== null) {
        const symbols = im[1].split(',').map(s => s.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
        const target = resolveRelative(dir, feature.testFile, im[2]);
        if (!target || !fs.existsSync(target)) continue; // unresolved-import via tsc
        const implSrc = fs.readFileSync(target, 'utf8');
        for (const sym of symbols) {
          if (sym === 'describe' || sym === 'it' || sym === 'expect') continue;
          if (!exportsSymbol(implSrc, sym)) {
            findings.push({
              bugClass: 'test-orphan',
              file: normalize(path.relative(dir, target)),
              line: null,
              message: `frozen test imports "${sym}" but the implementation does not export it`,
              featureId: feature.id,
            });
          }
        }
      }
    }
  }

  return dedupe(findings);
}

function readDeclaredEnv(dir: string): Set<string> {
  const out = new Set<string>();
  const p = path.join(dir, '.env.example');
  if (fs.existsSync(p)) {
    for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
      const m = line.match(/^([A-Z0-9_]+)\s*=/);
      if (m) out.add(m[1]);
    }
  }
  return out;
}

function exportsSymbol(src: string, symbol: string): boolean {
  const direct = new RegExp(
    `export\\s+(async\\s+)?(function|const|let|var|class|interface|type|enum)\\s+${escapeRe(symbol)}\\b`,
  );
  if (direct.test(src)) return true;
  const named = /export\s*\{([^}]+)\}/g;
  let m: RegExpExecArray | null;
  while ((m = named.exec(src)) !== null) {
    const names = m[1].split(',').map(s => {
      const parts = s.trim().split(/\s+as\s+/);
      return (parts[1] ?? parts[0]).trim();
    });
    if (names.includes(symbol)) return true;
  }
  return false;
}

function escapeRe(s: string): string {
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function resolveRelative(root: string, fromRel: string, spec: string): string | null {
  const base = path.dirname(path.join(root, fromRel));
  const raw = path.resolve(base, spec);
  for (const cand of [raw, raw + '.ts', raw.replace(/\.js$/, '.ts'), path.join(raw, 'index.ts')]) {
    if (fs.existsSync(cand) && fs.statSync(cand).isFile()) return cand;
  }
  return null;
}

function dedupe(findings: Finding[]): Finding[] {
  const seen = new Set<string>();
  return findings.filter(f => {
    const key = `${f.bugClass}|${f.file}|${f.line}|${f.message}`;
    if (seen.has(key)) return false;
    seen.add(key);
    return true;
  });
}
