#!/usr/bin/env python3
"""Libro de registro unico de la auditoria contra los escaneos.

Problema que resuelve: el numero de candidatos del diff NO baja cuando corriges,
asi que cada sesion corre el riesgo de volver a adjudicar lo mismo. Este script
cruza los candidatos con todo lo que ya se decidio y escribe un solo fichero,
ledger.jsonl, con un estado por candidato:

  APLICADO   el texto del repo que reportaba ya no existe, o coincide con el
             'old' de una entrada de corrections.jsonl para ese fichero.
  DESCARTADO figura en dismissed_entries.json o en las listas descartadas*.md
             de scan_vedabase.
  RUIDO      cae en una clase de ruido documentada (ver REGLAS).
  VERIFICADO lectura del impreso ya comprobada por imagen: NO volver a proponer.
  ABIERTO    lo unico que queda por mirar.

Uso:  python3 build_ledger.py            # reconstruye ledger.jsonl + resumen
      python3 build_ledger.py --abiertos <libro>   # lista los ABIERTOS de un libro
"""
import json, re, glob, os, sys, collections, pathlib

HERE = pathlib.Path(__file__).resolve().parent
REPO = pathlib.Path('/Users/jaganat/git_projects/vedabase-original')
SCAN = pathlib.Path('/Users/jaganat/git_projects/scan_vedabase')
OUT_PROSA = HERE / 'out_surya'
OUT_SYN = HERE / 'out_syn'
# Corridas del alineador de julio que quedaron fuera del ledger. Eran 459
# candidatos que nadie habia visto —155 unicos— y no eran ruido: ahi salieron
# 'word' -> 'work', 'hope' -> 'hopes', 'vyaktah' -> 'avyaktah'. A diferencia de
# las pasadas principales, estas alcanzan tambien el VERSO. Si algun dia se
# generan mas carpetas de salida, hay que anadirlas AQUI o se quedaran mudas.
OUT_EXTRA = [HERE / d for d in ('out_surya2', 'out_surya3', 'out_surya4', 'out_surya5')]
# escaneos declarados en books.json que NO son la edicion del repo
NO_MATCH = {x.replace('.pdf', '') for x in
            json.loads((HERE / 'books.json').read_text(encoding='utf-8')).get('no_repo_match', [])}
LEDGER = HERE / 'ledger.jsonl'

norm = lambda s: re.sub(r'\s+', ' ', re.sub(r'[^\w\s]', '', (s or '').lower())).strip()

# ── Lecturas del impreso ya verificadas por imagen (tasks.org). No reproponer. ──
VERIFICADAS = [
    ('cc', 'calves'), ('cc', 'cows'),          # adi 1.31: discrepancia interna del impreso
    ('cc', 'appears'),                          # adi 1.230, presente las dos veces
    ('cc', 'verse 43'), ('cc', 'verse 44'), ('cc', 'verse 45'),   # no 'sutra'
    ('cc', 'benedicted'),                       # no 'blessed'
    ('cc', 'auranzeb'),                         # sin la g, unica ocurrencia
    ('cc', 'tinebheli'),                        # no 'Tirunelveli', las cuatro veces
    ('cc', 'cintamani'), ('cc', 'anadih'),      # el impreso se contradice: repo correcto
]

# ── Clases de ruido confirmadas (tasks.org). Cada una es (nombre, test). ──
CABECERAS = re.compile(r'\b(the nectar of devotion|teachings of lord chaitanya|'
                       r'srimad bhagavatam|bhagavad gita|the science of self realization|'
                       r'light of the bhagavata|further consideration of devotional principles|'
                       r'techniques of hearing and memorizing)\b')
FUNCIONALES = {'that','when','also','much','the','a','of','and','is','in','to','it','as','be','for'}

# Palabras funcionales inglesas: si una supuesta OMISION no tiene ninguna, no es
# prosa que falte sino la transliteracion sanscrita de un blockquote, que el
# alineador excluye a proposito y por eso reporta siempre.
INGLES = {'the','of','and','to','in','is','that','a','he','his','for','it','with','as','was',
          'by','not','this','be','are','from','but','they','which','one','has','have','who',
          'their','you','all','can','there','when','we','on','at','so','if','my','me','him'}


