"""
@RAG_CONTEXT
{
  "archivo": "src/ocr_indexer/watcher.py",
  "lineas": 203,
  "tokens_estimados": 1670,
  "hash_contenido": "8a295c4a7b03d763ac4cb3827994614447dfcb71fc635c5170c803a91c8f004a",
  "creacion": "2026-03-17",
  "modificacion": "2026-03-17",
  "tags": [
    "sys",
    "time",
    "FileSystemEventHandler",
    "hashlib",
    "watchdog.observers",
    "logging",
    "Observer",
    "subprocess",
    "datetime",
    "json",
    "watchdog.events",
    "os"
  ],
  "descripcion_corta": "Monitor de archivos para OCR con debounce y blacklist",
  "descripcion_larga": "Clase FileSystemEventHandler que monitorea cambios en archivos de imagen/PDF (png, jpg, pdf, etc.) usando watchdog. Implementa debounce de 3 segundos, filtrado por extensiones válidas, detección de archivos temporales, respeto de blacklist desde config.json, y verificación de integridad mediante hash MD5. Evita procesamiento duplicado durante ejecuciones de fase2 mediante archivos PID. Inyecta archivos modificados como pendientes en ocr-indexer.json para procesamiento posterior por indexer.py."
}
"""
#!/usr/bin/env python3
import os
import sys
import json
import hashlib
import logging
import subprocess
import time
from datetime import datetime

log = logging.getLogger("watcher")

try:
    from watchdog.observers import Observer
    from watchdog.events import FileSystemEventHandler
except ImportError:
    print("Faltan dependencias. Instala con: pip install watchdog")
    sys.exit(1)

EXTENSIONES_VALIDAS = {'.png', '.jpg', '.jpeg', '.tiff', '.bmp', '.pdf'}
EXTENSIONES_TEMP = {'.part', '.tmp', '.crdownload', '.swp', '.swo'}


def calcular_hash(ruta):
    hasher = hashlib.md5()
    try:
        with open(ruta, 'rb') as f:
            buf = f.read(65536)
            while buf:
                hasher.update(buf)
                buf = f.read(65536)
        return hasher.hexdigest()
    except Exception as e:
        log.error(f"Error calculando hash de {ruta}: {e}")
        return None


