"""
cleanup_service.py — mantenimiento periódico.

1. Suppression automatique des photos (RGPD mineurs) : images plus vieilles que la rétention DE L'ÉCOLE
   de la famille (réglage « rgpd_days », modifiable dans l'admin ; défaut PHOTO_RETENTION_DAYS) dans
   PHOTOS_DIR (layout `photos/{école}/{photographe}/{CODE}/` et l'ancien `photos/{CODE}/`), la corbeille
   et INCOMING_DIR. Chaque suppression est tracée (audit « critical ») ; au-delà de RGPD_ALERT_THRESHOLD
   fichiers, une alerte e-mail part.
2. Aperçus (réduits / visages floutés) et cache `.protect/` orphelins : on ne garde jamais une copie
   d'une photo qui n'existe plus.
3. Corbeille : purgée après TRASH_RETENTION_DAYS, sans jamais dépasser la rétention RGPD de l'école.
4. Commandes jamais payées : annulées après UNPAID_ORDER_TTL_HOURS (le PaymentIntent Stripe est annulé) —
   sauf si Stripe indique qu'il est payé : la commande est alors enregistrée comme payée (voir expire_unpaid_orders).
5. Purge des compteurs de rate limiting périmés.

Tourne une fois au démarrage puis toutes les 24 h (sur le worker leader).
"""
import asyncio
import logging
import re
import time
from datetime import timedelta
from pathlib import Path
from typing import Optional

from core import config
from core.config import PHOTOS_DIR, INCOMING_DIR, THUMBS_DIR, PHOTO_RETENTION_DAYS, IMAGE_EXTENSIONS
from core.db import get_db
from core.timeutil import utcnow
from services import audit_service, alert_service

log = logging.getLogger("meeconic.cleanup")

_CLEANUP_INTERVAL = 86400  # 24 h
_IMG_EXTS = {e.lower() for e in IMAGE_EXTENSIONS}
_PREVIEW_RE = re.compile(r"^(?P<stem>.+?)\.(?P<kind>wm|pv|h[0-9a-f]{10})(?:\.\d+)?\.webp$")


def _retention_days(family_code: Optional[str] = None, school_id: Optional[str] = None) -> int:
    """Rétention de l'école (directe, ou celle de la famille) ; à défaut, la valeur globale du .env."""
    try:
        from services import school_service
        if school_id:
            return int(school_service.get_settings(school_id)["rgpd_days"])
        if family_code:
            return school_service.retention_days_for_family(family_code)
    except Exception as e:
        log.warning("Per-school retention unreadable (%s/%s): %s", school_id, family_code, e)
    return PHOTO_RETENTION_DAYS


def _locate(rel: Path) -> tuple[Optional[str], Optional[str]]:
    """(école, code famille) d'après le chemin relatif à PHOTOS_DIR.

      école/photographe/CODE/…            → nouveau layout
      .trash/école/lot/photographe/CODE/… → corbeille
      CODE/…                              → ancien layout (l'école se déduit de la base)
    """
    parts = rel.parts
    if not parts:
        return None, None
    if parts[0] == ".trash":
        return (parts[1], parts[4]) if len(parts) >= 6 else (parts[1] if len(parts) > 1 else None, None)
    if len(parts) >= 4:
        return parts[0], parts[2]
    return None, parts[0]


def _delete_old(directory: Path, default_days: int, by_family: bool = False, keep_depth: int = 0) -> tuple[int, dict]:
    """Supprime les images trop vieilles. Si `by_family`, la rétention est celle de l'école de chaque famille.
    Les dossiers vides sont élagués, sauf ceux à moins de `keep_depth` niveaux : `incoming/{école}/` est le
    dossier de dépôt FTP de l'appareil, il doit survivre vide (incident : élagué par ce nettoyage, l'organizer
    a enchaîné des centaines d'erreurs « No such file or directory » et l'appareil ne pouvait plus déposer).
    Retourne (total supprimé, {(école, famille): n})."""
    if not directory.exists():
        return 0, {}
    now = time.time()
    deleted = 0
    per_family: dict = {}
    cache: dict = {}
    for f in directory.rglob("*"):
        if not (f.is_file() and f.suffix.lower() in _IMG_EXTS):
            continue
        key = None
        if by_family:
            key = _locate(f.relative_to(directory))
            if key not in cache:
                school, family = key
                cache[key] = _retention_days(family_code=family, school_id=school)
            days = cache[key]
        else:
            days = default_days
        try:
            if f.stat().st_mtime < now - days * 86400:
                f.unlink()
                deleted += 1
                if key and key[1]:
                    per_family[key] = per_family.get(key, 0) + 1
        except OSError as e:
            log.warning("Suppression impossible %s: %s", f, e)
    for d in sorted(directory.rglob("*"), reverse=True):      # élague les dossiers vides, de bas en haut
        if d.is_dir() and len(d.relative_to(directory).parts) > keep_depth:
            try:
                d.rmdir()
            except OSError:
                pass
    return deleted, per_family


