#!/usr/bin/env python3
"""
Variante de diff_align.py que usa el OCR de Surya (alta calidad) como fuente única,
en lugar del doble Tesseract psm6/psm4.

Para cada libro con datos Surya disponibles:
  - repo  = concatenacion normalizada del markdown del repo (actual)
  - surya = concatenacion normalizada del OCR Surya

difflib(repo, surya) -> ops 'replace' + 'insert' + 'delete' -> candidatos.

Tipos de candidato:
  - replace  -> classify(): REPO_TYPO / REVISAR / AMBIGUO / OCR_NOISE
  - insert   -> REPO_OMISSION  (el escaneo tiene texto que falta en el repo)
  - delete   -> REPO_EXTRA     (el repo tiene texto que no está en el escaneo)

Filtros anti-ruido para insert/delete:
  - anclaje: exige >=ANCHOR palabras idénticas a ambos lados
  - longitud máxima configurable (--max-ins-span)
  - inserts solo-dígitos (números de página) se descartan
  - inserts repetidos >=3 veces en el libro (cabeceras corridas) se descartan
  - inserts cuyo texto ya existe en el repo como heading (#) o blockquote (>)
    se descartan (no son omisiones: el repo los tiene, solo que el diff los excluye)

Modo --fine (segunda pasada experimental): conserva diacríticos y puntuación
como tokens, para cazar diferencias de grafía/puntuación que la normalización
estándar hace invisibles. Más ruidoso; usar por libro, no --all.

Modo --synonyms: audita el bloque word-by-word (SYNONYMS) en vez de la prosa
(translation+purport). Es exactamente la región que el modo normal DESCARTA.
Repo: conserva sólo las líneas de glosas `*palabra*—glosa; ...`. Escaneo:
conserva sólo las líneas de glosas del OCR — CC imprime el rótulo `SYNONYMS`;
SB/ISO no (las glosas van sin rótulo, justo antes de `TRANSLATION`), así que
también se conserva cualquier línea con el patrón de glosa (guión largo `—`
separando cabecera y glosa; los guiones internos `-` no cuentan). Ambos lados
tokenizan a la MISMA secuencia cabecera+glosa y pasan por el mismo motor de
alineación. Salida por defecto: out_syn/<book>.candidates.jsonl. (Hasta el
2026-07-28 la Gītā quedaba fuera por no tener escaneo; ya lo tiene —1034 pgs
del 1972 Macmillan— y entra como los demás.)

Salida: out_surya/<book>.candidates.jsonl
Uso:    python3 diff_align_surya.py                # todos los libros
        python3 diff_align_surya.py --book beyond-birth-and-death
        python3 diff_align_surya.py --book gita --fine --dir out_fine
        python3 diff_align_surya.py --synonyms                       # todos
        python3 diff_align_surya.py --synonyms --book isopanisad
"""
import json, re, sys, unicodedata, argparse, difflib
from pathlib import Path
from collections import Counter

HERE = Path(__file__).resolve().parent
CFG = json.loads((HERE / "books.json").read_text())
REPO_ROOT = Path(CFG["_repo_root"])
SURYA_DIR = HERE / "surya"
OUT = HERE / "out_surya"
OUT.mkdir(exist_ok=True)

# límites de span (configurables por CLI)
MAX_REPO_SPAN = 12   # antes 4: reescrituras largas eran invisibles
MAX_SCAN_SPAN = 16   # antes 6
MAX_INS_SPAN = 12    # longitud máx de una omisión/adición capturada
FURNITURE_MIN_REPEATS = 3  # texto insertado repetido >=N veces = mobiliario de página

FINE = False  # --fine: conservar diacríticos y puntuación
SYNONYMS_MODE = False  # --synonyms: auditar el bloque word-by-word, no la prosa

# ---------- normalización ----------

def strip_diacritics(s):
    return "".join(c for c in unicodedata.normalize("NFKD", s)
                   if not unicodedata.combining(c))

WORD_RE = re.compile(r"[a-z0-9]+")
# fine: palabras (con diacríticos) + signos de puntuación como tokens sueltos
FINE_RE = re.compile(r"[a-z0-9À-ɏḀ-ỿ]+|[.,;:!?\"'()\[\]—–-]")

