#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { spawnSync } from "node:child_process";

/**
 * apply-ticket — applique une proposition de correction depuis un ticket Gitea
 *
 * Conçu pour:
 * - prendre un ticket [Correction]/[Fact-check] (issue) avec Chemin + Ancre + Proposition
 * - retrouver le bon paragraphe dans le .mdx/.md
 * - remplacer proprement
 * - ne JAMAIS toucher au frontmatter
 * - optionnel: écrire un alias d’ancre old->new (build-time) dans src/anchors/anchor-aliases.json
 * - optionnel: committer automatiquement
 * - optionnel: fermer le ticket (après commit)
 */

function usage(exitCode = 0) {
  console.log(`
apply-ticket — applique une proposition de correction depuis un ticket Gitea (robuste)

Usage:
  node scripts/apply-ticket.mjs <issue_number> [--dry-run] [--no-build] [--alias] [--commit] [--close]

Flags:
  --dry-run   : ne modifie rien, affiche BEFORE/AFTER
  --no-build  : n'exécute pas "npm run build" (INCOMPATIBLE avec --alias)
  --alias     : après application, ajoute l'alias d'ancre (old -> new) dans src/anchors/anchor-aliases.json
  --commit    : git add + git commit automatiquement (inclut alias si --alias)
  --close     : ferme automatiquement le ticket après commit (+ commentaire avec SHA)

Env (recommandé):
  FORGE_API   = base API (LAN) ex: http://192.168.1.20:3000
  FORGE_BASE  = base web ex: https://gitea.xxx.tld  (fallback si FORGE_API absent)
  FORGE_TOKEN = PAT (accès repo + issues)
  GITEA_OWNER = owner (optionnel si auto-détecté depuis git remote)
  GITEA_REPO  = repo  (optionnel si auto-détecté depuis git remote)

Notes:
  - Si dist/<chemin>/index.html est absent, le script lance "npm run build" sauf si --no-build.
  - Sauvegarde automatique: .tmp/apply-ticket/<fichier>.bak.issue-<N> (uniquement si on écrit)
  - Avec --alias : le script rebuild pour identifier le NOUVEL id, puis écrit l'alias old->new.
  - Refuse automatiquement les Pull Requests (PR) : ce ne sont pas des tickets éditoriaux.
`);
  process.exit(exitCode);
}

const argv = process.argv.slice(2);
if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) usage(0);

const issueNum = Number(argv[0]);
if (!Number.isFinite(issueNum) || issueNum <= 0) {
  console.error("❌ Numéro de ticket invalide.");
  usage(1);
}

const DRY_RUN = argv.includes("--dry-run");
const NO_BUILD = argv.includes("--no-build");
const DO_ALIAS = argv.includes("--alias");
const DO_COMMIT = argv.includes("--commit");
const DO_CLOSE = argv.includes("--close");

if (DO_ALIAS && NO_BUILD) {
  console.error("❌ --alias est incompatible avec --no-build (risque d'alias faux).");
  console.error("➡️  Relance sans --no-build.");
  process.exit(1);
}

if (DRY_RUN && (DO_ALIAS || DO_COMMIT || DO_CLOSE)) {
  console.warn("ℹ️ --dry-run : --alias/--commit/--close sont ignorés (aucune écriture).");
}

if (DO_CLOSE && DRY_RUN) {
  console.error("❌ --close est incompatible avec --dry-run.");
  process.exit(1);
}
if (DO_CLOSE && !DO_COMMIT) {
  console.error("❌ --close nécessite --commit (on ne ferme jamais un ticket sans commit).");
  process.exit(1);
}

if (typeof fetch !== "function") {
  console.error("❌ fetch() indisponible dans ce Node. Utilise Node 18+ (ou plus).");
  process.exit(1);
}

const CWD = process.cwd();
const CONTENT_ROOT = path.join(CWD, "src", "content");
const DIST_ROOT = path.join(CWD, "dist");
const ALIASES_FILE = path.join(CWD, "src", "anchors", "anchor-aliases.json");
const BACKUP_ROOT = path.join(CWD, ".tmp", "apply-ticket");

/* -------------------------- utils texte / matching -------------------------- */

