/**
 * Promote rules declared in an archived OpenSpec change into the catalog.
 *
 * Reads `openspec/changes/archive/<name>/proposal.md`, locates the
 * `## Business rules affected` section, and applies the declared actions:
 *
 *   - `mantiene BR-...`  → no-op (rule is referenced but unchanged)
 *   - `deroga BR-...`    → flip rule state to `derogated`, append a derogation
 *                          note that points back to the archive
 *   - `extiende BR-...`  → leave the rule active, mention the extension in its
 *                          "Reglas relacionadas" section
 *   - new `BR-...-NEW-N` → emit guidance for the user to add the new rule
 *                          (we don't auto-create skeletons because the per-rule
 *                          body is content-rich and benefits from human input)
 *
 * After applying changes, the index is regenerated.
 *
 * Usage:
 *   The script is invoked from the slash command /business-rules:promote
 *   (it is NOT exposed as `pnpm br:*` because the dev's flow always goes
 *   through Claude Code: the script applies the deterministic actions and
 *   Claude continues the session to fill the body of each new rule from
 *   code/tests/proposal in the same session).
 *
 *   Direct invocation (e.g. for tooling or debugging):
 *     npx tsx scripts/business-rules/promote.ts                          # most recent archive
 *     npx tsx scripts/business-rules/promote.ts 2026-05-03-foo           # specific archive
 *     npx tsx scripts/business-rules/promote.ts 2026-05-03-foo --dry-run # preview
 */