def tokenize(text):
    if FINE:
        text = text.lower()
        text = (text.replace("’", "'").replace("‘", "'")
                    .replace("“", '"').replace("”", '"'))
        rx = FINE_RE
    else:
        text = strip_diacritics(text).lower()
        text = text.replace("’", "'").replace("‘", "'")
        rx = WORD_RE
    toks = []
    for m in rx.finditer(text):
        toks.append((m.group(0), m.start()))
    return toks

# ---------- texto del repo ----------

def natural_key(p):
    """Clave de orden NATURAL: los números cuentan como números.

    sorted() a secas ordena alfabéticamente, y eso descoloca el texto:
        cc-adi-1.1, cc-adi-1.10, cc-adi-1.100, … cc-adi-1.109, cc-adi-1.11, cc-adi-1.2
    o sea el verso 1.100 ANTES del 1.2. El escaneo va en orden de página (el
    correcto), así que el diff comparaba dos secuencias distintas y sólo cazaba
    los versos que por azar caían alineados: rendimiento ínfimo en las līlās con
    más versos de tres cifras (mad9: 0 candidatos en 536 páginas).

    El colofón CIERRA el capítulo, pero `bg-4-colophon.md` ordena antes que
    `bg-4.1.md` (el '-' gana al '.'), así que se concatenaba al PRINCIPIO del
    capítulo. En el escaneo está al final, o sea justo antes; el diff alineaba
    entonces el colofón del cap. N del repo contra el del cap. N-1 del escaneo
    y cantaba 18 falsos 'fourth'/'third'. Se le da número de verso altísimo
    para que caiga al final de su capítulo. Sólo la Gītā tiene colofones.
    """
    s = re.sub(r'-colophon(?=\.md$)', '.999999', str(p))
    return [int(t) if t.isdigit() else t.lower()
            for t in re.split(r'(\d+)', s)]


# Citas de escritura que el repo añade como wiki-link de navegación y el impreso
# no trae: "[[sb/1/2/9|[SB 1.2.9] ]]" renderiza "SB 1.2.9" y se marcaba como
# REPO_EXTRA. NO son texto de más que haya que borrar —son referencias cruzadas
# puestas a propósito, y en algunos libros el impreso SÍ imprime la cita (la
# Gītā trae "(Bg. 15.15)")—, así que no se ocultan: se reclasifican a
# CITA_ENLACE para que no contaminen el cubo REPO_EXTRA. En Nectar of
# Instruction eran 8 de 24 candidatos, un tercio del ruido.
CITA_RE = re.compile(
    r'^(?:sb|bg|cc|bs|brs|mu|iso|noi|nod|tlc|kb|lob|ssr|cb|bhag)'
    r'(?:\s+(?:adi|madhya|antya|purva|uttara))?'
    r'(?:\s+\d+)+$'
)


def file_rank(book, path):
    """Orden de las piezas sueltas (preliminares y aparato final) respecto a los
    capítulos.

    `sorted()` los coloca por nombre, y en la Gītā eso significa 'chapter-01' <
    'dedication' < 'foreword' < 'introduction' < 'preface' < 'title': los 18
    capítulos primero y TODO el preliminar detrás. En el escaneo el preliminar
    va delante (dedicatoria p6, foreword p7, preface p11, introduction p15-59,
    cap. 1 en p61) y la sucesión discipular al final (p955). El diff arrancaba
    comparando el capítulo 1 del repo contra la portada del escaneo y tardaba
    ~180 páginas en resincronizar: preliminares + capítulos 1 y 2 salían con 0
    candidatos (hueco p35→p218). Ver `_file_order` en books.json.
    """
    exact = CFG.get("_file_order_exact", {}).get(book)
    if exact:
        # orden EXPLICITO y completo: unica forma de expresar piezas sueltas
        # INTERCALADAS entre capitulos (NOD: dos secciones caen entre los caps.
        # 9-10 y 10-11). _file_order solo sabe poner antes o despues de todos.
        name = path.name
        return exact.index(name) if name in exact else len(exact)
    order = CFG.get("_file_order", {}).get(book)
    if not order:
        return 0
    name = path.name
    if name in order:
        return order.index(name) - len(order)   # negativo: antes de capítulos
    tail = CFG.get("_file_order_tail", {}).get(book, [])
    if name in tail:
        return 1 + tail.index(name)             # positivo: después
    return 0                                     # capítulos, en su sitio

