#!/usr/bin/env python3
"""Quita del repo el texto que el impreso NO trae.

Por que hace falta una herramienta aparte. Todos los aplicadores anteriores
SUSTITUYEN una grafia por otra: misma posicion, mismo hueco. Aqui el impreso no
tiene nada en ese punto, asi que hay que BORRAR y dejar la frase cerrada. Eso
obliga a tocar lo que la sustitucion nunca toca: los espacios de los dos lados,
la coma que sobra, los parentesis que se quedan vacios y los asteriscos de la
cursiva que envolvia la palabra suprimida.

De donde salen los casos: la clase `op=delete` del alineador (texto que el repo
tiene y el escaneo no), adjudicada UNA A UNA sobre la imagen del impreso. Solo
se actua sobre los NO_ESTA, y solo cuando el veredicto dice explicitamente que
la frase se lee entera sin ese fragmento.

Guardarrailes, todos aprendidos a base de estropicios:
  - POSICION UNICA. Se localiza por el `context` del alineador, no por la
    palabra suelta. Si el contexto no da una sola posicion, no se toca.
  - NADA ESTRUCTURAL. Si el tramo cruza un salto de linea, un encabezado (#) o
    una linea de verso (>), se aparta: eso ya no es una insercion, es otra cosa.
  - LIMITE DE TAMANO. Mas de 120 caracteres no se borra en automatico.
  - CIERRE DE PAREJAS. Si al quitar el texto quedaban '()' o '**' vacios, se
    quitan tambien; si no, el fichero queda con basura de marcado.
  - VERIFICACION POSTERIOR obligatoria (barrido_integridad.py): comparar contra
    git y mirar que no se ha perdido nada mas que lo previsto.

Uso: python3 aplicar_supresion.py [--aplicar] [--muestra N]
"""
import json, re, sys, glob, unicodedata, pathlib, collections

HERE = pathlib.Path(__file__).resolve().parent
REPO = pathlib.Path('/Users/jaganat/git_projects/vedabase-original')
LOTES = HERE / 'lotes_imagen'
FECHA = '2026-08-11'
sys.path.insert(0, str(HERE))
from aplicar_posicional import normaliza

MAX = 120                      # caracteres que se aceptan borrar de una vez


def casos_no_esta():
    """Los NO_ESTA adjudicados por imagen, sin duplicados."""
    recs = []
    for f in sorted(LOTES.glob('resultado*.json')):
        n = int(re.search(r'(\d+)', f.name).group(1))
        if n < 80: continue        # los lotes de la clase delete empiezan en 80
        recs += json.load(open(f, encoding='utf-8'))
    vistos = set(); out = []
    for r in recs:
        if r.get('veredicto') != 'NO_ESTA': continue
        k = (r['file'], r['texto_repo'], r['pagina'])
        if k in vistos: continue
        vistos.add(k); out.append(r)
    return out


def verificadas():
    """La SEGUNDA adjudicacion, la que de verdad autoriza a borrar.

    El NO_ESTA de la primera pasada no basta: el ensayo demostro que el OCR
    parte los compuestos con guion y falla en cursiva y negrita, y ahi el texto
    SI esta impreso. En la segunda pasada al adjudicador se le enseña COMO
    QUEDARIA LA FRASE y responde SUPRIMIR o CONSERVAR. Solo SUPRIMIR autoriza.

    Devuelve {(file, pagina, borrado): registro}. Los lotes 90+ son los de
    verificacion; los `_p1` son volcados parciales de un agente interrumpido."""
    v = collections.defaultdict(list)
    for f in sorted(LOTES.glob('resultado*.json')):
        n = int(re.search(r'(\d+)', f.name).group(1))
        if n < 90: continue
        for r in json.load(open(f, encoding='utf-8')):
            if 'borrado' not in r: continue
            v[(r['file'], r['pagina'])].append(r)
    return v


def sd(s):
    """Simplifica para comparar: sin diacriticos, sin marcado, minusculas."""
    s = unicodedata.normalize('NFD', s or '')
    s = ''.join(c for c in s if unicodedata.category(c) != 'Mn')
    return ' '.join(re.sub(r'[^A-Za-z0-9]+', ' ', s.lower()).split())


