/**
 * Structural audit of the business-rules catalog.
 *
 * Sections produced by this script:
 *   A) Health      — counts, BCs covered, estimated catalog size in tokens.
 *   B) Drift       — Implementa paths missing, orphan/zombie citations.
 *   C) Assumptions — outstanding [VERIFICAR] / [ASUNCIÓN] markers older than 30 days.
 *   D) Cross-BC    — rules listing several bounded_contexts: verify pointer
 *                    presence in the non-owning BC catalog files.
 *
 * Section E (semantic tensions / pairwise analysis) is NOT produced here.
 * It is added by Claude continuing the session when the audit is invoked
 * via the slash command /business-rules:audit (script + LLM continuation
 * pattern, same as /business-rules:promote). Detection of contradictions
 * is also covered preventively in /opsx:propose via openspec/config.yaml.
 *
 * Usage:
 *   The script is invoked from the slash command /business-rules:audit
 *   (it is NOT exposed as `pnpm br:*` to keep the public API aligned with
 *   how the audit is actually consumed: the useful output is the integrated
 *   one with section E, which only the slash command produces).
 *
 *   Direct invocation (e.g. for tooling, debugging, future CI):
 *     npx tsx scripts/business-rules/audit.ts            # human-readable
 *     npx tsx scripts/business-rules/audit.ts --json     # JSON for tooling
 *
 * Exit codes:
 *   0 — clean
 *   2 — warnings present (drift, assumptions, missing pointers)
 */
import { readFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
import fastGlob from 'fast-glob';
import { parseCatalog, findRepoRoot } from './parse-catalog';
import { extractSection, hasGlobMetachars } from '../_catalog-runtime';
import type { CatalogIndex, ParsedFile } from './types';

const STALE_AGE_DAYS = 30;
const SIZE_INFO = 150;
const SIZE_WARN = 250;
const SIZE_SOFT_ERROR = 500;
const SIZE_REARCHITECT = 1000;
const TOKENS_PER_RULE_AVG = 700;

interface AuditReport {
  generatedAt: string;
  health: {
    totalFiles: number;
    activeRules: number;
    derogatedRules: number;
    proposedRules: number;
    totalRules: number;
    boundedContextsCovered: string[];
    estimatedTokens: number;
    sizeBucket: string;
  };
  drift: {
    missingImplementaPaths: Array<{ ruleId: string; path: string }>;
    orphanCitations: Array<{ ruleId: string; filePath: string; line: number }>;
    zombieRules: Array<{ ruleId: string; ageDays: number }>;
  };
  assumptions: Array<{ ruleId: string; marker: string; ageDays: number; file: string }>;
  crossBcMissingPointers: Array<{
    ruleId: string;
    boundedContext: string;
    expectedPointerIn: string;
  }>;
}

async function findCitations(
  repoRoot: string,
): Promise<Array<{ ruleId: string; filePath: string; line: number }>> {
  const patterns = ['backend/src/**/*.{ts,tsx}', 'frontend/src/**/*.{ts,tsx,html}'];
  const files = await fastGlob(patterns, {
    cwd: repoRoot,
    absolute: true,
    ignore: ['**/node_modules/**', '**/dist/**', '**/.angular/**'],
  });
  const pattern = /@rule\s+(BR-[A-Z0-9-]+)/g;
  const out: Array<{ ruleId: string; filePath: string; line: number }> = [];
  for (const filePath of files) {
    let content: string;
    try {
      content = readFileSync(filePath, 'utf-8');
    } catch {
      continue;
    }
    if (!content.includes('@rule')) continue;
    const lines = content.split('\n');
    for (let i = 0; i < lines.length; i++) {
      for (const m of lines[i].matchAll(pattern)) {
        out.push({ ruleId: m[1], filePath, line: i + 1 });
      }
    }
  }
  return out;
}

function daysSince(iso: string): number {
  const t = new Date(iso).getTime();
  return Math.floor((Date.now() - t) / 86400000);
}

function classifySize(activeRules: number): string {
  if (activeRules >= SIZE_REARCHITECT) return 'REARCHITECT';
  if (activeRules >= SIZE_SOFT_ERROR) return 'SOFT_ERROR';
  if (activeRules >= SIZE_WARN) return 'WARN';
  if (activeRules >= SIZE_INFO) return 'INFO';
  return 'OK';
}

const ASSUMPTION_MARKER = /\[(VERIFICAR|ASUNCIÓN)[^\]]*\]/g;