function normalizeText(s) {
  return String(s ?? "")
    .normalize("NFKD")
    .replace(/\p{Diacritic}/gu, "")
    .replace(/[’‘]/g, "'")
    .replace(/[“”]/g, '"')
    .replace(/[–—]/g, "-")
    .replace(/…/g, "...")
    .replace(/\s+/g, " ")
    .trim()
    .toLowerCase();
}

// stripping très pragmatique
function stripMd(mdx) {
  let s = String(mdx ?? "");
  s = s.replace(/`[^`]*`/g, " "); // inline code
  s = s.replace(/!\[[^\]]*\]\([^)]+\)/g, " "); // images
  s = s.replace(/\[[^\]]*\]\([^)]+\)/g, " "); // links
  s = s.replace(/[*_~]/g, " "); // emphasis-ish
  s = s.replace(/<[^>]+>/g, " "); // html tags
  s = s.replace(/\s+/g, " ").trim();
  return s;
}

function tokenize(s) {
  const n = normalizeText(stripMd(s));
  return n
    .replace(/[^a-z0-9'\- ]+/g, " ")
    .split(" ")
    .filter((w) => w.length >= 4);
}

function scoreText(candidate, targetText) {
  const tgt = tokenize(targetText);
  const blk = tokenize(candidate);
  if (!tgt.length || !blk.length) return 0;

  const tgtSet = new Set(tgt);
  const blkSet = new Set(blk);

  let hit = 0;
  for (const w of tgtSet) if (blkSet.has(w)) hit++;

  const tgtNorm = normalizeText(stripMd(targetText));
  const blkNorm = normalizeText(stripMd(candidate));
  const prefix = tgtNorm.slice(0, Math.min(180, tgtNorm.length));
  const prefixBonus = prefix && blkNorm.includes(prefix) ? 1000 : 0;

  const ratio = hit / Math.max(1, tgtSet.size);
  const ratioBonus = Math.round(ratio * 100);

  return prefixBonus + hit + ratioBonus;
}

function rankedBlockMatches(blocks, targetText, limit = 5) {
  return blocks
    .map((b, i) => ({
      i,
      score: scoreText(b, targetText),
      excerpt: stripMd(b).slice(0, 140),
    }))
    .sort((a, b) => b.score - a.score)
    .slice(0, limit);
}

function isLikelyExcerpt(s) {
  const t = String(s || "").trim();
  if (!t) return true;
  if (t.length < 120) return true;
  if (/[.…]$/.test(t)) return true;
  if (normalizeText(t).includes("tronqu")) return true;
  return false;
}

/* --------------------------- frontmatter / structure ------------------------ */

function normalizeNewlines(s) {
  return String(s ?? "").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
}

function splitMdxFrontmatter(src) {
  const text = normalizeNewlines(src);
  const m = text.match(/^---\n[\s\S]*?\n---\n?/);

  if (!m) {
    return {
      hasFrontmatter: false,
      frontmatter: "",
      body: text,
    };
  }

  const frontmatter = m[0];
  const body = text.slice(frontmatter.length);

  return {
    hasFrontmatter: true,
    frontmatter,
    body,
  };
}

function joinMdxFrontmatter(frontmatter, body) {
  if (!frontmatter) return String(body ?? "");
  return String(frontmatter) + String(body ?? "");
}

function assertFrontmatterIntegrity({ hadFrontmatter, originalFrontmatter, finalText, filePath }) {
  if (!hadFrontmatter) return;

  const text = normalizeNewlines(finalText);

  if (!text.startsWith("---\n")) {
    throw new Error(`Frontmatter perdu pendant la mise à jour de ${filePath}`);
  }

  if (!text.startsWith(originalFrontmatter)) {
    throw new Error(`Frontmatter altéré pendant la mise à jour de ${filePath}`);
  }
}

function splitParagraphBlocksPreserve(bodyText) {
  const text = normalizeNewlines(bodyText);

  if (!text) {
    return { blocks: [], separators: [] };
  }

  const blocks = [];
  const separators = [];

  const re = /(\n{2,})/g;
  let last = 0;
  let m;

  while ((m = re.exec(text))) {
    blocks.push(text.slice(last, m.index));
    separators.push(m[1]);
    last = m.index + m[1].length;
  }

  blocks.push(text.slice(last));

  return { blocks, separators };
}

function joinParagraphBlocksPreserve(blocks, separators) {
  if (!Array.isArray(blocks) || blocks.length === 0) return "";

  let out = "";
  for (let i = 0; i < blocks.length; i++) {
    out += blocks[i];
    if (i < separators.length) out += separators[i];
  }
  return out;
}

/* ------------------------------ utils système ------------------------------ */

function run(cmd, args, opts = {}) {
  const r = spawnSync(cmd, args, { stdio: "inherit", ...opts });
  if (r.error) throw r.error;
  if (r.status !== 0) throw new Error(`Command failed: ${cmd} ${args.join(" ")}`);
}

function runQuiet(cmd, args, opts = {}) {
  const r = spawnSync(cmd, args, { encoding: "utf8", stdio: "pipe", ...opts });
  if (r.error) throw r.error;
  if (r.status !== 0) {
    const out = (r.stdout || "") + (r.stderr || "");
    throw new Error(`Command failed: ${cmd} ${args.join(" ")}\n${out}`);
  }
  return r.stdout || "";
}

async function fileExists(p) {
  try {
    await fs.access(p);
    return true;
  } catch {
    return false;
  }
}

function getEnv(name, fallback = "") {
  return (process.env[name] ?? fallback).trim();
}

function inferOwnerRepoFromGit() {
  const r = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8" });
  if (r.status !== 0) return null;
  const u = (r.stdout || "").trim();
  const m = u.match(/[:/](?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/);
  if (!m?.groups) return null;
  return { owner: m.groups.owner, repo: m.groups.repo };
}

function gitHasStagedChanges() {
  const r = spawnSync("git", ["diff", "--cached", "--quiet"]);
  return r.status === 1;
}

/* ------------------------------ parsing ticket ----------------------------- */

function escapeRegExp(s) {
  return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function pickLine(body, key) {
  const re = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:\\s*([^\\n\\r]+)`, "mi");
  const m = String(body || "").match(re);
  return m ? m[1].trim() : "";
}