def es_translit(s):
    ws = [w for w in re.findall(r"[a-z']+", (s or '').lower()) if len(w) > 1]
    return len(ws) >= 5 and not any(w in INGLES for w in ws)


# Cabecera de pagina corrida DENTRO del texto: el impreso lleva 'Canto 4, Ch. 9'
# o 'Srimad-Bhagavatam' en la cabecera y el alineador la mete en medio de la
# palabra partida por el salto ('step canto 4 ch 9 mother').
CABECERA_DENTRO = re.compile(r'\bcanto\s+\d+\s+ch\s+\d+\b|\bch\s+\d+\s*\]|'
                             r'\btext\s+\d+\]|\bsrimad\s+bhagavatam\b|\bteachings of lord\b')
# Etiqueta de cita: el repo escribe 'Bg. 7.15' donde el impreso pone el verso.
CITA = re.compile(r'^(bg|sb|cc|bs|iso|noi|mu|cs|bhag|sri|srimad|cc adi|cc madhya|cc antya)'
                  r'[\s\d\.\-]*$')
# Palabras funcionales: una o dos sueltas que sobran o faltan son ruido del
# alineador, no defectos. Lista amplia, no la docena de antes.
FUNCION = set('''a an the of to in and or but is are was were be been being am
this that these those he she it they we you i him her them us my your his its
their our as at by for from into on with within without upon over under about
not no nor so if then than when while who whom whose which what where how why
have has had do does did done can could will would shall should may might must
there here also only just even still yet very much more most such some any all
one two three both each other another same own again further once because since
until although though whereas thus therefore hence however moreover'''.split())


def solo_funcionales(x):
    ws = re.findall(r"[a-z']+", (x or '').lower())
    return bool(ws) and len(ws) <= 2 and all(w in FUNCION for w in ws)


# Palabra rota por el OCR: el impreso separa letras dentro de una palabra
# ('k sna' por 'krsna', 'vivar' por 'vivarta'). Se detecta porque el lado del
# impreso, sin espacios, es prefijo o subcadena del lado del repo sin espacios.
def ocr_rompe_palabra(r, s):
    a = re.sub(r'\s+', '', r or ''); b = re.sub(r'\s+', '', s or '')
    if len(a) < 4 or len(b) < 3: return False
    return (b in a or a.startswith(b)) and ' ' in (s or '') and ' ' not in (r or '')


# Titulos de capitulo: el repo los lleva como encabezado y el impreso no los
# repite en el cuerpo, asi que el alineador los da por sobrantes.
_TIT = set()
try:
    _t = json.load(open(HERE.parent.parent / 'src/lib/chapter-names.json'))
    _TIT = {re.sub(r'[^a-z ]', '', v.lower()).strip() for v in _t.values()}
except Exception:
    pass
CITA2 = re.compile(r'^(iso mantra|brs|bs|cc|sb|bg|noi|nod|katha|mundaka|svet)[\s\d\.\-]*$')
# Cabecera/numeracion del impreso colada como omision.
CABECERA_SUELTA = re.compile(r'^[\divxl\s]+$|as it is|bhagavad as it is|ch \d+|^\d{2,4}\b')
PURPORT_ROTO = re.compile(r'^p[uo]{1,3}rport')


def cabecera_partida(r, s):
    """La cabecera del impreso cae dentro de una palabra partida por el salto de
    pagina: 'everyone' -> 'every toward a peaceful society 35 one'. Se comprueba
    quitando un tramo central del lado del impreso: si los extremos reconstruyen
    exactamente la palabra del repo, es eso y no un defecto."""
    a = re.sub(r'\s+', '', (r or '').lower())
    tk = (s or '').lower().split()
    if len(tk) < 3 or len(a) < 4: return False
    for i in range(1, len(tk)):
        for j in range(i + 1, len(tk) + 1):
            if ''.join(tk[:i]) + ''.join(tk[j:]) == a:
                medio = tk[i:j]
                if len(medio) >= 2 or any(x.isdigit() for x in ''.join(medio)):
                    return True
    return False