class OCREventHandler(FileSystemEventHandler):

    def __init__(self, engine_path):
        self.engine_path = engine_path
        self.index_path = os.path.join(engine_path, 'ocr-indexer.json')
        self.config_path = os.path.join(engine_path, 'config.json')
        self.log_path = os.path.join(engine_path, 'indexer.log')
        self.script_indexer = os.path.join(os.path.dirname(__file__), 'indexer.py')
        self._debounce = {}  # filepath -> ultimo timestamp procesado
        self._cargar_config()

    def _cargar_config(self):
        try:
            with open(self.config_path, 'r') as f:
                config = json.load(f)
            self.blacklist = config.get('blacklist', [])
        except Exception as e:
            log.error(f"Error leyendo config.json: {e}")
            self.blacklist = []

    def _es_valido(self, path):
        nombre = os.path.basename(path)
        # Ignorar archivos ocultos y temporales
        if nombre.startswith('~') or nombre.startswith('.'):
            return False
        ext = os.path.splitext(nombre)[1].lower()
        if ext in EXTENSIONES_TEMP or ext not in EXTENSIONES_VALIDAS:
            return False
        # Respetar blacklist
        for bloqueado in self.blacklist:
            if f"/{bloqueado}/" in path or path.endswith(f"/{bloqueado}"):
                return False
        return True

    def _fase2_en_curso(self):
        """Devuelve True si ya hay un proceso --fase2 corriendo para este motor (via PID file)."""
        pid_path = os.path.join(self.engine_path, 'fase2.pid')
        if not os.path.exists(pid_path):
            return False
        try:
            with open(pid_path) as f:
                pid = int(f.read().strip())
            os.kill(pid, 0)  # Signal 0: solo verifica si el proceso existe
            return True
        except (ValueError, OSError):
            # PID inválido o proceso ya terminó — limpiar archivo huérfano
            try:
                os.remove(pid_path)
            except Exception:
                pass
            return False

    def _procesar(self, path):
        # Debounce: ignorar si el mismo archivo fue procesado hace menos de 3 s
        ahora = time.time()
        if ahora - self._debounce.get(path, 0) < 3:
            return
        self._debounce[path] = ahora

        if not os.path.isfile(path) or not self._es_valido(path):
            return

        hash_actual = calcular_hash(path)
        if not hash_actual:
            return

        # Leer índice
        try:
            with open(self.index_path, 'r') as f:
                db = json.load(f)
        except Exception as e:
            log.warning(f"No se pudo leer ocr-indexer.json, iniciando vacío: {e}")
            db = {}

        # Si ya existe con el mismo hash, no hay cambio real
        if path in db and db[path].get('integridad', {}).get('hash_md5') == hash_actual:
            return

        # Inyectar como pendiente
        nombre = os.path.basename(path)
        ext = os.path.splitext(nombre)[1].lower().replace('.', '')
        db[path] = {
            "archivo": {
                "nombre": os.path.splitext(nombre)[0],
                "extension": ext,
                "directorio": os.path.dirname(path)
            },
            "fechas": {"indexacion": datetime.now().isoformat()},
            "integridad": {"hash_md5": hash_actual},
            "contenido": {"texto_ocr": ""},
            "estado": "pendiente"
        }

        try:
            with open(self.index_path, 'w') as f:
                json.dump(db, f, indent=4)
        except Exception as e:
            log.error(f"Error inyectando {path} en ocr-indexer.json: {e}")
            return

        log.info(f"Detectado: {nombre} → inyectado como pendiente")
        print(f"[VIGÍA] Detectado: {nombre} → inyectado como pendiente", flush=True)

        # Lanzar Fase 2 solo si no hay una ya corriendo
        if not self._fase2_en_curso():
            try:
                with open(self.log_path, 'a') as log_file:
                    subprocess.Popen(
                        [sys.executable, "-u", self.script_indexer, self.engine_path, "--fase2"],
                        stdout=log_file, stderr=log_file
                    )
            except Exception as e:
                log.error(f"Error lanzando Fase 2: {e}")

    def on_created(self, event):
        if not event.is_directory:
            self._procesar(event.src_path)

    def on_modified(self, event):
        if not event.is_directory:
            self._procesar(event.src_path)


def main():
    if len(sys.argv) < 2:
        sys.exit(1)

    engine_path = sys.argv[1]
    config_path = os.path.join(engine_path, 'config.json')
    log_path = os.path.join(engine_path, 'indexer.log')

    logging.basicConfig(
        filename=log_path,
        level=logging.INFO,
        format='%(asctime)s [VIGÍA] [%(levelname)s] %(message)s',
        datefmt='%Y-%m-%d %H:%M:%S',
        force=True  # forzar reconfiguración aunque watchdog haya pre-configurado el root logger
    )

    try:
        with open(config_path, 'r') as f:
            config = json.load(f)
        root_dir = config.get('root', os.path.dirname(engine_path))
    except Exception as e:
        log.critical(f"Error leyendo config.json: {e}")
        sys.exit(1)

    handler = OCREventHandler(engine_path)
    observer = Observer()
    observer.schedule(handler, root_dir, recursive=True)
    observer.start()
    log.info(f"Monitoreando: {root_dir}")
    print(f"[VIGÍA] Monitoreando: {root_dir}", flush=True)

    try:
        while observer.is_alive():
            observer.join(timeout=1)
    except KeyboardInterrupt:
        pass
    finally:
        observer.stop()
        observer.join()


if __name__ == "__main__":
    main()