# Libros con estructura POR-VERSO (verso + SYNONYMS + TRANSLATION + PURPORT).
# Alinear TODO el texto mete ruido masivo: el escaneo trae el verso
# (devanāgarī+IAST), el marcador "TEXT N" y el bloque SYNONYMS (glosas
# word-by-word) que el repo formatea distinto → en CADA verso el diff cree ver
# 'omisiones'/'extra' en las fronteras. Solución: comparar SOLO la prosa inglesa
# (translation + purport) en ambos lados.
VERSE_STRUCTURED = {"sri-caitanya-caritamrta", "srimad-bhagavatam"}

# Línea de SYNONYMS del repo: "*word*—gloss; *word*—gloss; ..." (≥2 glosas).
SYN_LINE_RE = re.compile(r"\*[^*\n]+\*\s*[—–-]")
_MARK_KEEP = re.compile(r"^(TRANSLATION|PURPORT)\s*$", re.I)
_MARK_SKIP = re.compile(r"^(SYNONYMS|TEXT\s+\d+.*|CHAPTER\b.*)\s*$", re.I)
_PAGENUM = re.compile(r"^\d+$")

# Rótulo SYNONYMS del escaneo (CC lo imprime; SB/ISO no).
_MARK_SYN = re.compile(r"^SYNONYMS\s*$", re.I)
_MARK_VERSE = re.compile(r"^(TEXTS?\s+\d+.*|CHAPTER\b.*)\s*$", re.I)
# Línea de glosa en el escaneo: cabecera + guión LARGO (—/–) + glosa. El guión
# corto (-) es interno de palabra (dāsa-vṛndāvana), no marca glosa. El verso en
# transliteración y la prosa carecen de este patrón → quedan fuera solos.
SCAN_GLOSS_RE = re.compile(r"\S\s*[—–]\s*\w")

def scan_prose(txt, mode):
    """Extrae SOLO translation+purport de una página Surya de libro por-verso.
    `mode` = estado entrante ('keep'/'skip'): la prosa puede continuar de la
    página anterior, por eso se arrastra. Devuelve (texto_prosa, mode_saliente).
    Salta nº de página + running header, marcadores, versos y bloques SYNONYMS."""
    out = []
    lines = txt.splitlines()
    i, n = 0, len(lines)
    while i < n:
        cur = lines[i]
        s = cur.strip()
        i += 1
        if not s:
            continue
        if _PAGENUM.match(s):
            # saltar tb. el running header (línea siguiente), salvo que sea marcador
            j = i
            while j < n and not lines[j].strip():
                j += 1
            if j < n:
                nxt = lines[j].strip()
                if not (_MARK_KEEP.match(nxt) or _MARK_SKIP.match(nxt)):
                    i = j + 1
            continue
        if _MARK_KEEP.match(s):
            mode = "keep"; continue
        if _MARK_SKIP.match(s):
            mode = "skip"; continue
        if mode == "keep":
            out.append(cur)
    return "\n".join(out), mode

def scan_synonyms(txt, mode):
    """Extrae SOLO el bloque word-by-word (SYNONYMS) de una página Surya.
    `mode` se arrastra entre páginas por si un bloque cruza el corte de página.
    Conserva una línea si estamos dentro de un bloque SYNONYMS (rótulo explícito,
    caso CC) O si la línea casa el patrón de glosa `—` (caso SB/ISO, sin rótulo).
    Salta nº de página, running header, marcadores de sección y el verso."""
    out = []
    for ln in txt.splitlines():
        s = ln.strip()
        if not s:
            continue
        if _MARK_SYN.match(s):
            mode = "syn"; continue
        if _MARK_KEEP.match(s):            # TRANSLATION / PURPORT → fin del bloque
            mode = "skip"; continue
        if _MARK_VERSE.match(s):           # TEXT n / CHAPTER → verso, fuera
            mode = "verse"; continue
        if _PAGENUM.match(s):
            continue
        if mode == "syn" or SCAN_GLOSS_RE.search(s):
            out.append(ln)
    return "\n".join(out), mode