def concuerda_con_impreso(nuevo, j, nota, n=4):
    """Contrasta la costura con la frase del impreso que cito el adjudicador.

    Es el control mas fuerte que hay: el agente escribio en su nota como se lee
    la pagina. Si nuestro resultado no cabe dentro de esa cita, la costura esta
    mal aunque la supresion sea correcta — que es justo lo que pasaba con
    «his Godbrother.. For this reason» y con «a daṇḍa, he retained».

    La ventana NO puede cruzar el final de la frase. Si la supresion cae justo
    antes de un punto, las palabras de la derecha ya son del parrafo siguiente y
    la cita no las cubre: se rechazaba «in the year Śakābda 1504.», que es
    exactamente lo que pone el impreso (cc-madhya-1.35). En ese caso se juzga
    solo con el lado que si esta dentro de la frase.

    Devuelve True/False, o None si la cita no llega a cubrir la zona."""
    cita = sd(nota)
    if not cita: return None
    pc = set(cita.split())
    # cada lado, recortado en la frontera de frase mas cercana
    ant = re.split(r'[.!?]["»”\')\]*_ ]*\s|\n\n', nuevo[:j])[-1]
    pos = re.split(r'[.!?]["»”\')\]*_ ]*\s|\n\n', nuevo[j:])[0]
    izq = sd(ant).split()[-n:]
    der = sd(pos).split()[:n]
    if not izq and not der: return None
    if izq and der:
        if izq[-1] not in pc or der[0] not in pc: return None
        return ' '.join(izq + der) in cita
    lado = izq or der
    if len(lado) < 2 or lado[0] not in pc: return None
    return ' '.join(lado) in cita


def casa_verificacion(ver, fich, pagina, borrado):
    """Empareja una supresion con su veredicto de segunda pasada.

    No se puede exigir que el `borrado` coincida letra a letra: las reglas de
    costura crecieron despues de generar los lotes (ahora el guion del compuesto
    se va con la palabra, asi que donde el lote decia 'śakti' aqui pone
    '-śakti'). Se acepta que uno termine en el otro, y si para esa pagina solo
    hay un veredicto, se usa ese."""
    cand = ver.get((fich, pagina), [])
    if not cand: return None
    for r in cand:
        if r['borrado'] == borrado: return r
    for r in cand:
        if borrado.endswith(r['borrado']) or r['borrado'].endswith(borrado): return r
    return cand[0] if len(cand) == 1 else None


def contextos(quiero):
    """El `context` del alineador para cada (fichero, texto). Es lo unico que
    situa la insercion: el mismo texto puede repetirse en el fichero."""
    ctxs = collections.defaultdict(list)
    for cf in sorted((HERE / 'out_surya').glob('*.candidates.jsonl')):
        for l in open(cf, encoding='utf-8'):
            d = json.loads(l)
            if d.get('op') != 'delete': continue
            k = (d['file'], (d.get('repo') or '').strip().lower())
            if k in quiero and d.get('context'):
                ctxs[k].append(d['context'])
    return ctxs


def extiende(txt, ini, fin):
    """Amplia el tramo a borrar para no dejar marcado ni puntuacion huerfanos.

    Devuelve (ini, fin, motivo). El orden importa: primero la cursiva, que es lo
    mas pegado a la palabra, y despues el parentesis, que la envuelve entera."""
    motivos = []
    # SEGUNDA MITAD DE UN COMPUESTO. Al quitar 'śakti' de 'hlādinī-śakti' se
    # quedaba el guion colgando: '*hlādinī-,*'. El impreso pone «namely the
    # hlādinī, or pleasure potency» (cc-adi-4.60), asi que el guion se va con
    # la palabra. Solo cuando lo que precede al guion es texto real.
    if txt[ini - 1:ini] == '-' and txt[ini - 2:ini - 1].isalnum():
        ini -= 1; motivos.append('guion del compuesto')
    # PRIMERA MITAD DE UN COMPUESTO, el caso simetrico. Quitar 'Bṛhad' de
    # 'Bṛhad-bhāgavatāmṛta' dejaba '-bhāgavatāmṛta'; el impreso pone
    # «Bhāgavatāmṛta», sin guion y CON mayuscula, porque la mayuscula viajaba en
    # la mitad suprimida (cc-madhya-1.35).
    elif txt[fin:fin + 1] == '-' and txt[fin + 1:fin + 2].isalnum():
        fin += 1; motivos.append('guion del compuesto')
        if txt[ini:ini + 1].isupper():
            motivos.append('cabeza de compuesto en mayuscula')
    # cursiva: *palabra* o _palabra_ que se queda vacia
    for m in ('*', '_'):
        if txt[ini - 1:ini] == m and txt[fin:fin + 1] == m:
            ini -= 1; fin += 1; motivos.append(f'cursiva {m}{m}')
    # parentesis o corchete que envolvia SOLO lo suprimido
    izq = txt[:ini].rstrip()
    for a, b in (('(', ')'), ('[', ']')):
        if izq.endswith(a) and txt[fin:].lstrip().startswith(b):
            ini = len(izq) - 1
            fin = fin + len(txt[fin:]) - len(txt[fin:].lstrip()) + 1
            motivos.append(f'parentesis {a}{b} vacio')
            break
    return ini, fin, motivos