function pickHeadingValue(body, headingKey) {
  const re = new RegExp(
    `^##\\s*${escapeRegExp(headingKey)}[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|\\n\\s*$)`,
    "mi"
  );
  const m = String(body || "").match(re);
  if (!m) return "";
  const lines = m[1].split(/\r?\n/).map((l) => l.trim());
  for (const l of lines) {
    if (!l) continue;
    if (l.startsWith("<!--")) continue;
    return l.replace(/^\/?/, "/").trim();
  }
  return "";
}

function pickSection(body, markers) {
  const text = String(body || "").replace(/\r\n/g, "\n");
  const idx = markers
    .map((m) => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) }))
    .filter((x) => x.i >= 0)
    .sort((a, b) => a.i - b.i)[0];

  if (!idx) return "";

  const start = idx.i + idx.m.length;
  const tail = text.slice(start);

  const stops = [
    "\n## ",
    "\nJustification",
    "\n---",
    "\n## Justification",
    "\n## Sources",
    "\nProblème identifié",
    "\nSources proposées",
    "\n## Proposition",
    "\n## Problème",
  ];

  let end = tail.length;
  for (const s of stops) {
    const j = tail.toLowerCase().indexOf(s.toLowerCase());
    if (j >= 0 && j < end) end = j;
  }

  return tail.slice(0, end).trim();
}

function unquoteBlock(s) {
  return String(s ?? "")
    .split(/\r?\n/)
    .map((l) => l.replace(/^\s*>\s?/, ""))
    .join("\n")
    .trim();
}

function normalizeChemin(chemin) {
  let c = String(chemin || "").trim();
  if (!c) return "";
  if (!c.startsWith("/")) c = "/" + c;
  if (!c.endsWith("/")) c = c + "/";
  return c;
}