def repo_text(book):
    """Devuelve (partes, skipped): partes = [(fileref, body)] con headings/quotes
    fuera; skipped = líneas excluidas (texto de # y >), para no marcar como
    'omisión' texto que el repo sí tiene."""
    bdir = REPO_ROOT / book
    parts = []
    skipped = []
    files = sorted(bdir.rglob("*.md"), key=lambda p: (file_rank(book, p), natural_key(p)))
    for f in files:
        raw = f.read_text(errors="ignore")
        lines = []
        for i, ln in enumerate(raw.splitlines()):
            s = ln.strip()
            if SYNONYMS_MODE:
                # Auditoría de synonyms: conserva SOLO las líneas de glosas
                # word-by-word (`*palabra*—glosa; ...`, ≥2 glosas). Los `*` se
                # limpian abajo con el resto de markdown. Todo lo demás (verso,
                # prosa, headings, blockquotes) queda fuera.
                if len(SYN_LINE_RE.findall(ln)) >= 2:
                    lines.append(ln)
                continue
            if s.startswith(">") or s.startswith("#"):
                skipped.append(s.lstrip(">#").strip())
                continue
            # Libros por-verso: la línea de SYNONYMS (glosas word-by-word) no es
            # prosa de Prabhupāda y su formato difiere del escaneo → fuera.
            if book in VERSE_STRUCTURED and len(SYN_LINE_RE.findall(ln)) >= 2:
                skipped.append(re.sub(r"\*(.+?)\*", r"\1", s))
                continue
            # Encabezado de capítulo escrito como texto normal, no como '#':
            # el repo pone "18. The Conversations With Prakasananda" al inicio
            # del fichero; el escaneo pone "CHAPTER EIGHTEEN". Sin esto salta un
            # candidato falso "18"->"chapter eighteen" en CADA capítulo de CADA
            # libro (una docena de UNCLEAR sólo en TLC). Sólo en las 3 primeras
            # líneas, para no comerse prosa que empiece por número.
            if i < 3 and re.match(r"^\d+\.\s+\S", s):
                skipped.append(s)
                continue
            lines.append(ln)
        body = "\n".join(lines)
        # wiki-links [[destino|etiqueta]] -> etiqueta. Sin esto, la ruta Y la
        # etiqueta tokenizan las dos ("cc madhya 19 170 cc madhya 19 170") y la
        # mitad sobrante se marca como REPO_EXTRA falso en cada cita de verso.
        body = re.sub(r"\[\[[^|\]]*\|(.*?)\]\]", r"\1", body)
        body = re.sub(r"\[\[([^|\]]*)\]\]", r"\1", body)
        body = re.sub(r"\*\*(.+?)\*\*", r"\1", body)
        body = re.sub(r"\*(.+?)\*", r"\1", body)
        body = re.sub(r"`([^`]*)`", r"\1", body)
        body = re.sub(r"<[^>]+>", " ", body)
        parts.append((str(f.relative_to(REPO_ROOT)), body))
    return parts, skipped

def repo_tokens(book):
    toks = []
    parts, skipped = repo_text(book)
    for fileref, body in parts:
        for tok, off in tokenize(body):
            toks.append((tok, fileref, off))
    skip_blob = " ".join(t for ln in skipped for t, _ in tokenize(ln))
    return toks, skip_blob

# ---------- texto del escaneo Surya ----------

INDEX_LINE_RE = re.compile(r'[,\s]\d+(-\d+)?\s*$')
GLOSS_LINE_RE = re.compile(r'^[A-ZĀĪŪṚṢṬŚṄÑḌṆ][\w\'’-]*[\s—–-]+[a-z]')

def is_apparatus(txt):
    """True si la página es índice analítico o glosario.

    El escaneo trae, por cada volumen, aparato que el repo no contiene (en CC:
    491 páginas, ~177k tokens). Alinearlo contra el texto del repo sólo produce
    ruido —ya coló 2 falsos positivos, un 'the' y un 'that' cazados contra
    entradas de índice— y descuadra el ancla. Las entradas de índice además
    están redactadas en estilo telegráfico, sin artículos, así que parecen
    'omisiones' del repo cuando no lo son.
    """
    lns = [l.strip() for l in txt.splitlines() if l.strip()]
    if not lns:
        return False
    # Página de apertura de capítulo: "CHAPTER TWENTY-ONE" deletreado. El repo
    # guarda el nº como heading, así que esta línea sólo genera ruido.
    if re.match(r"^CHAPTER\s+[A-Z-]+\s*$", lns[0], re.I):
        return True
    idx = sum(1 for l in lns if INDEX_LINE_RE.search(l) or l.startswith("See:"))
    if idx / len(lns) > 0.45:
        return True
    gl = sum(1 for l in lns if GLOSS_LINE_RE.match(l))
    return len(lns) > 6 and gl / len(lns) > 0.5

