"""
photo_service.py — fotos en disco, organizadas como en hec-photo-app.

Layout (una carpeta por colegio, por fotógrafo y por familia):

    photos/{colegio}/{fotógrafo}/{CÓDIGO}/foto.jpg
    photos/{colegio}/{fotógrafo}/{CÓDIGO}/.qr/…        ← foto de la tarjeta QR (no se vende)
    photos/{colegio}/{fotógrafo}/{CÓDIGO}/.protect/…   ← caché de la protección de caras
    photos/.trash/{colegio}/{lote}/…                   ← papelera (fuera del colegio a propósito)

Compatibilidad: las instalaciones anteriores guardaban `photos/{CÓDIGO}/foto.jpg`.
Esa carpeta sigue leyéndose (se trata como fotógrafo "") hasta que se vacíe sola
por la retención RGPD; el organizer ya solo escribe en el layout nuevo.

Este módulo solo toca el disco: no firma URLs ni sabe de base de datos (eso es
`media_service`).
"""
import logging
import re
import shutil
import time
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional

from core import config
from models.models import FamilyInfo

logger = logging.getLogger(__name__)

try:
    from zoneinfo import ZoneInfo
    _EVENT_TZ: "ZoneInfo | None" = ZoneInfo(config.EVENT_TIMEZONE)
except Exception:  # pragma: no cover — servidor sin base de datos de husos
    logger.warning("No se pudo cargar el huso %s: las búsquedas por hora usarán la del servidor.", config.EVENT_TIMEZONE)
    _EVENT_TZ = None


def _event_dt(mtime: float) -> datetime:
    """Instante absoluto → hora del EVENTO (no la del servidor, que en un VPS suele ser UTC)."""
    return datetime.fromtimestamp(mtime, tz=_EVENT_TZ)


IMAGE_EXTS = {e.lower() for e in config.IMAGE_EXTENSIONS}
TRASH_DIRNAME = ".trash"
# Carpetas internas que NO son de fotógrafos. `.trash` es obligatorio: sin él la
# papelera se leería como un fotógrafo más y las fotos borradas reaparecerían.
_RESERVED_DIRS = {"qr_cards", "_qr_cards", "thumbs", ".thumbs", TRASH_DIRNAME, "_errors"}

_CACHE_TTL = 60
_families_cache: dict = {}


def photos_root() -> Path:
    return config.PHOTOS_DIR


def school_root(school_id: str) -> Path:
    return config.PHOTOS_DIR / school_id


def trash_root(school_id: str) -> Path:
    """`photos/.trash/{colegio}`, FUERA de `photos/{colegio}` (mismo volumen: mover es un rename)."""
    return config.PHOTOS_DIR / TRASH_DIRNAME / school_id


def is_image(name: str) -> bool:
    return Path(name).suffix.lower() in IMAGE_EXTS


def photo_kind(filename: str) -> str:
    """Mismo criterio que hec-photo-app: el prefijo del archivo dice qué pipeline lo escribió."""
    return "ai" if filename.startswith("ia_") else "original"


def norm_code(code: str) -> str:
    """Compara códigos sin espacios ni guiones, en mayúsculas ('MARABC123' == 'MAR-ABC-123')."""
    return code.upper().replace(" ", "").replace("-", "")


def photographer_dirs(root: Path) -> List[Path]:
    if not root.exists():
        return []
    return sorted(p for p in root.iterdir()
                  if p.is_dir() and not p.name.startswith(("_", ".")) and p.name not in _RESERVED_DIRS)


def resolve_qr_folder(photographer_dir: Path, code: str) -> Optional[Path]:
    """Carpeta del código ignorando guiones/espacios (la que escribió el vendedor vs la impresa)."""
    direct = photographer_dir / code.upper().replace(" ", "")
    if direct.is_dir():
        return direct
    target = norm_code(code)
    try:
        for folder in photographer_dir.iterdir():
            if folder.is_dir() and not folder.name.startswith(".") and norm_code(folder.name) == target:
                return folder
    except OSError:
        pass
    return None


def family_dirs(school_id: str, code: str) -> List[tuple[str, Path]]:
    """[(fotógrafo, carpeta)] de una familia: una por fotógrafo que le sacó fotos, más la antigua si existe."""
    out: List[tuple[str, Path]] = []
    for pd in photographer_dirs(school_root(school_id)):
        folder = resolve_qr_folder(pd, code)
        if folder is not None:
            out.append((pd.name, folder))
    legacy = config.PHOTOS_DIR / code.upper().replace(" ", "")
    if legacy.is_dir() and legacy.name.upper() == code.upper() and not legacy.name.startswith("."):
        out.append(("", legacy))
    return out