function extractAnchorIdAnywhere(text) {
  const s = String(text || "");
  const m = s.match(/#?(p-\d+-[0-9a-f]{8})/i);
  return m ? m[1] : "";
}

function extractCheminFromAnyUrl(text) {
  const s = String(text || "");
  const m = s.match(/(\/[a-z0-9\-]+\/[a-z0-9\-\/]+\/)#p-\d+-[0-9a-f]{8}/i);
  return m ? m[1] : "";
}

/* --------------------------- lecture HTML paragraphe ------------------------ */

function cleanHtmlInner(inner) {
  let s = String(inner ?? "");
  s = s.replace(
    /<span[^>]*class=["'][^"']*para-tools[^"']*["'][^>]*>[\s\S]*?<\/span>/gi,
    " "
  );
  s = s.replace(/<[^>]+>/g, " ");
  s = s.replace(/\s+/g, " ").trim();
  s = s.replace(/\b(¶|Citer|Proposer|Copié)\b/gi, "").replace(/\s+/g, " ").trim();
  return s;
}

async function readHtmlParagraphText(htmlPath, anchorId) {
  const html = await fs.readFile(htmlPath, "utf-8");
  const re = new RegExp(
    `<p[^>]*\\bid=["']${escapeRegExp(anchorId)}["'][^>]*>([\\s\\S]*?)<\\/p>`,
    "i"
  );
  const m = html.match(re);
  if (!m) return "";
  return cleanHtmlInner(m[1]);
}

async function readAllHtmlParagraphs(htmlPath) {
  const html = await fs.readFile(htmlPath, "utf-8");
  const out = [];
  const re = /<p\b[^>]*\sid=["'](p-\d+-[0-9a-f]{8})["'][^>]*>([\s\S]*?)<\/p>/gi;
  let m;
  while ((m = re.exec(html))) {
    out.push({ id: m[1], text: cleanHtmlInner(m[2]) });
  }
  return out;
}

/* --------------------------- localisation fichier contenu ------------------- */

async function findContentFileFromChemin(chemin) {
  const clean = normalizeChemin(chemin).replace(/^\/+|\/+$/g, "");
  const parts = clean.split("/").filter(Boolean);
  if (parts.length < 2) return null;

  const collection = parts[0];
  const slugPath = parts.slice(1).join("/");

  const root = path.join(CONTENT_ROOT, collection);
  if (!(await fileExists(root))) return null;

  const exts = [".mdx", ".md"];

  async function walk(dir) {
    const entries = await fs.readdir(dir, { withFileTypes: true });
    for (const e of entries) {
      const full = path.join(dir, e.name);
      if (e.isDirectory()) {
        const r = await walk(full);
        if (r) return r;
      } else {
        for (const ext of exts) {
          if (e.name.endsWith(ext)) {
            const rel = path.relative(root, full).replace(/\\/g, "/");
            const relNoExt = rel.slice(0, -ext.length);
            if (relNoExt === slugPath) return full;
          }
        }
      }
    }
    return null;
  }

  return await walk(root);
}

/* -------------------------------- build helper ----------------------------- */

async function ensureBuildIfNeeded(distHtmlPath) {
  if (NO_BUILD) return;
  if (await fileExists(distHtmlPath)) return;

  console.log("ℹ️  dist manquant pour cette page → build (npm run build) …");
  run("npm", ["run", "build"], { cwd: CWD });

  if (!(await fileExists(distHtmlPath))) {
    throw new Error(`dist toujours introuvable après build: ${distHtmlPath}`);
  }
}

/* ----------------------------- API Gitea helpers --------------------------- */

async function fetchIssue({ forgeApiBase, owner, repo, token, issueNum }) {
  const url = `${forgeApiBase.replace(/\/+$/, "")}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`;
  const res = await fetch(url, {
    headers: {
      Authorization: `token ${token}`,
      Accept: "application/json",
      "User-Agent": "archicratie-apply-ticket/2.1",
    },
  });
  if (!res.ok) {
    const t = await res.text().catch(() => "");
    throw new Error(`HTTP ${res.status} fetching issue: ${url}\n${t}`);
  }
  return await res.json();
}

async function closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment }) {
  const base = forgeApiBase.replace(/\/+$/, "");
  const headers = {
    Authorization: `token ${token}`,
    Accept: "application/json",
    "Content-Type": "application/json",
    "User-Agent": "archicratie-apply-ticket/2.1",
  };

  if (comment) {
    const urlC = `${base}/api/v1/repos/${owner}/${repo}/issues/${issueNum}/comments`;
    await fetch(urlC, { method: "POST", headers, body: JSON.stringify({ body: comment }) });
  }

  const url = `${base}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`;
  const res = await fetch(url, {
    method: "PATCH",
    headers,
    body: JSON.stringify({ state: "closed" }),
  });

  if (!res.ok) {
    const t = await res.text().catch(() => "");
    throw new Error(`HTTP ${res.status} closing issue: ${url}\n${t}`);
  }
}

/* ------------------------------ Aliases helpers ---------------------------- */

async function loadAliases() {
  try {
    const s = await fs.readFile(ALIASES_FILE, "utf8");
    const obj = JSON.parse(s);
    return obj && typeof obj === "object" ? obj : {};
  } catch {
    return {};
  }
}

function sortObjectKeys(obj) {
  return Object.fromEntries(Object.keys(obj).sort().map((k) => [k, obj[k]]));
}

async function saveAliases(obj) {
  let out = obj || {};
  for (const k of Object.keys(out)) {
    if (out[k] && typeof out[k] === "object") out[k] = sortObjectKeys(out[k]);
  }
  out = sortObjectKeys(out);

  await fs.mkdir(path.dirname(ALIASES_FILE), { recursive: true });
  await fs.writeFile(ALIASES_FILE, JSON.stringify(out, null, 2) + "\n", "utf8");
}

async function upsertAlias({ chemin, oldId, newId }) {
  const route = normalizeChemin(chemin);
  if (!oldId || !newId) throw new Error("Alias: oldId/newId requis");
  if (oldId === newId) return { changed: false, reason: "same" };

  const data = await loadAliases();
  if (!data[route]) data[route] = {};

  const prev = data[route][oldId];
  if (prev && prev !== newId) {
    throw new Error(
      `Alias conflict: ${route}${oldId} already mapped to ${prev} (new=${newId})`
    );
  }
  if (prev === newId) return { changed: false, reason: "already" };

  data[route][oldId] = newId;
  await saveAliases(data);
  return { changed: true, reason: "written" };
}

async function computeNewIdFromDistByContent(distHtmlPath, afterBlock) {
  const paras = await readAllHtmlParagraphs(distHtmlPath);
  if (!paras.length) throw new Error(`Aucun <p id="p-..."> trouvé dans ${distHtmlPath}`);

  let best = { id: null, score: -1 };
  const target = stripMd(afterBlock).slice(0, 1200);

  for (const p of paras) {
    const sc = scoreText(p.text, target);
    if (sc > best.score) best = { id: p.id, score: sc };
  }

  if (!best.id || best.score < 60) {
    throw new Error(
      `Impossible d'identifier le nouvel id dans dist (score trop faible: ${best.score}).\n` +
        `➡️ Vérifie que la proposition correspond bien à UN paragraphe.`
    );
  }

  return best.id;
}

/* ----------------------------------- MAIN ---------------------------------- */

async function main() {
  const token = getEnv("FORGE_TOKEN");
  if (!token) {
    console.error("❌ FORGE_TOKEN manquant (PAT). Ex: export FORGE_TOKEN='...'");
    process.exit(1);
  }

  const inferred = inferOwnerRepoFromGit() || {};
  const owner = getEnv("GITEA_OWNER", inferred.owner || "");
  const repo = getEnv("GITEA_REPO", inferred.repo || "");
  if (!owner || !repo) {
    console.error("❌ Impossible de déterminer owner/repo. Fix: export GITEA_OWNER=... GITEA_REPO=...");
    process.exit(1);
  }

  const forgeApiBase = getEnv("FORGE_API") || getEnv("FORGE_BASE");
  if (!forgeApiBase) {
    console.error("❌ FORGE_API ou FORGE_BASE manquant. Ex: export FORGE_API='http://192.168.1.20:3000'");
    process.exit(1);
  }

  console.log(`🔎 Fetch ticket #${issueNum} from ${owner}/${repo} …`);
  const issue = await fetchIssue({ forgeApiBase, owner, repo, token, issueNum });

  if (issue?.pull_request) {
    console.error(`❌ #${issueNum} est une Pull Request (demande d’ajout), pas un ticket éditorial.`);
    console.error("➡️ Ouvre un ticket [Correction]/[Fact-check] depuis le site (Proposer), puis relance apply-ticket sur ce numéro.");
    process.exit(2);
  }

  const body = String(issue.body || "").replace(/\r\n/g, "\n");
  const title = String(issue.title || "");

  let chemin =
    pickLine(body, "Chemin") ||
    pickHeadingValue(body, "Chemin") ||
    extractCheminFromAnyUrl(body) ||
    extractCheminFromAnyUrl(title);

  let ancre =
    pickLine(body, "Ancre") ||
    pickHeadingValue(body, "Ancre paragraphe") ||
    pickHeadingValue(body, "Ancre");

  ancre = (ancre || "").trim();
  if (ancre.startsWith("#")) ancre = ancre.slice(1);

  if (!ancre) ancre = extractAnchorIdAnywhere(title) || extractAnchorIdAnywhere(body);

  chemin = normalizeChemin(chemin);

  const currentFull = pickSection(body, [
    "Texte actuel (copie exacte du paragraphe)",
    "## Texte actuel (copie exacte du paragraphe)",
  ]);
  const currentEx = pickSection(body, [
    "Texte actuel (extrait)",
    "## Assertion / passage à vérifier",
    "Assertion / passage à vérifier",
  ]);
  const texteActuel = unquoteBlock(currentFull || currentEx);

  const prop1 = pickSection(body, [
    "Proposition (texte corrigé complet)",
    "## Proposition (texte corrigé complet)",
  ]);
  const prop2 = pickSection(body, [
    "Proposition (remplacer par):",
    "## Proposition (remplacer par)",
  ]);
  const proposition = (prop1 || prop2).trim();

  if (!chemin) throw new Error("Ticket: Chemin introuvable dans le body.");
  if (!ancre) throw new Error("Ticket: Ancre introuvable dans le body.");
  if (!proposition) throw new Error("Ticket: Proposition introuvable dans le body.");

  console.log("✅ Parsed:", { chemin, ancre: `#${ancre}`, hasTexteActuel: Boolean(texteActuel) });

  const contentFile = await findContentFileFromChemin(chemin);
  if (!contentFile) throw new Error(`Fichier contenu introuvable pour Chemin=${chemin}`);
  console.log(`📄 Target content file: ${path.relative(CWD, contentFile)}`);

  const distHtmlPath = path.join(DIST_ROOT, chemin.replace(/^\/+|\/+$/g, ""), "index.html");
  await ensureBuildIfNeeded(distHtmlPath);

  let targetText = texteActuel;
  let distText = "";

  if (await fileExists(distHtmlPath)) {
    distText = await readHtmlParagraphText(distHtmlPath, ancre);
  }

  if (!targetText && distText) targetText = distText;
  if (targetText && distText && isLikelyExcerpt(targetText) && distText.length > targetText.length) {
    targetText = distText;
  }

  if (!targetText) {
    throw new Error("Impossible de reconstruire le texte du paragraphe (ni texte actuel, ni dist html).");
  }

  const originalRaw = await fs.readFile(contentFile, "utf-8");
  const { hasFrontmatter, frontmatter, body: originalBody } = splitMdxFrontmatter(originalRaw);

  const split = splitParagraphBlocksPreserve(originalBody);
  const blocks = split.blocks;
  const separators = split.separators;

  if (!blocks.length) {
    throw new Error(`Aucun bloc éditorial exploitable dans ${path.relative(CWD, contentFile)}`);
  }

  const ranked = rankedBlockMatches(blocks, targetText, 5);
  const best = ranked[0] || { i: -1, score: -1, excerpt: "" };
  const runnerUp = ranked[1] || null;

  if (best.i < 0 || best.score < 40) {
    console.error("❌ Match trop faible: je refuse de remplacer automatiquement.");
    console.error(`➡️  Score=${best.score}. Recommandation: ticket avec 'Texte actuel (copie exacte du paragraphe)'.`);
    console.error("Top candidates:");
    for (const r of ranked) {
      console.error(`  #${r.i + 1} score=${r.score}  ${r.excerpt}${r.excerpt.length >= 140 ? "…" : ""}`);
    }
    process.exit(2);
  }

  if (runnerUp) {
    const ambiguityGap = best.score - runnerUp.score;
    if (ambiguityGap < 15) {
      console.error("❌ Match ambigu: le meilleur candidat est trop proche du second.");
      console.error(`➡️  best=${best.score} / second=${runnerUp.score} / gap=${ambiguityGap}`);
      console.error("Top candidates:");
      for (const r of ranked) {
        console.error(`  #${r.i + 1} score=${r.score}  ${r.excerpt}${r.excerpt.length >= 140 ? "…" : ""}`);
      }
      process.exit(2);
    }
  }

  const beforeBlock = blocks[best.i];
  const afterBlock = proposition.trim();

  const nextBlocks = blocks.slice();
  nextBlocks[best.i] = afterBlock;

  const updatedBody = joinParagraphBlocksPreserve(nextBlocks, separators);
  const updatedRaw = joinMdxFrontmatter(frontmatter, updatedBody);

  assertFrontmatterIntegrity({
    hadFrontmatter: hasFrontmatter,
    originalFrontmatter: frontmatter,
    finalText: updatedRaw,
    filePath: path.relative(CWD, contentFile),
  });

  console.log(`🧩 Matched block #${best.i + 1}/${blocks.length} score=${best.score}`);

  if (DRY_RUN) {
    console.log("\n--- DRY RUN (no write, no backup) ---\n");
    console.log("=== BEFORE (excerpt) ===");
    console.log(beforeBlock.slice(0, 400) + (beforeBlock.length > 400 ? "…" : ""));
    console.log("\n=== AFTER (excerpt) ===");
    console.log(afterBlock.slice(0, 400) + (afterBlock.length > 400 ? "…" : ""));
    console.log("\n✅ Dry-run terminé.");
    return;
  }

  const relContentFile = path.relative(CWD, contentFile);
  const bakPath = path.join(BACKUP_ROOT, `${relContentFile}.bak.issue-${issueNum}`);
  await fs.mkdir(path.dirname(bakPath), { recursive: true });

  if (!(await fileExists(bakPath))) {
    await fs.writeFile(bakPath, originalRaw, "utf-8");
  }

  await fs.writeFile(contentFile, updatedRaw, "utf-8");
  console.log("✅ Applied.");

  let aliasChanged = false;
  let newId = null;

  if (DO_ALIAS) {
    console.log("🔁 Rebuild to compute new anchor ids (npm run build) …");
    run("npm", ["run", "build"], { cwd: CWD });

    if (!(await fileExists(distHtmlPath))) {
      throw new Error(`dist introuvable après build: ${distHtmlPath}`);
    }

    newId = await computeNewIdFromDistByContent(distHtmlPath, afterBlock);

    const res = await upsertAlias({ chemin, oldId: ancre, newId });
    aliasChanged = res.changed;

    if (aliasChanged) {
      console.log(`✅ Alias ajouté: ${chemin} ${ancre} -> ${newId}`);
      run("node", ["scripts/inject-anchor-aliases.mjs"], { cwd: CWD });
    } else {
      console.log(`ℹ️ Alias déjà présent ou inutile (${ancre} -> ${newId}).`);
    }

    run("node", ["scripts/check-anchor-aliases.mjs"], { cwd: CWD });
    run("node", ["scripts/verify-anchor-aliases-in-dist.mjs"], { cwd: CWD });
    run("npm", ["run", "test:anchors"], { cwd: CWD });
    run("node", ["scripts/check-inline-js.mjs"], { cwd: CWD });
  }

  if (DO_COMMIT) {
    const files = [path.relative(CWD, contentFile)];
    if (DO_ALIAS && aliasChanged) files.push(path.relative(CWD, ALIASES_FILE));

    run("git", ["add", ...files], { cwd: CWD });

    if (!gitHasStagedChanges()) {
      console.log("ℹ️ Nothing to commit (aucun changement staged).");
      return;
    }

    const msg = `edit: apply ticket #${issueNum} (${chemin}#${ancre})`;
    run("git", ["commit", "-m", msg], { cwd: CWD });

    const sha = runQuiet("git", ["rev-parse", "--short", "HEAD"], { cwd: CWD }).trim();
    console.log(`✅ Committed: ${msg} (${sha})`);

    if (DO_CLOSE) {
      const comment = `✅ Appliqué par apply-ticket.\nCommit: ${sha}`;
      await closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment });
      console.log(`✅ Ticket #${issueNum} fermé.`);
    }
    return;
  }

  console.log("Next (manuel) :");
  console.log(`  git diff -- ${path.relative(CWD, contentFile)}`);
  console.log(
    `  git add ${path.relative(CWD, contentFile)}${
      DO_ALIAS ? " src/anchors/anchor-aliases.json" : ""
    }`
  );
  console.log(`  git commit -m "edit: apply ticket #${issueNum} (${chemin}#${ancre})"`);
  if (DO_CLOSE) {
    console.log("  (puis relance avec --commit --close pour fermer automatiquement)");
  }
}

main().catch((e) => {
  console.error("💥", e?.message || e);
  process.exit(1);
});