#!/usr/bin/env python3
"""
Genera un reporte HTML interactivo de los diffs reales de out_surya/,
con imagen de la página del PDF original embebida (base64) y checkboxes
para seleccionar qué correcciones aplicar. Incluye botón "Exportar JSON".
"""
import json, re, html, base64, sys, unicodedata
from pathlib import Path
import fitz  # PyMuPDF

import argparse

HERE = Path(__file__).resolve().parent
CFG = json.loads((HERE / "books.json").read_text())
SCAN_ROOT = Path(CFG["_scan_root"])
SURYA_DIR = HERE / "surya"

# stem -> pdf path
STEM2PDF = {}
for book, rels in CFG["books"].items():
    for rel in rels:
        p = SCAN_ROOT / rel
        if p.exists():
            STEM2PDF.setdefault(book, {})[p.stem] = p

# Cache de imágenes: (pdf_path, page_num) -> base64 jpeg
_img_cache = {}

DPI = 90          # --dpi. A 120 un report de CC-madhya se iba a ~210 MB.
JPEG_Q = 60       # --jpeg-quality
NO_IMAGES = False # --no-images

def page_image_b64(book, pageref, dpi=None):
    """Renderiza la página del PDF y devuelve data URI JPEG."""
    dpi = dpi or DPI
    if not pageref or "#" not in pageref:
        return None
    stem, pg = pageref.split("#", 1)
    m = re.search(r'(\d+)', pg)
    if not m:
        return None
    page_num = int(m.group(1))  # 1-indexed

    pdf_path = STEM2PDF.get(book, {}).get(stem)
    if not pdf_path:
        return None

    key = (str(pdf_path), page_num)
    if key in _img_cache:
        return _img_cache[key]

    try:
        doc = fitz.open(str(pdf_path))
        if page_num < 1 or page_num > len(doc):
            return None
        page = doc[page_num - 1]
        mat = fitz.Matrix(dpi / 72, dpi / 72)
        pix = page.get_pixmap(matrix=mat, colorspace=fitz.csRGB)
        jpeg_bytes = pix.tobytes("jpeg", jpg_quality=JPEG_Q)
        b64 = base64.b64encode(jpeg_bytes).decode()
        uri = f"data:image/jpeg;base64,{b64}"
        _img_cache[key] = uri
        doc.close()
        return uri
    except Exception as e:
        print(f"  warn: {pdf_path} page {page_num}: {e}", file=sys.stderr)
        return None

# ---------- texto OCR de la página ----------

WORD_RE = re.compile(r"[a-z0-9]+")

def _norm_with_map(raw):
    """Normaliza igual que diff_align_surya.tokenize() pero devolviendo un mapa
    de cada carácter normalizado -> índice en el texto crudo. Necesario porque
    strip_diacritics() usa NFKD (descompone) y desplaza los offsets."""
    out, idx = [], []
    for i, ch in enumerate(raw):
        if ch in "’‘":
            d = "'"
        else:
            d = "".join(c for c in unicodedata.normalize("NFKD", ch)
                        if not unicodedata.combining(c)).lower()
        for c in d:
            out.append(c)
            idx.append(i)
    return "".join(out), idx

def page_ocr_path(book, pageref):
    if not pageref or "#" not in pageref:
        return None
    stem, pg = pageref.split("#", 1)
    p = SURYA_DIR / book / stem / f"{pg}.surya.txt"
    return p if p.exists() else None

def next_page_path(p):
    """Siguiente página OCR dentro del mismo escaneo (orden natural del stem)."""
    pages = sorted(p.parent.glob("*.surya.txt"))
    try:
        i = pages.index(p)
    except ValueError:
        return None
    return pages[i + 1] if i + 1 < len(pages) else None

def _find_seq(words_, needle):
    if not needle:
        return -1
    for i in range(len(words_) - len(needle) + 1):
        if words_[i:i + len(needle)] == needle:
            return i
    return -1