def list_family_files(school_id: str, code: str) -> List[Path]:
    """Fotos vendibles de una familia (no entra en subcarpetas: .qr/, .protect/…)."""
    files: List[Path] = []
    for _ph, folder in family_dirs(school_id, code):
        try:
            files += [f for f in folder.iterdir()
                      if f.is_file() and not f.name.startswith(".") and is_image(f.name)]
        except OSError as exc:
            logger.warning("No se pudo leer %s: %s", folder, exc)
    return sorted(files, key=lambda p: (p.name, str(p)))


def photographer_of(path: Path) -> str:
    """Nombre del fotógrafo según la ruta (""= layout antiguo)."""
    try:
        rel = path.relative_to(config.PHOTOS_DIR)
    except ValueError:
        return ""
    return rel.parts[1] if len(rel.parts) >= 4 else ""


def _safe_part(part: str) -> None:
    if not part or ".." in part or "/" in part or "\\" in part or part.startswith("."):
        raise ValueError(f"Invalid path component: {part!r}")


def find_photo(school_id: str, code: str, filename: str) -> Optional[Path]:
    """Ruta absoluta del original, o None si el nombre es sospechoso (../, absoluto, oculto) o no existe."""
    if not filename or Path(filename).name != filename or filename.startswith(".") or not is_image(filename):
        return None
    for _ph, folder in family_dirs(school_id, code):
        path = folder / filename
        try:
            path.resolve().relative_to(folder.resolve())
        except ValueError:
            continue
        if path.is_file():
            return path
    return None


def count_family_photos(school_id: str, code: str) -> int:
    return len(list_family_files(school_id, code))


# ─── Miniaturas / vista previa: ruta espejo del original ─────────────────────

def mirror_path(src: Path, suffix: str) -> Path:
    """`photos/a/b/c/x.jpg` → `thumbs/a/b/c/x{suffix}` (suffix incluye el punto)."""
    try:
        rel = src.relative_to(config.PHOTOS_DIR)
    except ValueError:
        rel = Path(src.name)
    return config.THUMBS_DIR / rel.parent / f"{src.stem}{suffix}"


def drop_derivatives(src: Path) -> None:
    """Borra miniaturas, vistas previas y caché protegida de un original."""
    d = mirror_path(src, "").parent
    try:
        for f in d.glob(f"{src.stem}.*"):
            f.unlink(missing_ok=True)
    except OSError:
        pass
    protect = src.parent / ".protect"
    try:
        for f in protect.glob(f"{src.stem}_heads_*"):
            f.unlink(missing_ok=True)
    except OSError:
        pass


# ─── Familias (admin) ────────────────────────────────────────────────────────

def invalidate_families_cache(school_id: Optional[str] = None) -> None:
    if school_id:
        _families_cache.pop(f"families_{school_id}", None)
    else:
        _families_cache.clear()


def get_all_families(school_id: str) -> List[FamilyInfo]:
    now = time.monotonic()
    key = f"families_{school_id}"
    cached = _families_cache.get(key)
    if cached and now < cached["expires_at"]:
        return cached["data"]

    root = school_root(school_id)
    families: Dict[str, FamilyInfo] = {}
    last_mtime: Dict[str, float] = {}
    for pd in photographer_dirs(root):
        for folder in sorted(pd.iterdir()):
            if not folder.is_dir() or folder.name.startswith("."):
                continue
            code = folder.name.upper()
            count, newest = 0, 0.0
            for f in folder.iterdir():
                if f.is_file() and not f.name.startswith(".") and is_image(f.name):
                    count += 1
                    try:
                        newest = max(newest, f.stat().st_mtime)
                    except OSError:
                        pass
            if count == 0:
                continue
            fam = families.setdefault(code, FamilyInfo(qr_code=code, photo_count=0, photographers=[]))
            fam.photo_count += count
            if pd.name not in fam.photographers:
                fam.photographers.append(pd.name)
            last_mtime[code] = max(last_mtime.get(code, 0.0), newest)
    for code, mtime in last_mtime.items():
        if mtime > 0:
            families[code].last_photo_at = _event_dt(mtime).strftime("%Y-%m-%dT%H:%M:%S")
    result = sorted(families.values(), key=lambda f: (f.last_photo_at or "", f.qr_code), reverse=True)
    _families_cache[key] = {"data": result, "expires_at": now + _CACHE_TTL}
    return result


# ─── Papelera: borrar NO destruye, mueve ─────────────────────────────────────

def _new_trash_batch(school_id: str, kind: str) -> Path:
    stamp = time.strftime("%Y%m%d-%H%M%S")
    batch = trash_root(school_id) / f"{stamp}-{kind}"
    n = 1
    while batch.exists():
        n += 1
        batch = trash_root(school_id) / f"{stamp}-{kind}-{n}"
    batch.mkdir(parents=True, exist_ok=True)
    return batch