function extractAssumptions(
  parsed: ParsedFile[],
): Array<{ ruleId: string; marker: string; ageDays: number; file: string }> {
  const out: Array<{ ruleId: string; marker: string; ageDays: number; file: string }> = [];
  for (const file of parsed) {
    const fileAge = daysSince(file.frontmatter.last_updated);
    if (fileAge < STALE_AGE_DAYS) continue;
    for (const rule of file.rules) {
      for (const m of rule.body.matchAll(ASSUMPTION_MARKER)) {
        out.push({
          ruleId: rule.id,
          marker: m[0],
          ageDays: fileAge,
          file: file.relativePath,
        });
      }
    }
  }
  return out;
}

function detectCrossBcMissingPointers(parsed: ParsedFile[]): Array<{
  ruleId: string;
  boundedContext: string;
  expectedPointerIn: string;
}> {
  const issues: Array<{
    ruleId: string;
    boundedContext: string;
    expectedPointerIn: string;
  }> = [];
  for (const file of parsed) {
    if (file.frontmatter.bounded_contexts.length <= 1) continue;
    const ownerBc = file.frontmatter.bounded_contexts[0];
    for (const rule of file.rules) {
      for (const bc of file.frontmatter.bounded_contexts.slice(1)) {
        const found = parsed.some(
          (other) =>
            other !== file &&
            other.frontmatter.bounded_contexts.includes(bc) &&
            other.frontmatter.bounded_contexts[0] === bc &&
            new RegExp(`\\b${rule.id}\\b`).test(readFileSync(other.filePath, 'utf-8')),
        );
        if (!found) {
          issues.push({
            ruleId: rule.id,
            boundedContext: bc,
            expectedPointerIn: `cliter/${bc}/business-rules/<some-aggregate>.md`,
          });
        }
      }
      void ownerBc;
    }
  }
  return issues;
}