def reglas_ruido(r, s):
    rr = (r or '').strip(); ss = (s or '').strip()
    if cabecera_partida(r, s):
        return 'cabecera del impreso dentro de una palabra partida por el salto de pagina'
    if cabecera_partida(s, r):
        return 'cabecera del impreso dentro de una palabra partida por el salto de pagina'
    if (rr and not ss and len(rr) <= 2) or (ss and not rr and len(ss) <= 2):
        return 'letra suelta del alineador'
    if not rr and PURPORT_ROTO.match(ss.lower()):
        return 'la palabra PURPORT del impreso, mal reconocida'
    if not rr and CABECERA_SUELTA.search(ss.lower()):
        return 'cabecera o numeracion de pagina del impreso'
    if not ss and re.sub(r'[^a-z ]', '', rr.lower()).strip() in _TIT:
        return 'titulo de capitulo del repo, que el impreso no repite en el cuerpo'
    if not ss and CITA2.match(rr.lower()):
        return 'etiqueta de cita que el repo anade (convencion)'
    if ocr_rompe_palabra(r, s):
        return 'palabra partida por el OCR dentro del escaneo'
    if CABECERA_DENTRO.search(s or ''):
        return 'cabecera de pagina corrida dentro del texto (palabra partida por el salto)'
    if CITA.match((r or '').strip().lower()) and not (s or '').strip():
        return 'etiqueta de cita que el repo anade y el impreso no lleva (convencion)'
    if CITA.match((r or '').strip().lower()) and re.match(r'^[\d\s\.]+', (s or '').strip()):
        return 'etiqueta de cita del repo frente al verso del impreso (convencion)'
    if not (s or '').strip() and solo_funcionales(r):
        return 'palabra funcional suelta que el alineador da por sobrante'
    if not (r or '').strip() and solo_funcionales(s):
        return 'palabra funcional suelta que el alineador da por ausente'
    if not r and es_translit(s):
        return 'transliteracion sanscrita de blockquote (el diff la excluye a proposito)'
    if r and s and len(r) == len(s) and r.replace('rn', 'm') == s.replace('rn', 'm'):
        return 'OCR rn->m'
    if 'disciplic' in r or 'disciplic' in s:
        return 'disciplic->discipline (falso confirmado 23/23 en CC, 9/9 en KB)'
    if ('bhag' in s and 'sb' in r) or ('bhag' in r and 'sb' in s):
        return 'convencion de cita sb->bhag'
    if re.fullmatch(r'[\dixvlcm\s]*', s or '') and not r:
        return 'numero de pagina o romano suelto'
    if CABECERAS.search(s or '') and not r:
        return 'cabecera de pagina corrida'
    if (r in FUNCIONALES and not s) or (s in FUNCIONALES and not r):
        return 'palabra funcional suelta'
    if re.fullmatch(r'(bg|sb|cc|bs|iso|noi)[\s\d]+', r or '') and not s:
        return 'etiqueta de cita que el impreso no lleva'
    return None

def cargar_veredictos():
    """verdicts.jsonl: lo que CUALQUIER sesion haya adjudicado ya.

    Formato, una linea por candidato:
      {"repo": "...", "scan": "...", "estado": "DESCARTADO|APLICADO|VERIFICADO",
       "razon": "...", "fecha": "2026-08-07"}
    Es append-only: escribe aqui en cuanto decidas algo, aunque la decision sea
    'no tocar'. Es lo unico que impide que la siguiente sesion lo re-adjudique.
    """
    v = {}
    p = HERE / 'verdicts.jsonl'
    if p.exists():
        for line in open(p, encoding='utf-8'):
            line = line.strip()
            if not line:
                continue
            try:
                d = json.loads(line)
            except Exception:
                continue
            v[(norm(d.get('repo')), norm(d.get('scan')))] = (d.get('estado', 'DESCARTADO'),
                                                             d.get('razon', 'veredicto registrado'))
    return v