def scan_tokens_surya(book):
    bdir = SURYA_DIR / book
    if not bdir.exists():
        return None
    # Sólo los escaneos DECLARADOS en books.json. La carpeta OCR puede tener
    # varios escaneos del mismo libro (p.ej. easy-journey: la edición india
    # original Y la de krishna.org 1972); comparar el repo contra las dos a la
    # vez mezcla textos distintos. El repo casa al 95% con krishnaorg y al 44%
    # con la india → se audita SÓLO la edición de la que derivó el repo, que es
    # la que books.json lista.
    declared = {Path(rel).stem for rel in CFG["books"].get(book, [])}
    verse = book in VERSE_STRUCTURED
    toks = []
    for stemdir in sorted(bdir.iterdir()):
        if not stemdir.is_dir():
            continue
        if declared and stemdir.name not in declared:
            continue
        mode = "skip"  # cada volumen arranca fuera de la región auditada
        for pf in sorted(stemdir.glob("*.surya.txt")):
            pageref = f"{stemdir.name}#{pf.stem.replace('.surya', '')}"
            txt = pf.read_text(errors="ignore")
            if is_apparatus(txt):
                continue
            if SYNONYMS_MODE:
                txt, mode = scan_synonyms(txt, mode)
            elif verse:
                txt, mode = scan_prose(txt, mode)
            for tok, off in tokenize(txt):
                toks.append((tok, pageref))
    return toks

def words(toks):
    return [t[0] for t in toks]

# ---------- diccionario ----------

def load_dict():
    d = set()
    p = Path("/usr/share/dict/words")
    if p.exists():
        for w in p.read_text(errors="ignore").splitlines():
            d.add(w.strip().lower())
    return d

ENGLISH = load_dict()

def known_set(toks):
    return set(t[0] for t in toks)

def is_known(tok, vocab):
    return len(tok) <= 2 or tok in ENGLISH or tok in vocab

# Pares que Surya rara vez confunde, pero por si acaso los dejamos
CONFUSE = {frozenset(p) for p in [
    "ec","tl","oa","nu","il","ha","mn","sf","ft","ie","uo","co","vy",
    "rn","cl","li","tf","ea","ou","au","hb","ji","yg","gq","08","l1","i1"]}

def _lev_ops(a, b):
    sm = difflib.SequenceMatcher(None, a, b, autojunk=False)
    return sm.get_opcodes()

def looks_like_ocr(repo_str, scan_str):
    """True si la diferencia parece ruido OCR (dropout de carácter, par confundible).
    Permite 1 insert/delete (letra caída) pero NO 1 substitución pura
    (que podría ser diff real: "word"/"work", "free"/"from", etc.).
    """
    a, b = list(repo_str), list(scan_str)
    bad = 0
    bad_subst = 0
    for tag, i1, i2, j1, j2 in _lev_ops(a, b):
        if tag == "equal":
            continue
        if tag == "replace" and (i2 - i1) == 1 and (j2 - j1) == 1:
            if frozenset(a[i1] + b[j1]) in CONFUSE:
                continue
            bad += 1
            bad_subst += 1
        elif tag in ("insert", "delete") and (max(i2 - i1, j2 - j1) == 1):
            bad += 1
        else:
            bad += 2
    # Tolerar 1 dropout de carácter (bad==1, sin substituciones puras)
    return bad == 0 or (bad == 1 and bad_subst == 0)

def classify(repo_str, scan_str, vocab):
    rt = repo_str.split()
    st = [t for t in scan_str.split() if not t.isdigit()]
    repo_known = all(is_known(t, vocab) for t in rt) if rt else False
    scan_known = all(is_known(t, vocab) for t in st) if st else False
    if repo_known and not scan_known:
        return "OCR_NOISE"
    if (not repo_known) and scan_known:
        return "REPO_TYPO"
    if repo_known and scan_known:
        if looks_like_ocr(repo_str, scan_str):
            return "OCR_NOISE"
        return "REVISAR"
    return "AMBIGUO"

def join(seq):
    return "".join(seq)

CONTEXT = 6

