"""
@RAG_CONTEXT
{
  "archivo": "src/ocr_indexer/searcher.py",
  "lineas": 243,
  "tokens_estimados": 2319,
  "hash_contenido": "08650154c5910f88a659ed32560bec984ff4838e5d677dac7ed38d9aa47239d6",
  "creacion": "2026-03-13",
  "modificacion": "2026-03-17",
  "tags": [
    "re",
    "json",
    "sys",
    "os"
  ],
  "descripcion_corta": "Motor de búsqueda con índice invertido para OCR",
  "descripcion_larga": "Script Python que implementa un motor de búsqueda sobre documentos OCR indexados. Construye y mantiene un índice invertido (token → lista de archivos) con normalización de texto mediante tolerancias de caracteres. Soporta búsquedas booleanas con grupos OR/AND, resaltado de múltiples coincidencias en resultados, y reconstrucción automática del índice cuando la base de datos OCR se actualiza. Maneja persistencia del índice en JSON con validación de frescura."
}
"""
#!/usr/bin/env python3
import sys
import json
import os
import re

GREEN = '\033[0;32m'
BLUE = '\033[0;34m'
YELLOW = '\033[1;33m'
RED = '\033[0;31m'
NC = '\033[0m'

def aplicar_tolerancias(texto, tolerancias):
    for orig, reemp in tolerancias.items():
        texto = texto.replace(orig, reemp)
    return texto

def resaltar_multiples(snippet, terminos, tolerancias):
    """Pinta de verde TODAS las palabras que coincidan en el recorte"""
    snippet_norm = aplicar_tolerancias(snippet.lower(), tolerancias)
    matches = []

    for t in terminos:
        t_n = aplicar_tolerancias(t.lower(), tolerancias)
        if not t_n: continue
        start = 0
        while True:
            idx = snippet_norm.find(t_n, start)
            if idx == -1: break
            matches.append((idx, idx + len(t_n)))
            start = idx + len(t_n)

    if not matches: return snippet

    # Limpiar empalmes para no corromper los colores
    matches.sort()
    filtered = []
    last_end = -1
    for start, end in matches:
        if start >= last_end:
            filtered.append((start, end))
            last_end = end

    # Reconstruir el texto con los colores
    res = ""
    last_idx = 0
    for start, end in filtered:
        res += snippet[last_idx:start]
        res += f"{GREEN}{snippet[start:end]}{NC}{YELLOW}"
        last_idx = end
    res += snippet[last_idx:]
    return res


def construir_invertido(db, tolerancias, min_len=3):
    """Construye índice invertido: token_normalizado → [filepath, ...]"""
    invertido = {}
    for filepath, data in db.items():
        if not isinstance(data, dict) or data.get("estado") == "pendiente":
            continue
        texto = data.get("contenido", {}).get("texto_ocr", "") or ""
        nombre = data.get("archivo", {}).get("nombre", "") or ""
        combinado = aplicar_tolerancias(texto + " " + nombre, tolerancias).lower()
        for token in re.split(r'\W+', combinado):
            if len(token) >= min_len:
                if token not in invertido:
                    invertido[token] = []
                if filepath not in invertido[token]:
                    invertido[token].append(filepath)
    return invertido


def cargar_o_reconstruir_invertido(index_path, inverted_path, db, tolerancias):
    """Carga inverted.json si está vigente; reconstruye si ocr-indexer.json es más nuevo."""
    if os.path.exists(inverted_path):
        if os.path.getmtime(index_path) <= os.path.getmtime(inverted_path):
            try:
                with open(inverted_path, 'r') as f:
                    return json.load(f)
            except Exception:
                pass  # Corrupto → reconstruir

    invertido = construir_invertido(db, tolerancias)
    try:
        with open(inverted_path, 'w') as f:
            json.dump(invertido, f)
    except Exception:
        pass  # Si no se puede guardar, operar en memoria igual
    return invertido


def buscar_candidatos(or_groups, invertido, tolerancias):
    """Retorna el set de filepaths candidatos usando el índice invertido."""
    candidatos = set()
    for and_group in or_groups:
        grupo_candidatos = None
        for termino in and_group:
            termino_norm = aplicar_tolerancias(termino, tolerancias).lower()
            filepaths = set(invertido.get(termino_norm, []))
            if grupo_candidatos is None:
                grupo_candidatos = filepaths
            else:
                grupo_candidatos &= filepaths
            if not grupo_candidatos:
                break
        if grupo_candidatos:
            candidatos |= grupo_candidatos
    return candidatos