def cargar_descartes():
    """dismissed_entries.json + las listas descartadas*.md de scan_vedabase."""
    fuera = set()
    p = HERE / 'dismissed_entries.json'
    if p.exists():
        for e in json.load(open(p)):
            fuera.add((norm(e.get('repo')), norm(e.get('scan_reading'))))
    for md in glob.glob(str(SCAN / 'descartadas*.md')):
        for line in open(md, encoding='utf-8'):
            m = re.search(r'«(.+?)».{0,12}«(.+?)»', line)
            if m:
                fuera.add((norm(m.group(1)), norm(m.group(2))))
    return fuera

def cargar_aplicadas():
    """corrections.jsonl -> por fichero, el conjunto de textos 'old' ya sustituidos."""
    porfich = collections.defaultdict(set)
    todos = set()
    f = REPO / 'corrections.jsonl'
    for line in open(f, encoding='utf-8'):
        line = line.strip()
        if not line:
            continue
        try:
            d = json.loads(line)
        except Exception:
            continue
        for par in (d.get('replace') or {}).values():
            if isinstance(par, list) and par:
                old = norm(par[0])
                if len(old) > 8:
                    todos.add(old)
                    if d.get('file'):
                        porfich[d['file']].add(old)
    return porfich, todos

_cache = {}
def texto_repo(f):
    if f not in _cache:
        p = REPO / f
        _cache[f] = norm(p.read_text(encoding='utf-8')) if p.exists() else None
    return _cache[f]

# ── Cubetas ya cerradas: no volver a adjudicarlas ──────────────────────────
# La auditoria word-for-word de SYNONYMS se corrio hasta el final (158/158 lotes
# + 107/107 del bucket OCR_NOISE, ver SYNONYMS_AUDIT_HANDOFF.md) y dejo 2.100
# correcciones con field=synonyms en corrections.jsonl (sb 1.264, cc 755,
# iso 37, bg 17). Sus candidatos NO son trabajo pendiente.
CERRADAS = {('sinonimos', 'sri-caitanya-caritamrta'), ('sinonimos', 'srimad-bhagavatam'),
            ('sinonimos', 'isopanisad'), ('sinonimos', 'bhagavad-gita-as-it-is')}


def clasificar(c, descartes, porfich, aplicadas_todas, tipo='prosa', libro_id=None, veredictos=None):
    r, s = norm(c.get('repo')), norm(c.get('scan'))
    fich = c.get('file') or ''
    libro = (c.get('book') or fich.split('/')[0])
    # Un veredicto solo manda si es TERMINAL. Los informativos ('ABIERTO' con el
    # motivo de por que hace falta imagen) no pueden reabrir lo ya cerrado: al
    # dejarlos mandar, 343 candidatos de la auditoria de sinonimos volvieron a
    # contarse como pendientes.
    v = veredictos.get((r, s)) if veredictos else None
    # RUIDO tambien es TERMINAL cuando viene de un veredicto: significa que
    # alguien miro la imagen y el impreso coincide con el repo. Al no estar en
    # esta lista, los 142 cierres de la cola de abiertos caian al ramal
    # informativo de abajo y seguian contando como ABIERTO, con la razon puesta
    # («coincide con el repo») pero sin cerrarse.
    # AUDITADO tambien es TERMINAL cuando viene de un veredicto, por el mismo
    # motivo que RUIDO: significa que el candidato ya se clasifico en una
    # cubeta con nombre (p.ej. «TLC 1968: transliteracion de la primera
    # edicion»). Sin esto los 29 del rediff de agosto seguian contando como
    # abiertos con la razon puesta pero sin cerrarse.
    if v and v[0] in ('APLICADO', 'DESCARTADO', 'VERIFICADO', 'RUIDO', 'AUDITADO'):
        return v
    if (tipo, libro_id) in CERRADAS and '--sin-cubetas' not in sys.argv:
        return 'AUDITADO', 'auditoria de synonyms cerrada al 100% (ver SYNONYMS_AUDIT_HANDOFF.md)'
    t = texto_repo(fich)
    if t is None:
        return 'SIN_FICHERO', 'el fichero del candidato no existe en el repo'
    if r and len(r) > 12 and r not in t:
        return 'APLICADO', 'el texto del repo que reportaba ya no existe'
    if r and (r in porfich.get(fich, ()) or r in aplicadas_todas):
        return 'APLICADO', 'figura como old en corrections.jsonl'
    if (r, s) in descartes:
        return 'DESCARTADO', 'ya descartado en dismissed_entries/descartadas'
    for lb, frag in VERIFICADAS:
        if libro.startswith(lb) and (frag in r or frag in s):
            return 'VERIFICADO', f'lectura del impreso ya comprobada por imagen ({frag})'
    ruido = reglas_ruido(r, s)
    if ruido:
        return 'RUIDO', ruido
    if v:                      # informativo: por que sigue abierto
        return 'ABIERTO', v[1]
    return 'ABIERTO', ''