def candidates_for(book):
    rtoks, skip_blob = repo_tokens(book)
    stoks = scan_tokens_surya(book)
    if not rtoks or not stoks:
        return []
    vocab = known_set(rtoks)

    # Alineación POR SEGMENTOS (līlā / canto) si books.json los declara.
    #
    # Alinear el libro entero de una vez obliga a un `ratio` global, y el ratio
    # real varía mucho por segmento — en CC: adi 1.223, antya 1.641, madhya
    # 1.431 frente a un global de 1.405. Como el ancho de ventana se calcula con
    # ese ratio, en los segmentos de ratio alto la ventana se queda corta y
    # trunca el tramo; el resultado era 41-100 fichas/100pg en adi contra 0-3 en
    # madhya y antya (mad9: 0 en 536 páginas), 66 de 140 ventanas avanzando a
    # ciegas y el ancla llegando sólo al 93.4% del escaneo.
    #
    # Cada segmento se alinea contra SUS volúmenes, con su propio ratio y su
    # ancla desde cero, así que un segmento no puede arrastrar al siguiente.
    segs = CFG.get("_segments", {}).get(book)
    if segs:
        out = []
        for seg in segs:
            pref = seg["repo"] + "/"
            rsub = [t for t in rtoks if t[1].startswith(pref) or f"/{pref}" in t[1]]
            stems = set(seg["stems"])
            ssub = [t for t in stoks if t[1].split("#")[0] in stems]
            if not rsub or not ssub:
                print(f"    aviso: segmento {seg['repo']} vacío "
                      f"(repo={len(rsub)} scan={len(ssub)})", file=sys.stderr)
                continue
            out += _align(rsub, ssub, vocab, skip_blob, book)
        return out
    return _align(rtoks, stoks, vocab, skip_blob, book)

