"""
qr_detect.py — lectura de códigos de familia en una imagen (cascada de hec-photo-app).

La usan el organizer (fotos que llegan por FTP) y el escaneo de fotogramas de la cámara
(`scan_service`), para que los dos lean igual de bien. Cascada, de barata a cara:

  0. Puerta: ¿hay un patrón QR visible? Se consultan los DOS detectores de OpenCV porque
     ninguno basta solo (medido sobre 250 tarjetas reales fotografiadas a tres distancias):

                            primer plano   media    lejos
         detect()               96,7 %     93,3 %   15,0 %   ← falla el QR pequeño
         detectMulti()          25,0 %     93,3 %   96,7 %   ← falla el primer plano
         detect | detectMulti   98,3 %     98,3 %   98,3 %

     El 90 % de las fotos son retratos sin QR y salen aquí sin más coste.
  1. Descodificación múltiple (varias tarjetas en la foto; tolera el marco redondeado de la tarjeta).
  2. Gris tal cual.   3. CLAHE (reflejos / luz desigual).   4. Otsu (desenfoque / ruido).
  5. Enfoque (unsharp).
  6. OCR del código IMPRESO bajo el QR (tarjeta rayada o doblada). Solo con prefijos conocidos.

⚠️ El OCR lee el prefijo del colegio, no uno fijo: es el paso más caro (tesseract sobre ~12 Mpx)
y con un prefijo equivocado se pagaría entero para no encontrar nunca nada. Los prefijos salen
de la configuración del colegio MÁS los que ya estén en disco (la config y las tarjetas ya
repartidas no siempre coinciden). Si tesseract no está instalado, el primer fallo desactiva el
paso para siempre: no se reintenta foto a foto.
"""
import logging
import re
from pathlib import Path
from typing import Optional

import cv2
import numpy as np

logger = logging.getLogger(__name__)

_qr_detector = cv2.QRCodeDetector()
_ocr_unavailable = False

# Código válido tal como puede aparecer en una tarjeta (nuevo MAR-4JC-IBD, antiguo ABC-1234, o sin guiones).
CODE_RE = re.compile(r"^[A-Z0-9][A-Z0-9-]{1,23}$")


def extract_code(raw: str) -> Optional[str]:
    """Contenido de un QR → código de familia, o None si no es un código válido.

    Acepta el código a secas, una URL `…/r/CÓDIGO` o `…?code=CÓDIGO`. La validación de formato no es
    cosmética: el código acaba siendo el nombre de una carpeta, así que un QR malicioso con `../../x`
    no puede escribir fuera de photos/.
    """
    s = (raw or "").strip()
    if not s:
        return None
    if s.lower().startswith(("http://", "https://")):
        m = re.search(r"/r/([^/?#\s]+)", s, re.IGNORECASE) or re.search(r"[?&]code=([^&#\s]+)", s, re.IGNORECASE)
        if not m:
            return None
        s = m.group(1)
    code = s.replace(" ", "").upper()
    return code if CODE_RE.match(code) else None


def valid_prefixes(configured: "str | set[str] | None", output_dir: Optional[Path] = None) -> "set[str]":
    """Prefijos de código en circulación: los configurados + los de las carpetas que ya existen en disco."""
    prefixes: set[str] = set()
    if isinstance(configured, str) and configured:
        prefixes.add(configured.upper())
    elif configured:
        prefixes |= {c.upper() for c in configured if c}
    if output_dir:
        try:
            for photographer in output_dir.iterdir():
                if not photographer.is_dir() or photographer.name.startswith((".", "_")):
                    continue
                for folder in photographer.iterdir():
                    if not folder.is_dir() or folder.name.startswith((".", "_")):
                        continue
                    m = re.match(r"^([A-Za-z]{2,6})[-0-9]", folder.name)
                    if m:
                        prefixes.add(m.group(1).upper())
        except OSError:
            pass
    return prefixes