def main():
    descartes = cargar_descartes()
    veredictos = cargar_veredictos()
    porfich, aplicadas = cargar_aplicadas()
    print(f'veredictos previos registrados: {len(veredictos)}')
    filas = []
    fuentes = [(OUT_PROSA, 'prosa'), (OUT_SYN, 'sinonimos')]
    fuentes += [(d, 'verso') for d in OUT_EXTRA if d.exists()]
    for carpeta, tipo in fuentes:
        for f in sorted(glob.glob(str(carpeta / '*.candidates.jsonl'))):
            libro = os.path.basename(f).replace('.candidates.jsonl', '')
            for i, line in enumerate(open(f, encoding='utf-8')):
                line = line.strip()
                if not line:
                    continue
                # ESCANEO QUE NO ES LA MISMA EDICION. books.json declara en
                # `no_repo_match` los PDF cuyo texto no corresponde al del repo:
                # la Easy Journey de la India (~1960) escribe «the above three
                # points» donde el repo dice «these propositions», y ademas
                # «Bhagwat Geeta», «Sreemad Bhagbatam». Un candidato sacado de
                # ahi no es una divergencia: es otra edicion. Aplicarlos habria
                # reescrito el capitulo entero.
                try:
                    _d = json.loads(line)
                    if (_d.get('page') or '').split('#')[0] in NO_MATCH: continue
                except Exception:
                    pass
                try:
                    c = json.loads(line)
                except Exception:
                    continue
                estado, razon = clasificar(c, descartes, porfich, aplicadas, tipo, libro, veredictos)
                filas.append({'tipo': tipo, 'libro': libro, 'idx': i,
                              'file': c.get('file'), 'page': c.get('page'),
                              'verdict': c.get('verdict'), 'repo': c.get('repo'),
                              'scan': c.get('scan'), 'estado': estado, 'razon': razon})
    with open(LEDGER, 'w', encoding='utf-8') as fh:
        for x in filas:
            fh.write(json.dumps(x, ensure_ascii=False) + '\n')

    cnt = collections.Counter(x['estado'] for x in filas)
    print(f'ledger.jsonl: {len(filas)} candidatos')
    for k, v in cnt.most_common():
        print(f'  {v:6}  {k}')
    print()
    print(f"{'LIBRO':42} {'total':>6} {'ABIERTOS':>9}")
    porlibro = collections.defaultdict(lambda: collections.Counter())
    for x in filas:
        porlibro[x['libro']]['total'] += 1
        porlibro[x['libro']][x['estado']] += 1
    for b, c in sorted(porlibro.items(), key=lambda x: -x[1]['ABIERTO']):
        print(f"{b:42} {c['total']:6} {c['ABIERTO']:9}")

if __name__ == '__main__':
    if len(sys.argv) > 2 and sys.argv[1] == '--abiertos':
        for line in open(LEDGER, encoding='utf-8'):
            x = json.loads(line)
            if x['estado'] == 'ABIERTO' and x['libro'] == sys.argv[2]:
                print(f"{x['page']}  REPO={x['repo']!r}  IMPRESO={x['scan']!r}")
    else:
        main()