def _move_to_trash(src: Path, batch: Path, rel: Path) -> None:
    dest = batch / rel
    dest.parent.mkdir(parents=True, exist_ok=True)
    if dest.exists():
        dest = dest.with_name(f"{dest.stem}_{int(time.time() * 1000)}{dest.suffix}")
    shutil.move(str(src), str(dest))


def delete_photo(school_id: str, code: str, filename: str) -> bool:
    """Foto a la papelera (y fuera sus derivados). False si no existe."""
    _safe_part(filename)
    path = find_photo(school_id, code, filename)
    if path is None:
        return False
    try:
        rel = path.relative_to(school_root(school_id))
    except ValueError:                      # layout antiguo: photos/CODE/x.jpg
        rel = Path("_legacy") / path.parent.name / path.name
    batch = _new_trash_batch(school_id, f"photo-{norm_code(code)}")
    drop_derivatives(path)
    _move_to_trash(path, batch, rel)
    invalidate_families_cache(school_id)
    logger.info("Colegio %s: foto a la papelera: %s → %s", school_id, path, batch.name)
    return True


def delete_family_photos(school_id: str, code: str) -> int:
    """Todas las carpetas de la familia (de todos los fotógrafos) a UN lote restaurable."""
    batch: Optional[Path] = None
    deleted = 0
    for ph, folder in family_dirs(school_id, code):
        files = sum(1 for f in folder.rglob("*") if f.is_file())
        if batch is None:
            batch = _new_trash_batch(school_id, f"family-{norm_code(code)}")
        rel = Path(ph) / folder.name if ph else Path("_legacy") / folder.name
        shutil.rmtree(mirror_path(folder / "x", "").parent, ignore_errors=True)
        _move_to_trash(folder, batch, rel)
        deleted += files
    if deleted:
        invalidate_families_cache(school_id)
    logger.info("Colegio %s: %d ficheros a la papelera para %s", school_id, deleted, code)
    return deleted


def delete_all_photos(school_id: str) -> int:
    """Vacía el colegio moviendo TODAS las familias a la papelera (recuperable)."""
    batch = _new_trash_batch(school_id, "all")
    deleted = 0
    for pd in photographer_dirs(school_root(school_id)):
        for folder in list(pd.iterdir()):
            if not folder.is_dir():
                continue
            deleted += sum(1 for f in folder.rglob("*") if f.is_file())
            _move_to_trash(folder, batch, Path(pd.name) / folder.name)
    shutil.rmtree(config.THUMBS_DIR / school_id, ignore_errors=True)
    invalidate_families_cache(school_id)
    logger.warning("Colegio %s: BORRADO TOTAL — %d ficheros a la papelera (%s)", school_id, deleted, batch.name)
    return deleted


def _batch_label(name: str) -> tuple[str, str]:
    parts = name.split("-", 2)
    if len(parts) < 3 or len(parts[0]) != 8:
        return "", name
    d, t = parts[0], parts[1]
    return f"{d[:4]}-{d[4:6]}-{d[6:]}T{t[:2]}:{t[2:4]}:{t[4:6]}", parts[2]


def _batch_age_days(batch: Path) -> int:
    return int((time.time() - batch.stat().st_mtime) / 86400)


def list_trash(school_id: str) -> List[Dict]:
    trash = trash_root(school_id)
    if not trash.is_dir():
        return []
    entries = []
    for batch in trash.iterdir():
        if not batch.is_dir():
            continue
        files = [f for f in batch.rglob("*") if f.is_file()]
        deleted_at, kind = _batch_label(batch.name)
        entries.append({
            "id": batch.name, "kind": kind, "deleted_at": deleted_at, "file_count": len(files),
            "size_mb": round(sum(f.stat().st_size for f in files) / 1048576, 1),
            "families": sorted({d.name for d in batch.glob("*/*") if d.is_dir()})[:12],
            "expires_in_days": max(0, config.TRASH_RETENTION_DAYS - _batch_age_days(batch)),
        })
    return sorted(entries, key=lambda e: e["id"], reverse=True)


def restore_trash_entry(school_id: str, entry_id: str) -> int:
    """Devuelve un lote a su ruta exacta; lo que ya exista allí no se pisa."""
    _safe_part(entry_id.lstrip("."))
    trash = trash_root(school_id)
    batch = (trash / entry_id).resolve()
    try:
        batch.relative_to(trash.resolve())
    except ValueError:
        raise ValueError("Path traversal detected")
    if not batch.is_dir():
        raise ValueError(f"Entrada de papelera inexistente: {entry_id}")
    restored = 0
    for f in list(batch.rglob("*")):
        if not f.is_file():
            continue
        rel = f.relative_to(batch)
        if rel.parts[0] == "_legacy":      # layout antiguo: photos/CODE/x.jpg
            dest = config.PHOTOS_DIR / Path(*rel.parts[1:])
        else:
            dest = school_root(school_id) / rel
        dest.parent.mkdir(parents=True, exist_ok=True)
        if dest.exists():
            logger.warning("Papelera: %s ya existe, no se sobrescribe", dest)
            continue
        shutil.move(str(f), str(dest))
        restored += 1
    shutil.rmtree(batch, ignore_errors=True)
    invalidate_families_cache(school_id)
    logger.info("Colegio %s: restaurados %d ficheros del lote %s", school_id, restored, entry_id)
    return restored