def main():
    if len(sys.argv) < 2:
        print(f"{RED}Error interno: Falta la ruta del índice.{NC}")
        sys.exit(1)

    index_path = sys.argv[1]
    engine_dir = os.path.dirname(index_path)
    config_path = os.path.join(engine_dir, 'config.json')
    inverted_path = os.path.join(engine_dir, 'inverted.json')

    try:
        with open(index_path, 'r') as f: db = json.load(f)
    except:
        print(f"{RED}Error al leer el índice.{NC}")
        sys.exit(1)

    try:
        with open(config_path, 'r') as f: config = json.load(f)
        tolerancias = config.get("tolerancias", {"O": "0", "0": "O"})
    except:
        tolerancias = {"O": "0", "0": "O"}

    invertido = cargar_o_reconstruir_invertido(index_path, inverted_path, db, tolerancias)

    while True:
        print(f"\n{BLUE}===================================================={NC}")
        print(f"{YELLOW}                 BUSCADOR OCR LOCAL                 {NC}")
        print(f"{BLUE}===================================================={NC}")

        print(f"{GREEN}💡 Tip: Usa && (AND) y || (OR). Ej: Razo && 7064 || Lona{NC}")
        entrada_original = input(f"{YELLOW}Búsqueda avanzada (o 'salir'): {NC}").strip()

        if not entrada_original or entrada_original.lower() == 'salir':
            print("Saliendo del buscador...")
            break

        # Reemplazar la coma por OR para mantener compatibilidad
        query_parsed = entrada_original.replace(',', '||')

        # Procesamiento Booleano
        or_groups_raw = query_parsed.split('||')
        or_groups = []
        for og in or_groups_raw:
            and_terms = [t.strip() for t in og.split('&&') if t.strip()]
            if and_terms:
                or_groups.append(and_terms)

        if not or_groups: continue

        # Fase 1: Pre-filtrado O(1) con índice invertido
        candidatos = buscar_candidatos(or_groups, invertido, tolerancias)

        # Fase 2: Verificación booleana exacta + extracción de snippets
        resultados = []

        for filepath in candidatos:
            data = db.get(filepath)
            if not isinstance(data, dict) or data.get("estado") == "pendiente": continue

            contenido = data.get("contenido", {})
            texto_original = contenido.get("texto_ocr", "") if isinstance(contenido, dict) else ""
            archivo_info = data.get("archivo", {})
            nombre_original = archivo_info.get("nombre", "") if isinstance(archivo_info, dict) else ""

            texto_con_tol = aplicar_tolerancias(texto_original, tolerancias)
            nombre_con_tol = aplicar_tolerancias(nombre_original, tolerancias)
            texto_norm = texto_con_tol.lower()
            nombre_norm = nombre_con_tol.lower()

            match_found = False
            terminos_exitosos = []
            termino_para_centro = ""

            for and_group in or_groups:
                group_match = True

                for termino in and_group:
                    termino_con_tol = aplicar_tolerancias(termino, tolerancias)
                    termino_norm = termino_con_tol.lower()

                    if termino_norm not in texto_norm and termino_norm not in nombre_norm:
                        group_match = False
                        break

                if group_match:
                    match_found = True
                    terminos_exitosos = and_group
                    termino_para_centro = and_group[0]
                    break

            if match_found:
                termino_centro_norm = aplicar_tolerancias(termino_para_centro.lower(), tolerancias)
                idx = texto_norm.find(termino_centro_norm)

                if idx != -1:
                    # Lente ampliado a 80 caracteres hacia atrás y hacia adelante
                    inicio = max(0, idx - 80)
                    fin = min(len(texto_original), idx + len(termino_centro_norm) + 80)
                    snippet = texto_original[inicio:fin].replace('\n', ' ')

                    # Usamos la nueva función para pintar TODAS las palabras
                    snippet_resaltado = resaltar_multiples(snippet, terminos_exitosos, tolerancias)
                    snippet_final = f"...{snippet_resaltado}..."
                else:
                    snippet_final = "Coincidencia hallada en el nombre del archivo."

                ext = archivo_info.get('extension', '') if isinstance(archivo_info, dict) else ''
                resultados.append({
                    "archivo": f"{nombre_original}.{ext}",
                    "ruta": filepath,
                    "terminos_and": ", ".join(terminos_exitosos),
                    "snippet": snippet_final
                })

        print(f"\n{BLUE}----------------------------------------------------{NC}")
        info_tol = f"({len(tolerancias)} reglas)" if tolerancias else ""
        print(f"Resultados para: {GREEN}'{entrada_original}'{NC} {YELLOW}{info_tol}{NC}")
        print(f"Se encontraron {YELLOW}{len(resultados)}{NC} coincidencias.\n")

        for i, res in enumerate(resultados, 1):
            print(f"{BLUE}[{i}] {res['archivo']}{NC}")
            print(f"    {GREEN}Ruta:{NC}       {res['ruta']}")
            print(f"    {YELLOW}Motivo:{NC}     Se encontraron: [{GREEN}{res['terminos_and']}{NC}]")
            print(f"    {YELLOW}Texto:{NC}      {res['snippet']}\n")

        print(f"{BLUE}===================================================={NC}")
        resp = input(f"{YELLOW}¿Qué deseas hacer? [Enter] Otra Búsqueda | [M] Menú | [S] Salir: {NC}").strip().lower()

        if resp == 's': sys.exit(0)
        elif resp == 'm': break

if __name__ == "__main__":
    main()