def _original_exists(photos_dir: Path, stem: str) -> bool:
    try:
        return any(f.stem == stem for f in photos_dir.iterdir() if f.is_file())
    except OSError:
        return False


def prune_orphan_derivatives() -> int:
    """Supprime aperçus et cache `.protect/` dont l'original n'existe plus. Retourne le nombre supprimé."""
    removed = 0
    if THUMBS_DIR.exists():
        for f in THUMBS_DIR.rglob("*"):
            if not f.is_file():
                continue
            m = _PREVIEW_RE.match(f.name)
            stem = m.group("stem") if m else f.stem
            # `.wm.` previews come from versions that stamped a watermark on them: they are never served again.
            legacy = bool(m and m.group("kind") == "wm")
            try:
                originals = PHOTOS_DIR / f.parent.relative_to(THUMBS_DIR)
            except ValueError:
                continue
            if legacy or not _original_exists(originals, stem):
                try:
                    f.unlink()
                    removed += 1
                except OSError:
                    pass
        for d in sorted(THUMBS_DIR.rglob("*"), reverse=True):
            if d.is_dir():
                try:
                    d.rmdir()
                except OSError:
                    pass
    if PHOTOS_DIR.exists():
        for protect in PHOTOS_DIR.rglob(".protect"):
            if not protect.is_dir():
                continue
            for f in protect.iterdir():
                stem = f.name.split("_heads_")[0]
                if not _original_exists(protect.parent, stem):
                    try:
                        f.unlink()
                        removed += 1
                    except OSError:
                        pass
            try:
                protect.rmdir()
            except OSError:
                pass
    return removed


def purge_trashes() -> int:
    from services import photo_service, school_service
    purged = 0
    try:
        for s in school_service.list_schools():
            days = min(config.TRASH_RETENTION_DAYS, _retention_days(school_id=s["id"]))
            purged += photo_service.purge_trash(s["id"], days)
    except Exception as e:
        log.warning("Trash purge failed: %s", e)
    return purged


# Set by main.py at startup: the same coroutine the Stripe webhook runs (amount check, idempotence, e-mails).
_payment_reconciler = None
# Orders whose PaymentIntent turned out to be paid at Stripe, waiting for the loop to book them.
_paid_at_stripe: list[tuple[str, int]] = []


def set_payment_reconciler(fn) -> None:
    global _payment_reconciler
    _payment_reconciler = fn


def expire_unpaid_orders() -> int:
    """Cancels orders never paid after the TTL. Returns how many were cancelled.

    Incident behind the Stripe check: two orders were charged at Stripe (`succeeded`) but the webhook never
    reached us (STRIPE_WEBHOOK_SECRET empty), so this job marked them `expired`/`cancelled` and the family
    paid for photos that were then refused. An order is now expired only when Stripe says the PaymentIntent
    is NOT paid; when Stripe cannot be asked, the order is left for the next cycle rather than guessed."""
    cutoff = (utcnow() - timedelta(hours=config.UNPAID_ORDER_TTL_HOURS)).isoformat()
    with get_db() as conn:
        rows = conn.execute(
            "SELECT id, stripe_payment_intent_id FROM orders "
            "WHERE payment_status IN ('unpaid','failed') AND status='pending' AND created_at < ?", (cutoff,)).fetchall()

    stripe = None
    if config.STRIPE_SECRET_KEY:
        import stripe
        stripe.api_key = config.STRIPE_SECRET_KEY

    to_expire = []
    for r in rows:
        pi_id = r["stripe_payment_intent_id"]
        if pi_id and stripe is not None:
            try:
                pi = stripe.PaymentIntent.retrieve(pi_id)
                if pi["status"] == "succeeded":
                    _paid_at_stripe.append((pi_id, int(pi.get("amount_received") or pi["amount"])))
                    log.warning("Order %s is paid at Stripe (%s) but was never confirmed — booking it, not expiring", r["id"], pi_id)
                    continue
                if pi["status"] != "canceled":
                    stripe.PaymentIntent.cancel(pi_id)
            except Exception as e:
                log.warning("Order %s left for the next cycle, Stripe could not confirm %s: %s", r["id"], pi_id, e)
                continue
        to_expire.append(r["id"])

    if to_expire:
        with get_db() as conn:
            for order_id in to_expire:
                conn.execute("UPDATE orders SET status='cancelled', payment_status='expired' WHERE id=?", (order_id,))
            conn.commit()
        log.info("%d unpaid order(s) expired", len(to_expire))
    return len(to_expire)