import { readFileSync, writeFileSync, readdirSync, existsSync, statSync } from 'node:fs';
import { resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import { findRepoRoot } from './parse-catalog';

type RuleAction =
  | { kind: 'mantiene'; id: string; title: string }
  | { kind: 'deroga'; id: string; title: string; reason?: string }
  | { kind: 'extiende'; id: string; title: string; relatesTo?: string }
  | {
      kind: 'nueva';
      boundedContext: string;
      aggregate: string;
      title: string;
      idPrefix?: string;
    };

const ACTION_KEYWORDS: Array<{ pattern: RegExp; kind: 'mantiene' | 'deroga' | 'extiende' }> = [
  { pattern: /\bderog/i, kind: 'deroga' },
  { pattern: /\bextiend/i, kind: 'extiende' },
  { pattern: /\bmantien/i, kind: 'mantiene' },
];

function findLatestArchive(repoRoot: string): string | null {
  const archiveDir = resolve(repoRoot, 'openspec/changes/archive');
  if (!existsSync(archiveDir)) return null;
  const entries = readdirSync(archiveDir)
    .filter((name) => statSync(resolve(archiveDir, name)).isDirectory())
    .sort();
  return entries.length > 0 ? entries[entries.length - 1] : null;
}

function extractAffectedSection(proposalContent: string): string | null {
  const match = proposalContent.match(
    /^##\s+Business rules affected[\s\S]*?(?=^##\s|\Z)/m,
  );
  return match ? match[0] : null;
}

function parseRuleActions(section: string): RuleAction[] {
  const actions: RuleAction[] = [];
  const lines = section.split('\n');
  for (const raw of lines) {
    const line = raw.trim();
    if (!line.startsWith('-') && !line.startsWith('*')) continue;

    // NEW rule format. Two accepted shapes (the path is mandatory):
    //   - en `<bc>/<aggregate>` — <title>
    //   - BR-<PREFIX> en `<bc>/<aggregate>` — <title>
    const newPathMatch = line.match(
      /(?:\b(BR-[A-Z0-9-]+)\s+)?en\s+`([a-z][a-z0-9-]*)\/([a-z][a-z0-9-]*)`/,
    );
    if (newPathMatch) {
      const idPrefix = newPathMatch[1];
      const boundedContext = newPathMatch[2];
      const aggregate = newPathMatch[3];
      const titleMatch = line.match(/—\s*(.+)$/);
      const title = titleMatch ? titleMatch[1].trim() : '';
      actions.push({
        kind: 'nueva',
        boundedContext,
        aggregate,
        title,
        idPrefix,
      });
      continue;
    }

    const idMatch = line.match(/\b(BR-[A-Z0-9-]+)\b/);
    if (!idMatch) continue;
    const id = idMatch[1];
    const titleMatch = line.match(/—\s*(.+)$/) || line.match(/-\s*(.+)$/);
    const title = titleMatch ? titleMatch[1].trim() : id;

    let kind: 'mantiene' | 'deroga' | 'extiende' = 'mantiene';
    for (const k of ACTION_KEYWORDS) {
      if (k.pattern.test(line)) {
        kind = k.kind;
        break;
      }
    }
    actions.push({ kind, id, title });
  }
  return actions;
}

interface CatalogIndex {
  byId: Record<
    string,
    {
      id: string;
      filePath: string;
      state: string;
      title: string;
      aggregates: string[];
    }
  >;
}

function loadIndex(repoRoot: string): CatalogIndex | null {
  const path = resolve(repoRoot, 'cliter/business-rules-index.json');
  if (!existsSync(path)) return null;
  return JSON.parse(readFileSync(path, 'utf-8')) as CatalogIndex;
}

function applyDerogation(
  filePath: string,
  ruleId: string,
  archiveName: string,
  today: string,
): boolean {
  let content = readFileSync(filePath, 'utf-8');
  const ruleHeadingRe = new RegExp(
    `^##\\s+${escapeRegex(ruleId)}\\s+—[^\\n]*$`,
    'm',
  );
  const match = content.match(ruleHeadingRe);
  if (!match || match.index === undefined) return false;
  const startIdx = match.index;
  const headingEnd = startIdx + match[0].length;
  const restAfterHeading = content.slice(headingEnd);
  const nextHeadingMatch = restAfterHeading.match(/^##\s/m);
  const sectionEnd =
    nextHeadingMatch && nextHeadingMatch.index !== undefined
      ? headingEnd + nextHeadingMatch.index
      : content.length;

  let section = content.slice(startIdx, sectionEnd);

  const stateLineRe = /^(\|\s*Estado\s*\|\s*)`active`/m;
  const proposedLineRe = /^(\|\s*Estado\s*\|\s*)`proposed`/m;
  if (stateLineRe.test(section)) {
    section = section.replace(stateLineRe, '$1`derogated`');
  } else if (proposedLineRe.test(section)) {
    section = section.replace(proposedLineRe, '$1`derogated`');
  } else {
    return false;
  }

  if (!/###\s+Derogación/i.test(section)) {
    const note = `\n### Derogación\n\nDerogada el ${today} a partir del cambio archivado \`openspec/changes/archive/${archiveName}/\`. La regla se conserva como entrada histórica; su identificador no se reutiliza.\n`;
    section = section.trimEnd() + '\n' + note;
  }

  content = content.slice(0, startIdx) + section + content.slice(sectionEnd);
  writeFileSync(filePath, content, 'utf-8');
  return true;
}

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

function extractIdsFromFile(filePath: string): string[] {
  const content = readFileSync(filePath, 'utf-8');
  const matches = content.matchAll(/^##\s+(BR-[A-Z0-9-]+)\s+—/gm);
  return Array.from(matches).map((m) => m[1]);
}

function inferIdPrefixFromFile(filePath: string): string | null {
  const ids = extractIdsFromFile(filePath);
  if (ids.length === 0) return null;
  const counts: Record<string, number> = {};
  for (const id of ids) {
    const parts = id.split('-');
    if (parts.length < 4) continue;
    const prefix = parts.slice(0, parts.length - 1).join('-');
    counts[prefix] = (counts[prefix] ?? 0) + 1;
  }
  let best: string | null = null;
  let bestCount = 0;
  for (const [prefix, count] of Object.entries(counts)) {
    if (count > bestCount) {
      best = prefix;
      bestCount = count;
    }
  }
  return best;
}

function nextSequentialId(
  prefix: string,
  reservedIds: Set<string>,
  index: CatalogIndex,
): string {
  const candidates = new Set<string>(reservedIds);
  for (const id of Object.keys(index.byId)) candidates.add(id);
  let max = 0;
  for (const id of candidates) {
    if (!id.startsWith(`${prefix}-`)) continue;
    const tail = id.slice(prefix.length + 1);
    const n = parseInt(tail, 10);
    if (!isNaN(n) && n > max) max = n;
  }
  const next = (max + 1).toString().padStart(3, '0');
  return `${prefix}-${next}`;
}

function applyNewRuleScaffold(
  repoRoot: string,
  action: Extract<RuleAction, { kind: 'nueva' }>,
  index: CatalogIndex,
  reservedIds: Set<string>,
  archiveName: string,
): { ok: true; ruleId: string; filePath: string } | { ok: false; reason: string } {
  const aggregateQualified = `${action.boundedContext}/${action.aggregate}`;
  const filePath = resolve(
    repoRoot,
    'cliter',
    action.boundedContext,
    'business-rules',
    `${action.aggregate}.md`,
  );

  if (!existsSync(filePath)) {
    return {
      ok: false,
      reason: `el archivo ${filePath} no existe; crea primero el .md del agregado (con frontmatter mínimo) o ejecuta /business-rules:document para crear el scaffold`,
    };
  }

  const prefix = action.idPrefix ?? inferIdPrefixFromFile(filePath);
  if (!prefix) {
    return {
      ok: false,
      reason: `no se pudo inferir el prefijo de ID porque no hay reglas previas en ${aggregateQualified}; declara el prefijo explícitamente en el proposal: \`- BR-<PREFIX> en \\\`${aggregateQualified}\\\` — ${action.title}\``,
    };
  }

  const ruleId = nextSequentialId(prefix, reservedIds, index);
  reservedIds.add(ruleId);

  const scaffold = [
    '',
    '---',
    '',
    `## ${ruleId} — ${action.title}`,
    '',
    '| Campo     | Valor                                                                  |',
    '| --------- | ---------------------------------------------------------------------- |',
    '| Estado    | `proposed`                                                             |',
    '| Severidad | `blocking` <!-- [VERIFICAR] ajustar a `informational` si aplica -->    |',
    `| Origen    | \`openspec/changes/archive/${archiveName}/proposal.md\`                |`,
    '',
    '### Enunciado',
    '',
    '<!-- [VERIFICAR] Redacta una o dos frases en español describiendo la regla. -->',
    '',
    '### Casos exhaustivos',
    '',
    '<!-- [VERIFICAR] Define las columnas y enumera los casos relevantes. -->',
    '',
    '| #   | <columna 1> | Resultado |',
    '| --- | ----------- | --------- |',
    '| 1   | ...         | ...       |',
    '',
    '### Implementa',
    '',
    '<!-- [VERIFICAR] Cita paths reales del código que implementan esta regla. -->',
    '',
    '- `backend/src/@app/<bc>/<agg>/.../<file>.ts:<line>` — <qué hace en el flujo>',
    '',
    '### Tests',
    '',
    '<!-- [VERIFICAR] Cita los tests linkeables. -->',
    '',
    '',
  ].join('\n');

  const content = readFileSync(filePath, 'utf-8');
  const trimmed = content.trimEnd();
  writeFileSync(filePath, trimmed + '\n' + scaffold, 'utf-8');

  return { ok: true, ruleId, filePath };
}

function regenerateIndex(repoRoot: string): { ok: boolean; output: string } {
  const result = spawnSync('pnpm', ['--silent', 'br:generate'], {
    cwd: repoRoot,
    encoding: 'utf-8',
  });
  return {
    ok: result.status === 0,
    output: (result.stdout ?? '') + (result.stderr ?? ''),
  };
}

interface PromoteSummary {
  archive: string;
  applied: { kind: string; id: string; note?: string }[];
  skipped: { id: string; reason: string }[];
  newRulesCreated: { ruleId: string; title: string; filePath: string }[];
  newRulesPending: { reason: string; line: string }[];
}

async function main(): Promise<void> {
  const args = process.argv.slice(2);
  const dryRun = args.includes('--dry-run');
  const archiveArg = args.find((a) => !a.startsWith('--'));
  const repoRoot = findRepoRoot();

  const archiveName = archiveArg ?? findLatestArchive(repoRoot);
  if (!archiveName) {
    console.error('✗ No se encontró ningún archive en `openspec/changes/archive/`.');
    process.exit(1);
  }

  const proposalPath = resolve(
    repoRoot,
    'openspec/changes/archive',
    archiveName,
    'proposal.md',
  );
  if (!existsSync(proposalPath)) {
    console.error(`✗ No existe ${proposalPath}.`);
    process.exit(1);
  }

  console.log(`› Promoting rules from archive: ${archiveName}`);
  if (dryRun) console.log('  (dry run — no se escribirán cambios)');

  const proposalContent = readFileSync(proposalPath, 'utf-8');
  const section = extractAffectedSection(proposalContent);
  if (!section) {
    console.log('  Sin sección "## Business rules affected" en el proposal. Nada que hacer.');
    return;
  }

  const actions = parseRuleActions(section);
  if (actions.length === 0) {
    console.log('  Sección encontrada pero sin acciones declaradas. Nada que hacer.');
    return;
  }

  const summary: PromoteSummary = {
    archive: archiveName,
    applied: [],
    skipped: [],
    newRulesCreated: [],
    newRulesPending: [],
  };

  const index = loadIndex(repoRoot);
  if (!index) {
    console.error(
      '✗ El índice del catálogo no existe. Ejecuta `pnpm br:generate` antes de promote.',
    );
    process.exit(1);
  }

  const today = new Date().toISOString().slice(0, 10);
  const reservedIds = new Set<string>();

  for (const action of actions) {
    if (action.kind === 'nueva') {
      if (dryRun) {
        summary.newRulesPending.push({
          reason: '(dry run) crearía scaffold',
          line: `${action.boundedContext}/${action.aggregate} — ${action.title}`,
        });
        continue;
      }
      const result = applyNewRuleScaffold(
        repoRoot,
        action,
        index,
        reservedIds,
        archiveName,
      );
      if (result.ok) {
        summary.newRulesCreated.push({
          ruleId: result.ruleId,
          title: action.title,
          filePath: result.filePath,
        });
      } else {
        summary.newRulesPending.push({
          reason: result.reason,
          line: `${action.boundedContext}/${action.aggregate} — ${action.title}`,
        });
      }
      continue;
    }

    const indexEntry = index.byId[action.id];
    if (!indexEntry) {
      summary.skipped.push({
        id: action.id,
        reason: `no encontrada en el catálogo`,
      });
      continue;
    }

    const filePath = resolve(repoRoot, indexEntry.filePath);

    if (action.kind === 'mantiene' || action.kind === 'extiende') {
      summary.applied.push({
        kind: action.kind,
        id: action.id,
        note:
          action.kind === 'extiende'
            ? 'sin cambios automáticos; revisar "Reglas relacionadas" manualmente'
            : 'sin cambios',
      });
      continue;
    }

    if (action.kind === 'deroga') {
      if (dryRun) {
        summary.applied.push({
          kind: 'deroga',
          id: action.id,
          note: `(dry run) marcaría como derogated en ${indexEntry.filePath}`,
        });
        continue;
      }
      const ok = applyDerogation(filePath, action.id, archiveName, today);
      if (ok) {
        summary.applied.push({
          kind: 'deroga',
          id: action.id,
          note: `marcada derogated en ${indexEntry.filePath}`,
        });
      } else {
        summary.skipped.push({
          id: action.id,
          reason: 'no se pudo localizar/modificar la fila Estado',
        });
      }
    }
  }

  if (
    !dryRun &&
    (summary.applied.some((a) => a.kind === 'deroga') ||
      summary.newRulesCreated.length > 0)
  ) {
    const regen = regenerateIndex(repoRoot);
    if (!regen.ok) {
      console.error('\n✗ La regeneración del índice falló:');
      console.error(regen.output);
      process.exit(1);
    }
  }

  console.log('\n=== Resumen ===');
  console.log(`Archive: ${summary.archive}`);
  console.log(`Acciones aplicadas: ${summary.applied.length}`);
  for (const a of summary.applied) {
    console.log(`  ✓ ${a.kind} ${a.id}${a.note ? ` — ${a.note}` : ''}`);
  }
  if (summary.skipped.length > 0) {
    console.log(`\nAcciones saltadas: ${summary.skipped.length}`);
    for (const s of summary.skipped) {
      console.log(`  - ${s.id}: ${s.reason}`);
    }
  }
  if (summary.newRulesCreated.length > 0) {
    console.log(
      `\nReglas nuevas creadas (state=proposed): ${summary.newRulesCreated.length}`,
    );
    console.log(
      '  Tienen scaffold completo con secciones marcadas [VERIFICAR]. Rellena',
    );
    console.log(
      '  el body (Enunciado, Casos exhaustivos, Implementa, Tests) y cuando',
    );
    console.log(
      '  esté listo cambia el Estado a `active`. Regenera con `pnpm br:generate`.',
    );
    for (const n of summary.newRulesCreated) {
      console.log(`  ✓ ${n.ruleId} — ${n.title}`);
      console.log(`    en ${n.filePath}`);
    }
  }

  if (summary.newRulesPending.length > 0) {
    console.log(
      `\nReglas nuevas no creadas: ${summary.newRulesPending.length}`,
    );
    for (const n of summary.newRulesPending) {
      console.log(`  - ${n.line}`);
      console.log(`    ${n.reason}`);
    }
  }
}

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