def _align(rtoks, stoks, vocab, skip_blob, book):
    rw = words(rtoks)
    sw = words(stoks)

    ANCHOR = 3
    CHUNK_R = 10000
    S_MARGIN = 2000
    EDGE = ANCHOR + 2
    ANCHOR_MIN = 25   # tokens seguidos idénticos para fiarse de un punto de sincronía

    def _coalesce(ops, min_gap):
        """Funde ops no-'equal' separadas por bloques 'equal' CORTOS en un solo
        replace.

        Sin esto las TRANSPOSICIONES son invisibles. difflib convierte
        "recommended neither" -> "neither recommended" en insert('neither') +
        delete('neither') separados por UNA sola palabra igual. El filtro de
        anclaje exige ANCHOR(=3) palabras idénticas a cada lado, así que la
        mitad izquierda se cae por la derecha y la derecha por la izquierda:
        las dos se descartan y el diff nunca propone el cambio. Fundiéndolas
        queda un replace con anclas largas a ambos lados, que ya se clasifica
        normalmente (BBD cap.5: repo "is recommended neither by" vs impreso
        "is neither recommended by", encontrado a mano, no por el pipeline).
        """
        out, i = [], 0
        while i < len(ops):
            if ops[i][0] == "equal":
                out.append(ops[i]); i += 1; continue
            i1, j1 = ops[i][1], ops[i][3]
            i2, j2 = ops[i][2], ops[i][4]
            k = i
            while (k + 2 < len(ops) and ops[k+1][0] == "equal"
                   and (ops[k+1][2] - ops[k+1][1]) < min_gap
                   and ops[k+2][0] != "equal"):
                i2, j2 = ops[k+2][2], ops[k+2][4]
                k += 2
            out.append(("replace", i1, i2, j1, j2) if k > i else ops[i])
            i = k + 1
        return out

    def _window(rchunk, schunk, r_off, s_off):
        sm = difflib.SequenceMatcher(None, rchunk, schunk, autojunk=False)
        ops = _coalesce(sm.get_opcodes(), ANCHOR)
        res = {}
        for k, (tag, i1, i2, j1, j2) in enumerate(ops):
            if tag == "equal":
                continue
            if tag == "replace":
                if (i2 - i1) > MAX_REPO_SPAN or (j2 - j1) > MAX_SCAN_SPAN:
                    continue
            elif tag == "insert":
                if (j2 - j1) > MAX_INS_SPAN:
                    continue
            elif tag == "delete":
                if (i2 - i1) > MAX_INS_SPAN:
                    continue
            prev_ok = k > 0 and ops[k-1][0] == "equal" and (ops[k-1][2]-ops[k-1][1]) >= ANCHOR
            next_ok = k < len(ops)-1 and ops[k+1][0] == "equal" and (ops[k+1][2]-ops[k+1][1]) >= ANCHOR
            if not (prev_ok and next_ok):
                continue
            rp = " ".join(rchunk[i1:i2])
            sp = " ".join(schunk[j1:j2])
            if tag == "replace" and join(rchunk[i1:i2]) == join(schunk[j1:j2]):
                continue
            res[(r_off + i1, r_off + i2, tag)] = (rp, sp, s_off + j1, s_off + j2)
        return res, sm.get_matching_blocks()

    def _last_anchor(blocks, limit):
        """Último bloque GRANDE que casa dentro de [0, limit).

        Se exige ANCHOR_MIN tokens seguidos idénticos: una coincidencia larga es
        un punto de sincronía fiable, mientras que un bloque corto puede casar
        por azar (artículos, preposiciones) y desviar el ancla. Devuelve
        (fin_repo, fin_escaneo) en coordenadas locales, o None."""
        best = None
        for i, j, n in blocks:
            if n >= ANCHOR_MIN and i + n <= limit:
                best = (i + n, j + n)
        return best

    r_len = len(rw)
    s_len = len(sw)
    ratio = s_len / r_len if r_len else 1.0
    step = max(CHUNK_R - 2 * EDGE, 1)
    r_start = 0
    replaces = {}
    # Ancla móvil: posición REAL en el escaneo que corresponde a r_start.
    #
    # Antes se estimaba con `int(r_start * ratio)`, una regla de tres global que
    # da por hecho que repo y escaneo avanzan en proporción constante. No es
    # cierto: ~25% del escaneo (ratio 1.55 en CC) no existe en el repo —cada
    # volumen trae portada, prefacio, glosario, índice, referencias, cabeceras
    # corridas y números de página, y el repo además excluye los ślokas. El
    # desfase se acumulaba y, en cuanto superaba S_MARGIN, la ventana ni
    # contenía la zona buena: difflib alineaba contra texto ajeno y el `page`
    # del candidato apuntaba a otro capítulo (visto en ant5, mad2, ant2).
    #
    # Ahora los dos punteros avanzan JUNTOS hasta el último bloque largo que
    # casa de verdad, así que no pueden desincronizarse: el material extra del
    # escaneo se absorbe como un salto entre bloques. Anclar en un punto fijo de
    # la ventana no vale — si la alineación es mala justo ahí, el ancla se
    # desvía y arrastra las ventanas siguientes.
    s_anchor = 0
    while r_start < r_len:
        r_end = min(r_start + CHUNK_R, r_len)
        s_start = max(0, s_anchor - S_MARGIN)
        s_end = min(s_len, s_anchor + int((r_end - r_start) * ratio) + S_MARGIN)
        chunk_res, blocks = _window(rw[r_start:r_end], sw[s_start:s_end], r_start, s_start)
        anc = _last_anchor(blocks, (r_end - r_start) - EDGE)
        for (i1, i2, tag), v in chunk_res.items():
            loc_i1 = i1 - r_start
            loc_i2 = i2 - r_start
            if r_start > 0 and loc_i2 < EDGE:
                continue
            if r_end < r_len and loc_i1 >= (r_end - r_start) - EDGE:
                continue
            if (i1, i2, tag) not in replaces:
                replaces[(i1, i2, tag)] = v
        if anc is not None:
            r_next = r_start + anc[0]
            s_anchor = s_start + anc[1]
        else:
            r_next = r_start + step       # ventana sin ancla fiable: paso ciego
            s_anchor += int(step * ratio)
        # el ancla podría caer antes del inicio (ventana degenerada): forzar
        # avance mínimo o el bucle no termina
        r_start = r_next if r_next > r_start else r_start + step

    # --- filtro de mobiliario de página para inserts ---
    # cabeceras corridas / pies repetidos: se comparan SIN dígitos, porque el
    # número de página cambia en cada aparición ("mantra one 15", "mantra one 17")
    def ins_core(text):
        return " ".join(t for t in text.split() if not t.isdigit())

    ins_texts = Counter(
        ins_core(v[1]) for (i1, i2, tag), v in replaces.items() if tag == "insert")

    # primer índice de token de cada página: los running heads viven ahí
    page_first = {}
    for idx, t in enumerate(stoks):
        page_first.setdefault(t[1], idx)
    HEAD_ZONE = 8  # tokens iniciales de página considerados zona de cabecera

    # etiquetas de sección del libro impreso que el repo representa
    # estructuralmente (no son texto omitido)
    SECTION_LABELS = {"translation", "purport", "purports", "synonyms",
                      "mantra", "text", "texts", "chapter", "verse", "verses"}

    cands = []
    for span in sorted(replaces, key=lambda s: (s[0], s[1], s[2])):
        i1, i2, tag = span
        repo_str, scan_str, j1, j2 = replaces[span]

        if tag == "insert":
            core = ins_core(scan_str)
            if not core:
                continue                       # solo números de página
            if ins_texts[core] >= FURNITURE_MIN_REPEATS:
                continue                       # cabecera corrida / pie repetido
            if core in skip_blob:
                continue                       # el repo lo tiene como # o >
            if all(t in SECTION_LABELS for t in core.split()):
                continue                       # etiqueta de sección impresa
            pref = stoks[min(j1, len(stoks)-1)][1]
            if j1 - page_first.get(pref, 0) < HEAD_ZONE:
                continue                       # zona de cabecera de página
            verdict = "REPO_OMISSION"
        elif tag == "delete":
            rt = [t for t in repo_str.split() if not t.isdigit()]
            if not rt:
                continue
            verdict = "CITA_ENLACE" if CITA_RE.match(repo_str) else "REPO_EXTRA"
        else:
            verdict = classify(repo_str, scan_str, vocab)

        lo = max(0, i1 - CONTEXT)
        hi = min(len(rw), i2 + CONTEXT)
        ctx = " ".join(rw[lo:i1]) + " [[" + repo_str + "]] " + " ".join(rw[i2:hi])
        fileref = rtoks[i1][1] if i1 < len(rtoks) else ""
        pj = min(j1, len(stoks) - 1)
        pageref = stoks[pj][1] if pj >= 0 else ""
        cands.append({
            "book": book, "file": fileref, "page": pageref,
            "verdict": verdict, "consensus": True,   # Surya = única fuente de alta calidad
            "repo": repo_str, "scan": scan_str,
            "scan6": scan_str, "scan4": scan_str,    # compat con adjudicate_cli.py
            "context": ctx,
            "span": [i1, i2],
            "op": tag,
        })
    return cands