def ocr_code(img_or_path, prefixes: "set[str]") -> Optional[str]:
    """Lee el código impreso (`PREFIJO-XXX-XXX`) con pytesseract. None si no hay prefijos / tesseract."""
    global _ocr_unavailable
    if _ocr_unavailable or not prefixes:
        return None
    try:
        import pytesseract
    except ImportError:
        _ocr_unavailable = True
        logger.info("OCR de rescate no disponible (falta pytesseract): se omite en adelante")
        return None
    try:
        img = cv2.imread(str(img_or_path), cv2.IMREAD_COLOR) if isinstance(img_or_path, (str, Path)) else img_or_path
        if img is None:
            return None
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if len(img.shape) == 3 else img
        # Que tesseract vea texto de ~300 ppp: los fotogramas de cámara son pequeños.
        if gray.shape[1] < 1200:
            scale = max(1, 1200 // max(gray.shape[1], 1))
            gray = cv2.resize(gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
        _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
        text = pytesseract.image_to_string(binary, config="--psm 11 --oem 3")

        alt = "|".join(sorted(map(re.escape, prefixes), key=len, reverse=True))
        m = re.search(rf"\b({alt})[-\s]([A-Z0-9]{{3}})[-\s]([A-Z0-9]{{3}})\b", text, re.IGNORECASE)
        if m:
            pref, seg1, seg2 = m.group(1).upper(), m.group(2).upper(), m.group(3).upper()
            # Un segmento igual al prefijo delata que se leyó dos veces la misma palabra, no un código.
            if seg1 != pref and seg2 != pref:
                code = f"{pref}-{seg1}-{seg2}"
                logger.info("OCR detectó código: %s", code)
                return code
        m = re.search(rf"\b({alt})\s*(\d{{2}})\s*(\d{{3}})\b", text, re.IGNORECASE)   # formato numérico antiguo
        if m:
            code = f"{m.group(1).upper()}{m.group(2)}{m.group(3)}"
            logger.info("OCR detectó código (formato numérico): %s", code)
            return code
    except Exception as exc:
        if exc.__class__.__name__ == "TesseractNotFoundError":
            _ocr_unavailable = True
            logger.warning("OCR de rescate desactivado: tesseract no está instalado en el PATH")
        else:
            logger.debug("OCR error: %s", exc)
    return None


def reset_ocr_state() -> None:
    """Para los tests: vuelve a permitir el OCR tras un fallo simulado."""
    global _ocr_unavailable
    _ocr_unavailable = False


def detect_qr(img, path: Optional[Path] = None, prefixes: "set[str] | None" = None,
              use_ocr: bool = True) -> Optional[str]:
    """Texto crudo del QR de una imagen BGR (o, como último recurso, el código impreso por OCR)."""
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if len(img.shape) == 3 else img

    # 0 — puerta (ver docstring del módulo)
    found, _ = _qr_detector.detect(gray)
    if not found:
        found, _ = _qr_detector.detectMulti(gray)
    if not found:
        return None
    logger.debug("Patrón QR visible — intentando descodificar")

    # 1 — descodificación múltiple: la que acierta con varias tarjetas en la foto
    try:
        ok, datas, _, _ = _qr_detector.detectAndDecodeMulti(gray)
        if ok:
            for d in datas:
                if d and d.strip():
                    return d.strip().upper()
    except cv2.error as exc:
        logger.debug("detectAndDecodeMulti falló: %s", exc)

    # 2 — gris tal cual
    data, _, _ = _qr_detector.detectAndDecode(gray)
    if data:
        return data.strip().upper()

    # 3 — CLAHE: contraste adaptativo contra brillos y luz desigual
    clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
    data, _, _ = _qr_detector.detectAndDecode(clahe.apply(gray))
    if data:
        return data.strip().upper()

    # 4 — Otsu: contra el desenfoque y el ruido
    blurred = cv2.GaussianBlur(gray, (5, 5), 0)
    _, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
    data, _, _ = _qr_detector.detectAndDecode(binary)
    if data:
        return data.strip().upper()

    # 5 — enfoque (unsharp)
    kernel = np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]], dtype=np.float32)
    data, _, _ = _qr_detector.detectAndDecode(cv2.filter2D(gray, -1, kernel))
    if data:
        return data.strip().upper()

    # 6 — QR dañado físicamente: leer el código impreso. Sin prefijos conocidos no se intenta.
    if use_ocr and prefixes:
        return ocr_code(path if path is not None else img, prefixes)
    return None