def page_ocr_html(book, pageref, scan_str, context=""):
    """Texto OCR crudo de la página, con el fragmento del diff resaltado.
    Si el fragmento cruza de página, encadena la siguiente.

    Para REPO_EXTRA no hay texto del escaneo que resaltar (el repo AÑADE algo),
    así que se resalta el EMPALME: las palabras de antes y después del hueco.
    Si aparecen contiguas en el escaneo, queda probado que lo que el repo tiene
    de más no está en la página.

    Devuelve (html, estado, etiqueta_de_páginas)."""
    p = page_ocr_path(book, pageref)
    label = pageref.split("#")[-1] if pageref else ""
    if p is None:
        return None, "sin fichero OCR", label
    raw = p.read_text(errors="ignore")
    if not raw.strip():
        return None, "página OCR vacía (0 bytes)", label

    # el span del diff puede seguir en la página siguiente: encadenarla.
    # El separador NO puede contener [a-z0-9]: sería un token intruso que
    # partiría la secuencia buscada justo en el caso que queremos cubrir.
    SEP = "\n\n────────────────────────────\n\n"
    nxt = next_page_path(p)
    if nxt is not None and len(scan_str.split()) > 1:
        nraw = nxt.read_text(errors="ignore")
        if nraw.strip():
            raw = raw.rstrip() + SEP + nraw
            label = f"{label} + {nxt.stem.replace('.surya','')}"

    norm, idx = _norm_with_map(raw)
    toks = [(m.group(0), m.start(), m.end()) for m in WORD_RE.finditer(norm)]
    words_ = [t[0] for t in toks]

    def _mark(i, n, note):
        a = idx[toks[i][1]]
        b = idx[toks[i + n - 1][2] - 1] + 1
        return (html.escape(raw[:a]) + "<mark>" + html.escape(raw[a:b])
                + "</mark>" + html.escape(raw[b:]), note, label)

    target = scan_str.split()
    if not target:
        # REPO_EXTRA: resaltar el empalme antes+después del hueco
        m = re.match(r"(.*?)\s*\[\[(.*?)\]\]\s*(.*)", context or "")
        if not m:
            return html.escape(raw), "sin resaltado (el repo añade texto)", label
        pre_all, post_all = m.group(1).split(), m.group(3).split()
        for k in (4, 3, 2):
            pre, post = pre_all[-k:], post_all[:k]
            if len(pre) < k or len(post) < k:
                continue
            i = _find_seq(words_, pre + post)
            if i >= 0:
                # OJO: esto sólo prueba ausencia en el OCR, y el OCR NO es la
                # fuente — lo es el PDF. Surya se come palabras (visto con
                # "kṣaṇe kṣaṇe", que el impreso sí tiene). La imagen decide.
                return _mark(i, 2 * k,
                             f"el OCR empalma estas {2*k} palabras sin el texto del "
                             f"repo — NO es prueba: confírmalo en la imagen")
        return (html.escape(raw),
                "empalme no localizado — el texto del repo podría estar aquí; revisar", label)

    # buscar el prefijo más largo: si el span cruza a una página no encadenable
    # (fin de escaneo) al menos se resalta lo que haya
    for n in range(len(target), 0, -1):
        i = _find_seq(words_, target[:n])
        if i >= 0:
            note = "" if n == len(target) else f"parcial: {n}/{len(target)} palabras localizadas"
            return _mark(i, n, note)
    return html.escape(raw), "fragmento no localizado en esta página", label

def load_rows(out_dir):
    rows = []
    for f in sorted(out_dir.glob("*.adjudicated.jsonl")):
        book = f.stem.replace(".adjudicated", "")
        for line in f.read_text().splitlines():
            e = json.loads(line)
            llm = e.get("llm", {})
            if llm.get("is_real_diff"):
                rows.append({
                    "book": book,
                    "file": e.get("file", "").split("/")[-1],
                    "page": e.get("page", ""),
                    "repo": e.get("repo", ""),
                    "original_reading": llm.get("original_reading", ""),
                    "confidence": llm.get("confidence", ""),
                    "note": llm.get("note", ""),
                    "context": e.get("context", ""),
                    "span": e.get("span", []),
                })
    return rows