def main():
    global OUT, MAX_REPO_SPAN, MAX_SCAN_SPAN, MAX_INS_SPAN, FINE, SYNONYMS_MODE
    ap = argparse.ArgumentParser()
    ap.add_argument("--book")
    ap.add_argument("--all", action="store_true")
    ap.add_argument("--dir", default=None, help="directorio de salida (default: out_surya/, out_syn/ con --synonyms)")
    ap.add_argument("--max-repo-span", type=int, default=MAX_REPO_SPAN)
    ap.add_argument("--max-scan-span", type=int, default=MAX_SCAN_SPAN)
    ap.add_argument("--max-ins-span", type=int, default=MAX_INS_SPAN)
    ap.add_argument("--fine", action="store_true",
                    help="conservar diacríticos y puntuación (2a pasada, ruidoso)")
    ap.add_argument("--synonyms", action="store_true",
                    help="auditar el bloque word-by-word (SYNONYMS) en vez de la prosa")
    a = ap.parse_args()
    SYNONYMS_MODE = a.synonyms
    if a.dir:
        OUT = Path(a.dir)
    elif SYNONYMS_MODE:
        OUT = HERE / "out_syn"
    OUT.mkdir(exist_ok=True)
    MAX_REPO_SPAN = a.max_repo_span
    MAX_SCAN_SPAN = a.max_scan_span
    MAX_INS_SPAN = a.max_ins_span
    FINE = a.fine

    books = [a.book] if a.book else list(CFG["books"].keys())
    grand = 0
    for book in books:
        if not (SURYA_DIR / book).exists():
            if a.book:
                print(f"sin Surya OCR: {book}", file=sys.stderr)
            continue
        cands = candidates_for(book)
        cc = Counter(c["verdict"] for c in cands)
        outf = OUT / f"{book}.candidates.jsonl"
        with outf.open("w") as fh:
            for c in cands:
                fh.write(json.dumps(c, ensure_ascii=False) + "\n")
        print(f"  {book}: {len(cands)} cand  "
              f"REPO_TYPO={cc['REPO_TYPO']} REVISAR={cc['REVISAR']} "
              f"OMISSION={cc['REPO_OMISSION']} EXTRA={cc['REPO_EXTRA']} "
              f"CITA={cc['CITA_ENLACE']} "
              f"AMBIGUO={cc['AMBIGUO']} OCR_NOISE={cc['OCR_NOISE']} -> {outf.name}",
              file=sys.stderr)
        grand += len(cands)
    print(f"TOTAL candidatos: {grand}", file=sys.stderr)

if __name__ == "__main__":
    main()