MARCAS = '*_"“«(['          # marcado y comillas que preceden a la palabra real


def inicio_de_frase(base_i):
    """Decide si lo suprimido se comia el ARRANQUE de la frase.

    Lo dijeron dos adjudicaciones distintas: al quitar 'In verses 194 and 200'
    de cc-adi-11.39 quedaba «it is said that...» en minuscula, cuando el impreso
    pone «It is said». Y al quitar el inciso de cc-adi-17.302 quedaba «a flute
    in His mouth» donde el impreso abre «A flute». Sin esta regla la supresion
    es correcta de fondo y deja el texto mal escrito."""
    s = base_i.rstrip(' ').rstrip('*_"”»')
    return (not s) or s[-1] in '.!?:' or s[-1] == '\n'


def capitaliza(base_d):
    """Pone en mayuscula la primera letra real, saltando el marcado."""
    for i, c in enumerate(base_d):
        if c in MARCAS: continue
        if c.isalpha():
            return base_d[:i] + c.upper() + base_d[i + 1:], c.islower()
        break
    return base_d, False


def cose(txt, ini, fin, forzar_mayuscula=False):
    """Une los dos lados y arregla SOLO la juntura. Nada fuera de la ventana."""
    izq, der = txt[:ini], txt[fin:]
    # un solo espacio en la costura; si el lado derecho empieza por puntuacion,
    # ninguno (para no dejar 'meses , ' donde iba 'meses,')
    corte_i = len(izq) - len(izq.rstrip(' '))
    corte_d = len(der) - len(der.lstrip(' '))
    base_i, base_d = izq.rstrip(' '), der.lstrip(' ')
    arreglos = []
    # ABREVIATURA DENTRO DE PARENTESIS. '(A.D. 1521-1533)' menos 'A.D' dejaba
    # '(. 1521-1533)': el punto de la abreviatura se queda huerfano pegado al
    # parentesis. Se absorbe (cc-adi-17.195).
    if base_i.endswith(('(', '[')) and base_d[:1] in '.,;:':
        base_d = base_d[1:].lstrip(' ')
        arreglos.append('punto de abreviatura huerfano tras parentesis')
    # PUNTUACION DUPLICADA en la costura. Al quitar «or try to engage him as a
    # servant» quedaba «his Godbrother.. For this reason» (cc-madhya-10.143):
    # el punto del final del inciso y el que ya cerraba la frase.
    if base_i[-1:] in '.,;:' and base_d[:1] == base_i[-1:]:
        base_d = base_d[1:].lstrip(' ')
        arreglos.append('puntuacion duplicada en la costura')
    # La mayuscula que viajaba en la cabeza del compuesto suprimido.
    if forzar_mayuscula:
        nueva, cambio = capitaliza(base_d)
        if cambio:
            base_d = nueva
            arreglos.append('mayuscula del compuesto restituida')
    # ARRANQUE DE FRASE que se comio la supresion.
    if inicio_de_frase(base_i):
        # coma que colgaba del inciso suprimido: quitar «Like the previous
        # verse» dejaba «, this verse appears» abriendo parrafo (cc-adi-6.74).
        if base_d[:1] == ',':
            base_d = base_d[1:].lstrip(' ')
            arreglos.append('coma huerfana al abrir frase')
        nueva, cambio = capitaliza(base_d)
        if cambio:
            base_d = nueva
            arreglos.append('mayuscula inicial restituida')
    if not base_i or not base_d:
        union = base_i + base_d
    elif base_d[0] in ',.;:!?)]"’' or base_i[-1] in '([“*_':
        union = base_i + base_d
    elif base_i[-1] == '\n' or base_d[0] == '\n':
        union = base_i + base_d
    else:
        union = base_i + ' ' + base_d
    # comas dobles que deja la supresion de un inciso: 'X, , Y' -> 'X, Y'
    j = len(base_i)
    ven = union[max(0, j - 3):j + 3]
    nue = re.sub(r',\s*,', ',', ven)
    nue = re.sub(r'\s+([,.;:])', r'\1', nue)
    if nue != ven:
        union = union[:max(0, j - 3)] + nue + union[j + 3:]
    return union, arreglos