def load_candidates(out_dir, verdicts=("REVISAR", "REPO_TYPO",
                                       "REPO_OMISSION", "REPO_EXTRA")):
    """Carga directamente de *.candidates.jsonl sin adjudicación LLM."""
    rows = []
    for f in sorted(out_dir.glob("*.candidates.jsonl")):
        book = f.stem.replace(".candidates", "")
        for line in f.read_text().splitlines():
            if not line.strip():
                continue
            e = json.loads(line)
            if e.get("verdict") not in verdicts:
                continue
            rows.append({
                "book":             book,
                "file":             e.get("file", "").split("/")[-1],
                "page":             e.get("page", ""),
                "repo":             e.get("repo", ""),
                "original_reading": e.get("scan", ""),
                "confidence":       e.get("confidence", "medium"),
                # 'note' propio si el candidato lo trae (p.ej. una categoría de
                # triaje); si no, el veredicto del script
                "note":             e.get("note") or f"verdict={e.get('verdict','?')}",
                "context":          e.get("context", ""),
                "span":             e.get("span", []),
            })
    return rows

def render_html(rows):
    total = len(rows)
    print(f"Renderizando {total} diffs con imágenes...", file=sys.stderr)

    # Pre-render images + texto OCR
    for i, r in enumerate(rows):
        r["_img"] = None if NO_IMAGES else page_image_b64(r["book"], r["page"])
        r["_ocr"], r["_ocr_note"], r["_ocr_pages"] = page_ocr_html(
            r["book"], r["page"], r["original_reading"], r["context"])
        if (i+1) % 10 == 0:
            print(f"  {i+1}/{total} páginas procesadas", file=sys.stderr)

    parts = ["""<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Surya diff — correcciones pendientes</title>
<style>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; font-size: 13px; margin: 0; background: #f5f5f5; }
#toolbar { position: sticky; top: 0; z-index: 100; background: #1a1a2e; color: #fff;
           padding: 10px 16px; display: flex; align-items: center; gap: 12px; }
#toolbar h1 { margin: 0; font-size: 16px; }
#toolbar .count { background: #e94560; border-radius: 12px; padding: 2px 10px; font-weight: bold; }
button { cursor: pointer; padding: 6px 14px; border: none; border-radius: 6px;
         font-size: 13px; font-weight: bold; }
#btn-all  { background: #16213e; color: #fff; border: 1px solid #aaa; }
#btn-none { background: #16213e; color: #fff; border: 1px solid #aaa; }
#btn-export { background: #4caf50; color: #fff; }
#btn-export:hover { background: #388e3c; }
#counter { font-size: 13px; }
.cards { padding: 12px 16px; display: flex; flex-direction: column; gap: 10px; }
.card { background: #fff; border-radius: 8px; border: 2px solid #ddd;
        display: grid; grid-template-columns: """ + ("32px 1fr 460px" if NO_IMAGES else "32px 1fr 340px 320px") + """; gap: 0;
        overflow: hidden; transition: border-color 0.15s; }
.card.checked { border-color: #4caf50; }
.cb-cell { display: flex; align-items: center; justify-content: center;
           padding: 10px; background: #fafafa; border-right: 1px solid #eee; }
.cb-cell input[type=checkbox] { width: 18px; height: 18px; cursor: pointer; }
.info { padding: 10px 14px; }
.info .header { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; margin-bottom: 6px; }
.book-tag { background: #e3f2fd; color: #1565c0; border-radius: 4px;
            padding: 2px 7px; font-size: 11px; font-weight: bold; }
.file-tag { color: #666; font-size: 11px; }
.conf-tag { font-size: 11px; padding: 2px 7px; border-radius: 4px; font-weight: bold; }
.conf-high { background: #e8f5e9; color: #2e7d32; }
.conf-medium { background: #fff8e1; color: #f57f17; }
.conf-low { background: #fce4ec; color: #b71c1c; }
.diff-row { display: flex; gap: 8px; align-items: center; margin: 4px 0; font-size: 14px; }
.repo-val { background: #ffebee; color: #c62828; padding: 3px 8px; border-radius: 4px;
            font-weight: bold; text-decoration: line-through; }
.arrow { color: #999; font-size: 16px; }
.orig-val { background: #e8f5e9; color: #1b5e20; padding: 3px 8px; border-radius: 4px;
            font-weight: bold; }
.ctx { font-size: 11px; color: #777; margin-top: 4px; }
.ctx b { background: #fff9c4; color: #333; padding: 0 2px; }
.note { font-size: 11px; color: #555; margin-top: 5px; border-left: 3px solid #ddd; padding-left: 8px; }
.ocr-cell { border-left: 1px solid #eee; background: #fbfbfd; display: flex;
            flex-direction: column; min-width: 0; }
.ocr-cell .ocr-head { font-size: 10px; color: #888; text-transform: uppercase;
                      letter-spacing: .5px; padding: 6px 10px 2px; flex: none; }
.ocr-cell .ocr-note { font-size: 10px; color: #b71c1c; padding: 0 10px 4px; flex: none; }
.ocr-cell pre { margin: 0; padding: 4px 10px 10px; font-family: ui-monospace, Menlo, monospace;
                font-size: 10.5px; line-height: 1.45; white-space: pre-wrap;
                word-break: break-word; overflow-y: auto; max-height: 420px; color: #333; }
.ocr-cell mark { background: #ffe082; color: #000; font-weight: bold;
                 box-shadow: 0 0 0 2px #ffe082; }
.ocr-cell .no-ocr { display: flex; align-items: center; justify-content: center;
                    height: 100%; color: #aaa; font-size: 11px; padding: 20px; text-align: center; }
.page-cell { border-left: 1px solid #eee; overflow: hidden; }
.page-cell img { width: 100%; display: block; object-fit: contain; max-height: 420px; }
.page-cell .no-img { display: flex; align-items: center; justify-content: center;
                     height: 100%; color: #aaa; font-size: 12px; padding: 20px; text-align: center; }
#json-out { display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%);
            background: #fff; border: 2px solid #333; border-radius: 8px; padding: 20px;
            width: 80vw; max-height: 80vh; overflow: auto; z-index: 9999; }
#json-out h3 { margin: 0 0 10px; }
#json-out textarea { width: 100%; height: 400px; font-family: monospace; font-size: 12px; }
#json-close { float: right; background: #e53935; color: #fff; }
</style>
</head>
<body>
<div id="toolbar">
  <h1>Surya OCR diff</h1>
  <span class="count" id="counter">0 seleccionados</span>
  <button id="btn-all" onclick="selectAll()">Seleccionar todo</button>
  <button id="btn-none" onclick="selectNone()">Deseleccionar todo</button>
  <button id="btn-export" onclick="exportJson()">Exportar JSON seleccionados</button>
  <span style="margin-left:auto;color:#aaa;font-size:12px">""" + f"{total} diffs reales" + """</span>
</div>
<div class="cards" id="cards">
"""]

    DATA = []  # para el export JS
    for i, r in enumerate(rows):
        conf_cls = f"conf-{r['confidence']}" if r['confidence'] in ('high','medium','low') else 'conf-medium'
        ctx = r['context'].replace('[[', '<b>').replace(']]', '</b>')
        img_tag = (f'<img src="{r["_img"]}" alt="scan page" loading="lazy">'
                   if r["_img"] else '<div class="no-img">sin imagen</div>')
        page_label = r['page'].split('#')[-1] if r['page'] else ''
        if r["_ocr"] is not None:
            ncls = ""  # el panel OCR nunca "confirma" nada: la fuente es el PDF
            note = (f'<div class="ocr-note{ncls}">{html.escape(r["_ocr_note"])}</div>'
                    if r["_ocr_note"] else '')
            ocr_tag = (f'<div class="ocr-head">texto OCR — {html.escape(r["_ocr_pages"])}</div>'
                       + note + f'<pre>{r["_ocr"]}</pre>')
        else:
            ocr_tag = f'<div class="no-ocr">{html.escape(r["_ocr_note"])}</div>'

        parts.append(f"""<div class="card" id="card-{i}">
  <div class="cb-cell">
    <input type="checkbox" id="cb-{i}" onchange="toggle({i})">
  </div>
  <div class="info">
    <div class="header">
      <span class="book-tag">{html.escape(r['book'])}</span>
      <span class="file-tag">{html.escape(r['file'])}</span>
      <span class="file-tag" style="color:#999">{html.escape(page_label)}</span>
      <span class="conf-tag {conf_cls}">{html.escape(r['confidence'])}</span>
    </div>
    <div class="diff-row">
      <span class="repo-val">{html.escape(r['repo'])}</span>
      <span class="arrow">→</span>
      <span class="orig-val">{html.escape(r['original_reading'])}</span>
    </div>
    <div class="ctx">{ctx}</div>
    <div class="note">{html.escape(r['note'])}</div>
  </div>
  <div class="ocr-cell">{ocr_tag}</div>
  {'' if NO_IMAGES else f'<div class="page-cell">{img_tag}</div>'}
</div>""")

        DATA.append({
            "book": r['book'],
            "file": r['file'],
            "page": r['page'],
            "repo": r['repo'],
            "original_reading": r['original_reading'],
            "confidence": r['confidence'],
            "note": r['note'],
            "context": r['context'],
        })

    parts.append(f"""</div>
<div id="json-out">
  <button id="json-close" onclick="document.getElementById('json-out').style.display='none'">✕ Cerrar</button>
  <h3>JSON de correcciones seleccionadas</h3>
  <textarea id="json-text" readonly></textarea>
</div>
<script>
const DATA = {json.dumps(DATA, ensure_ascii=False)};
let selected = new Set();

function toggle(i) {{
  const cb = document.getElementById('cb-' + i);
  const card = document.getElementById('card-' + i);
  if (cb.checked) {{ selected.add(i); card.classList.add('checked'); }}
  else {{ selected.delete(i); card.classList.remove('checked'); }}
  document.getElementById('counter').textContent = selected.size + ' seleccionados';
}}

function selectAll() {{
  document.querySelectorAll('input[type=checkbox]').forEach((cb, i) => {{
    cb.checked = true; selected.add(i);
    document.getElementById('card-' + i).classList.add('checked');
  }});
  document.getElementById('counter').textContent = selected.size + ' seleccionados';
}}

function selectNone() {{
  document.querySelectorAll('input[type=checkbox]').forEach((cb, i) => {{
    cb.checked = false; selected.delete(i);
    document.getElementById('card-' + i).classList.remove('checked');
  }});
  document.getElementById('counter').textContent = '0 seleccionados';
}}

function exportJson() {{
  const out = [...selected].sort((a,b)=>a-b).map(i => DATA[i]);
  document.getElementById('json-text').value = JSON.stringify(out, null, 2);
  document.getElementById('json-out').style.display = 'block';
}}
</script>
</body></html>""")

    return "\n".join(parts)