def purge_trash(school_id: str, retention_days: Optional[int] = None) -> int:
    """Borra DEFINITIVAMENTE los lotes más viejos que la retención (nunca más que la RGPD del colegio)."""
    days = config.TRASH_RETENTION_DAYS if retention_days is None else retention_days
    trash = trash_root(school_id)
    if not trash.is_dir():
        return 0
    purged = 0
    for batch in trash.iterdir():
        if batch.is_dir() and _batch_age_days(batch) >= days:
            purged += sum(1 for f in batch.rglob("*") if f.is_file())
            shutil.rmtree(batch, ignore_errors=True)
    return purged


# ─── Reasignación manual y búsqueda por fecha (corrección de un QR mal leído) ──

def list_photos_by_date(
    school_id: str,
    date_from: Optional[str] = None, date_to: Optional[str] = None,
    time_from: Optional[str] = None, time_to: Optional[str] = None,
    photographer: Optional[str] = None, limit: int = 300,
) -> "tuple[List[dict], int]":
    """Fotos del colegio por fecha/hora (en la hora del EVENTO). Sin fechas: hoy, para no devolver
    miles de filas. Devuelve (como mucho `limit` más recientes, total de coincidencias)."""
    root = school_root(school_id)
    if not root.exists():
        return [], 0
    if not date_from and not date_to:
        date_from = date_to = _event_dt(time.time()).strftime("%Y-%m-%d")
    out: List[dict] = []
    for pd in photographer_dirs(root):
        if photographer and pd.name != photographer:
            continue
        for folder in pd.iterdir():
            if not folder.is_dir() or folder.name.startswith("."):
                continue
            for f in folder.iterdir():
                if not f.is_file() or f.name.startswith(".") or not is_image(f.name):
                    continue
                try:
                    dt = _event_dt(f.stat().st_mtime)
                except OSError:
                    continue
                day, hm = dt.strftime("%Y-%m-%d"), dt.strftime("%H:%M")
                if (date_from and day < date_from) or (date_to and day > date_to):
                    continue
                if (time_from and hm < time_from) or (time_to and hm > time_to):
                    continue
                out.append({
                    "photographer": pd.name, "qr_code": folder.name.upper(), "filename": f.name,
                    "kind": photo_kind(f.name), "captured_at": dt.strftime("%Y-%m-%dT%H:%M:%S"),
                })
    out.sort(key=lambda p: p["captured_at"], reverse=True)
    return out[:limit], len(out)


def move_photo(school_id: str, photographer: str, code: str, filename: str, target_code: str,
               target_photographer: Optional[str] = None) -> str:
    """Mueve una foto a la carpeta de otro código (el QR fotografiado que no se detectó acabó bajo el
    código anterior). `target_code` puede venir escrito a mano: se resuelve ignorando guiones y, si no
    existe, se crea. ValueError (parámetro inválido) / FileNotFoundError (origen inexistente)."""
    target_photographer = target_photographer or photographer
    for part in (photographer, code, filename, target_photographer, target_code):
        _safe_part(part)
    root = school_root(school_id).resolve()
    src = (root / photographer / code / filename).resolve()
    try:
        src.relative_to(root)
    except ValueError:
        raise ValueError("Path traversal detected")
    if not src.is_file():
        raise FileNotFoundError(f"Photo not found: {photographer}/{code}/{filename}")
    dest_pd = (root / target_photographer).resolve()
    try:
        dest_pd.relative_to(root)
    except ValueError:
        raise ValueError("Path traversal detected")
    if not dest_pd.is_dir():
        raise ValueError(f"Unknown target photographer: {target_photographer}")
    folder = resolve_qr_folder(dest_pd, target_code) or (dest_pd / target_code.upper().replace(" ", ""))
    folder.mkdir(parents=True, exist_ok=True)
    dest = folder / filename
    if dest.exists():
        dest = dest.with_name(f"{dest.stem}_{int(time.time())}{dest.suffix}")
    drop_derivatives(src)
    shutil.move(str(src), str(dest))
    invalidate_families_cache(school_id)
    logger.info("Colegio %s: foto reasignada %s/%s/%s → %s/%s/%s", school_id, photographer, code, filename,
                target_photographer, folder.name, dest.name)
    return folder.name