def main():
    aplicar = '--aplicar' in sys.argv
    muestra = int(sys.argv[sys.argv.index('--muestra') + 1]) if '--muestra' in sys.argv else 12

    casos = casos_no_esta()
    print(f'NO_ESTA adjudicados por imagen: {len(casos)}')
    ver = verificadas()
    print(f'verificados en segunda pasada: {sum(len(x) for x in ver.values())}')
    ctxs = contextos({(c['file'], c['texto_repo'].strip().lower()) for c in casos})

    res = collections.Counter(); hechas = []; apartados = []
    porfich = collections.defaultdict(list)
    for c in casos: porfich[c['file']].append(c)

    for fich, lista in sorted(porfich.items()):
        p = REPO / fich
        if not p.exists():
            res['sin fichero'] += len(lista); continue
        txt = p.read_text(encoding='utf-8')
        pend = []
        for c in lista:
            k = (fich, c['texto_repo'].strip().lower())
            sitio = None
            for ctx in ctxs.get(k, []):
                m = re.search(r'\[\[(.+?)\]\]', ctx)
                if not m: continue
                izq = ctx[:m.start()].strip(); der = ctx[m.end():].strip()
                pal = m.group(1).strip()
                buscado = ' '.join(y for y in (izq, pal, der) if y)
                norm, mapa = normaliza(txt)
                pos = [y.start() for y in re.finditer(re.escape(buscado), norm)]
                if len(pos) != 1: continue
                ini_n = pos[0] + (len(izq) + 1 if izq else 0)
                ini, fin = mapa[ini_n], mapa[ini_n + len(pal) - 1] + 1
                if normaliza(txt[ini:fin])[0] != pal: continue
                sitio = (ini, fin); break
            if not sitio:
                res['el contexto no da posicion unica'] += 1
                apartados.append({**c, 'motivo': 'sin posicion unica'}); continue

            ini, fin = sitio
            real = txt[ini:fin]
            if '\n' in real:
                res['el tramo cruza un salto de linea'] += 1
                apartados.append({**c, 'motivo': 'cruza salto de linea', 'real': real}); continue
            if len(real) > MAX:
                res[f'el tramo pasa de {MAX} caracteres'] += 1
                apartados.append({**c, 'motivo': 'demasiado largo', 'real': real}); continue
            linea = txt.rfind('\n', 0, ini) + 1
            cab = txt[linea:linea + 2]
            if cab.startswith('#') or cab.startswith('>'):
                res['esta en un encabezado o en el verso'] += 1
                apartados.append({**c, 'motivo': 'linea estructural', 'real': real}); continue
            pend.append((ini, fin, real, c))

        ocupados = []
        for ini, fin, real, c in sorted(pend, key=lambda y: -y[0]):
            if any(not (fin <= a or ini >= b) for a, b in ocupados):
                res['posicion ya ocupada por otra supresion'] += 1; continue
            i2, f2, motivos = extiende(txt, ini, fin)
            antes_ctx = txt[max(0, i2 - 70):min(len(txt), f2 + 70)]
            nuevo, arreglos = cose(txt, i2, f2,
                                   'cabeza de compuesto en mayuscula' in motivos)
            motivos = motivos + arreglos
            j = len(txt[:i2].rstrip(' '))
            despues_ctx = nuevo[max(0, j - 70):j + 70]
            ficha = {'file': fich, 'page': c['pagina'],
                     'borrado': txt[i2:f2], 'motivos': motivos,
                     'antes': antes_ctx.replace('\n', '⏎'),
                     'despues': despues_ctx.replace('\n', '⏎'),
                     'nota': c.get('nota', '')[:240]}

            # LA PUERTA. Sin un SUPRIMIR de la segunda pasada no se borra nada.
            v = casa_verificacion(ver, fich, c['pagina'], txt[i2:f2])
            if v is None:
                res['sin verificar todavia'] += 1
                apartados.append({**ficha, 'motivo': 'sin segunda adjudicacion'}); continue
            if v['veredicto'] != 'SUPRIMIR':
                res[f"la imagen dice {v['veredicto']}"] += 1
                apartados.append({**ficha, 'motivo': v['veredicto'],
                                  'impreso': v.get('impreso', ''),
                                  'nota_imagen': v.get('nota', '')[:240]}); continue
            # COSTURA ROTA. El barrido contra git cazo dos: quitar «the śrutis»
            # de entre comillas dejaba «means ' ' attaining» (cc-madhya-8.225), y
            # quitar «mahā-» dejaba «* bhāgavata,*» (cc-madhya-8.276). Antes de
            # nada, mirar que la juntura no ha quedado con un par vacio ni con
            # doble espacio.
            costura = nuevo[max(0, j - 4):j + 4]
            if re.search(r"''|\"\s\"|'\s'|\*\s\*|\(\s*\)|\[\s*\]|  |\*\s\w", costura):
                res['la costura queda rota (par vacio o espacio de mas)'] += 1
                apartados.append({**ficha, 'motivo': 'costura rota',
                                  'costura': costura}); continue
            # contraste final contra la frase que el adjudicador leyo
            ok = concuerda_con_impreso(nuevo, j, v.get('nota', ''))
            ficha['concuerda'] = ok
            if ok is False:
                res['la costura no casa con la frase citada del impreso'] += 1
                apartados.append({**ficha, 'motivo': 'costura distinta del impreso',
                                  'nota_imagen': v.get('nota', '')[:300]}); continue
            ficha['verificado'] = v.get('nota', '')[:240]
            ocupados.append((ini, fin))
            hechas.append(ficha)
            txt = nuevo
        if aplicar and pend and ocupados:
            p.write_text(txt, encoding='utf-8')

    res['SUPRIMIDO'] = len(hechas)
    print()
    for k, v in res.most_common(): print(f'  {v:5}  {k}')

    print(f'\n--- muestra de {min(muestra, len(hechas))} supresiones ---')
    for h in hechas[:muestra]:
        print(f"\n{h['file']}  p.{h['page']}   borra: {h['borrado']!r}"
              + (f"   [{', '.join(h['motivos'])}]" if h['motivos'] else ''))
        print(f"   antes: ...{h['antes']}...")
        print(f"  ahora: ...{h['despues']}...")

    json.dump(hechas, open(HERE / 'supresiones.json', 'w'), ensure_ascii=False, indent=1)
    json.dump(apartados, open(HERE / 'supresiones_apartadas.json', 'w'), ensure_ascii=False, indent=1)
    if aplicar:
        with open(HERE / 'verdicts.jsonl', 'a') as fh:
            for h in hechas:
                fh.write(json.dumps({"repo": h['borrado'].strip().lower(), "scan": "",
                                     "estado": "VERIFICADO",
                                     "razon": f"SUPRIMIDO: el impreso no lo trae (p.{h['page']}). {h['nota']}",
                                     "fecha": FECHA}, ensure_ascii=False) + '\n')
            for a in apartados:
                # los apartados vienen de dos sitios: los que no llegaron a
                # situarse (traen `texto_repo`) y los que si se situaron pero no
                # pasaron una puerta (traen `borrado` y `page`)
                txt = (a.get('texto_repo') or a.get('borrado') or '').lower()
                pag = a.get('pagina', a.get('page', '?'))
                fh.write(json.dumps({"repo": txt, "scan": "",
                                     "estado": "ABIERTO",
                                     "razon": f"el impreso no lo trae (p.{pag}), pero no se puede "
                                              f"suprimir en automatico: {a['motivo']}. "
                                              f"{(a.get('nota_imagen') or a.get('nota') or '')[:180]}",
                                     "fecha": FECHA}, ensure_ascii=False) + '\n')
        print(f'\nAPLICADO. veredictos escritos: {len(hechas) + len(apartados)}')
    else:
        print('\n(ensayo: no se ha escrito nada. Anade --aplicar)')
    return 0


if __name__ == '__main__':
    sys.exit(main())