async def book_orders_paid_at_stripe() -> int:
    """Runs the webhook's booking for every order found paid at Stripe by `expire_unpaid_orders`."""
    booked = 0
    while _paid_at_stripe:
        pi_id, amount = _paid_at_stripe.pop()
        alert_service.notify(
            f"stripe-unconfirmed:{pi_id}", "Stripe payment was never confirmed by webhook",
            f"payment_intent={pi_id} succeeded at Stripe but the order was still unpaid. Check that the webhook "
            f"endpoint is configured and STRIPE_WEBHOOK_SECRET is set.")
        if _payment_reconciler is None:
            log.error("No payment reconciler registered — %s stays unpaid until the webhook is replayed", pi_id)
            continue
        try:
            await _payment_reconciler(pi_id, amount)
            booked += 1
        except Exception as e:
            log.error("Booking %s failed: %s", pi_id, e, exc_info=True)
    return booked


def run_once() -> int:
    photos_deleted, per_family = _delete_old(PHOTOS_DIR, PHOTO_RETENTION_DAYS, by_family=True)
    incoming_deleted, _ = _delete_old(INCOMING_DIR, _retention_days(), keep_depth=1)
    deleted = photos_deleted + incoming_deleted
    if deleted:
        log.info("GDPR: %d photo(s) deleted (per-school retention)", deleted)

    # Suppression de photos d'enfants = action irréversible → toujours tracée en sévérité "critical",
    # une entrée par famille touchée.
    for (school, family), n in per_family.items():
        try:
            from services.school_service import school_id_for_family
            sid = school or school_id_for_family(family)
        except Exception:
            sid = school
        audit_service.log_event(
            "photo.rgpd_delete", resource_type="family", resource_id=family,
            details={"files_deleted": n, "retention_days": _retention_days(family_code=family, school_id=sid)},
            severity="critical", school_id=sid,
        )
    if incoming_deleted:
        audit_service.log_event(
            "photo.rgpd_delete", resource_type="incoming", resource_id="incoming",
            details={"files_deleted": incoming_deleted, "retention_days": _retention_days()}, severity="critical")
    if deleted > config.RGPD_ALERT_THRESHOLD:
        alert_service.notify(
            "rgpd-mass-delete", f"Suppression RGPD massive : {deleted} photos",
            f"Le nettoyage automatique a supprimé {deleted} photos (seuil d'alerte {config.RGPD_ALERT_THRESHOLD}). "
            f"Familles touchées : {len(per_family)}. Vérifiez le journal d'audit et la rétention (rgpd_days) des écoles.")

    from services import shoot_service
    for name, fn in (("aperçus orphelins", prune_orphan_derivatives), ("corbeille", purge_trashes),
                     ("commandes impayées", expire_unpaid_orders), ("shoot events", shoot_service.prune_events)):
        try:
            fn()
        except Exception as e:
            log.error("Cleanup '%s' failed: %s", name, e)
    try:
        from core.rate_limit import purge_old
        purge_old()
    except Exception as e:
        log.warning("Rate-limit purge failed: %s", e)
    return deleted


async def run_cleanup_loop() -> None:
    while True:
        try:
            await asyncio.to_thread(run_once)
            await book_orders_paid_at_stripe()
        except asyncio.CancelledError:
            raise
        except Exception as e:
            log.error("Erreur cleanup RGPD: %s", e, exc_info=True)
            alert_service.notify("cleanup-failed", "Le nettoyage RGPD a échoué", str(e))
        await asyncio.sleep(_CLEANUP_INTERVAL)