async function main(): Promise<void> {
  const args = process.argv.slice(2);
  const asJson = args.includes('--json');
  const repoRoot = findRepoRoot();

  const indexPath = resolve(repoRoot, 'cliter/business-rules-index.json');
  if (!existsSync(indexPath)) {
    console.error('✗ No existe el índice. Ejecuta `pnpm br:generate` primero.');
    process.exit(1);
  }
  const index = JSON.parse(readFileSync(indexPath, 'utf-8')) as CatalogIndex;
  const parsed = await parseCatalog({ repoRoot });

  const citations = await findCitations(repoRoot);
  const cited = new Set(citations.map((c) => c.ruleId));

  const orphanCitations = citations.filter((c) => !index.byId[c.ruleId]);
  const zombieRules: Array<{ ruleId: string; ageDays: number }> = [];
  for (const rule of Object.values(index.byId)) {
    if (rule.state !== 'active') continue;
    if (cited.has(rule.id)) continue;
    const age = daysSince(rule.last_updated);
    if (age >= STALE_AGE_DAYS) zombieRules.push({ ruleId: rule.id, ageDays: age });
  }

  const missingImplementaPaths: Array<{ ruleId: string; path: string }> = [];
  const pathPattern = /`(backend|frontend|cliter|scripts|docs)\/[^`]+`/g;
  for (const file of parsed) {
    for (const rule of file.rules) {
      // Shared, line-anchored section extraction (fix #8): `### Implementa` no
      // longer mis-bounds `### Implementación`.
      const section = extractSection(rule.body, '### Implementa');
      if (section === null) continue;
      for (const m of section.matchAll(pathPattern)) {
        const candidate = m[0].replace(/`/g, '').split(':')[0];
        // Skip glob patterns; only concrete paths are existence-checked (fix #9).
        if (hasGlobMetachars(candidate)) continue;
        if (!existsSync(resolve(repoRoot, candidate))) {
          missingImplementaPaths.push({ ruleId: rule.id, path: candidate });
        }
      }
    }
  }

  const assumptions = extractAssumptions(parsed);
  const crossBcMissingPointers = detectCrossBcMissingPointers(parsed);

  const boundedContextsCovered = Object.keys(index.byBoundedContext).sort();
  const estimatedTokens = index.totalRules * TOKENS_PER_RULE_AVG;
  const sizeBucket = classifySize(index.activeRules);

  const report: AuditReport = {
    generatedAt: new Date().toISOString(),
    health: {
      totalFiles: index.totalFiles,
      activeRules: index.activeRules,
      derogatedRules: index.derogatedRules,
      proposedRules: index.proposedRules,
      totalRules: index.totalRules,
      boundedContextsCovered,
      estimatedTokens,
      sizeBucket,
    },
    drift: {
      missingImplementaPaths,
      orphanCitations,
      zombieRules,
    },
    assumptions,
    crossBcMissingPointers,
  };

  if (asJson) {
    console.log(JSON.stringify(report, null, 2));
  } else {
    printHumanReport(report);
  }

  const hasWarnings =
    missingImplementaPaths.length > 0 ||
    orphanCitations.length > 0 ||
    zombieRules.length > 0 ||
    assumptions.length > 0 ||
    crossBcMissingPointers.length > 0 ||
    sizeBucket !== 'OK';

  process.exit(hasWarnings ? 2 : 0);
}

function printHumanReport(report: AuditReport): void {
  const h = report.health;
  console.log('=== Business rules — audit ===');
  console.log(`Generado: ${report.generatedAt}\n`);

  console.log('A) SALUD');
  console.log(`  Reglas activas:    ${h.activeRules}`);
  console.log(`  Reglas derogadas:  ${h.derogatedRules}`);
  console.log(`  Reglas propuestas: ${h.proposedRules}`);
  console.log(`  Total:             ${h.totalRules} en ${h.totalFiles} archivos`);
  console.log(`  BCs cubiertos:     ${h.boundedContextsCovered.join(', ') || '(ninguno)'}`);
  console.log(`  Tamaño estimado:   ~${h.estimatedTokens.toLocaleString()} tokens`);
  console.log(`  Bucket de tamaño:  ${h.sizeBucket}`);
  if (h.sizeBucket !== 'OK') {
    console.log(
      `  ⚠ Considera filtrado por BC en propose y revisión de la arquitectura del catálogo.`,
    );
  }

  console.log('\nB) DRIFT');
  if (report.drift.missingImplementaPaths.length === 0) {
    console.log('  ✓ Sin paths inexistentes en `Implementa`.');
  } else {
    console.log(`  ⚠ Paths inexistentes (${report.drift.missingImplementaPaths.length}):`);
    for (const x of report.drift.missingImplementaPaths) {
      console.log(`    - [${x.ruleId}] ${x.path}`);
    }
  }
  if (report.drift.orphanCitations.length === 0) {
    console.log('  ✓ Sin citaciones huérfanas.');
  } else {
    console.log(`  ⚠ Citaciones huérfanas (${report.drift.orphanCitations.length}):`);
    for (const x of report.drift.orphanCitations) {
      console.log(`    - ${x.ruleId} en ${x.filePath}:${x.line}`);
    }
  }
  if (report.drift.zombieRules.length === 0) {
    console.log('  ✓ Sin reglas zombi.');
  } else {
    console.log(`  ⚠ Reglas zombi (${report.drift.zombieRules.length}):`);
    for (const x of report.drift.zombieRules) {
      console.log(`    - ${x.ruleId} (${x.ageDays} días sin citas)`);
    }
  }

  console.log('\nC) ASUNCIONES PENDIENTES');
  if (report.assumptions.length === 0) {
    console.log('  ✓ Sin asunciones antiguas.');
  } else {
    console.log(`  ⚠ Marcadores con > ${STALE_AGE_DAYS} días (${report.assumptions.length}):`);
    for (const x of report.assumptions) {
      console.log(`    - [${x.ruleId}] ${x.marker} (${x.ageDays} días) en ${x.file}`);
    }
  }

  console.log('\nD) CROSS-BC POINTERS');
  if (report.crossBcMissingPointers.length === 0) {
    console.log('  ✓ Todas las reglas cross-BC tienen apuntadores.');
  } else {
    console.log(`  ⚠ Apuntadores faltantes (${report.crossBcMissingPointers.length}):`);
    for (const x of report.crossBcMissingPointers) {
      console.log(`    - ${x.ruleId} no aparece en ${x.expectedPointerIn}`);
    }
  }

  console.log(
    '\nNota: este script ha cubierto la parte estructural del audit. Para incluir',
  );
  console.log(
    '      el análisis semántico (tensiones latentes entre reglas), invoca el slash',
  );
  console.log(
    '      command /business-rules:audit en Claude Code: corre este script y luego',
  );
  console.log(
    '      Claude continúa la sesión con el análisis pairwise (sección E del reporte).',
  );
  console.log('');
}

main().catch((err) => {
  console.error('✗ audit falló:');
  console.error(err.message ?? err);
  process.exit(1);
});