if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--dir", default="out_surya")
    ap.add_argument("--out", default=None)
    ap.add_argument("--candidates", action="store_true",
                    help="Leer candidates.jsonl (REVISAR/REPO_TYPO) en vez de adjudicated.jsonl")
    ap.add_argument("--book", default=None,
                    help="Filtrar un solo libro (slug)")
    ap.add_argument("--dpi", type=int, default=DPI,
                    help=f"DPI de las imágenes (default {DPI}). Es TODO el peso del report.")
    ap.add_argument("--jpeg-quality", type=int, default=JPEG_Q,
                    help=f"Calidad JPEG (default {JPEG_Q})")
    ap.add_argument("--no-images", action="store_true",
                    help="Sin imágenes: report ligero, sólo texto OCR. OJO: la "
                         "fuente de verdad es el PDF, así que un report sin foto "
                         "sirve para TRIAR, no para decidir.")
    a = ap.parse_args()
    DPI = a.dpi
    JPEG_Q = a.jpeg_quality
    NO_IMAGES = a.no_images
    out_dir = Path(a.dir)
    out_path = Path(a.out) if a.out else out_dir / "report_surya_interactive.html"
    rows = load_candidates(out_dir) if a.candidates else load_rows(out_dir)
    if a.book:
        rows = [r for r in rows if r["book"] == a.book]
    html_content = render_html(rows)
    out_path.write_text(html_content, encoding="utf-8")
    size_mb = out_path.stat().st_size / 1_000_000
    print(f"\nEscrito: {out_path} ({size_mb:.1f} MB, {len(rows)} diffs)", file=sys.stderr)
