"""
Meeconic Portal Backend — API REST du portail B2C des familles (multi-école).

Routes portail :
  GET  /api/health
  GET  /api/portal/photos?code=XXX          → galerie (aperçus filigranés signés)
  POST /api/portal/consent
  POST /api/portal/orders                   → commande + PaymentIntent Stripe
  GET  /api/portal/orders?code=XXX          → historique + liens de téléchargement (commandes payées)
  GET  /api/portal/download/{order}/{file}  → original HD, lien signé, commande payée uniquement
  GET  /api/media/preview/{code}/{file}     → aperçu filigrané, lien signé
  POST /api/stripe/webhook

Les originaux ne sont JAMAIS servis comme fichiers statiques.
"""

import asyncio
import json
import os
import logging
import re
import uuid
from contextlib import asynccontextmanager
from typing import Optional

import stripe
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.gzip import GZipMiddleware
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel, Field

from core import config
from core.config import (
    PHOTOS_DIR, THUMBS_DIR, INCOMING_DIR, PORT, HOST, CORS_ORIGINS, IS_PRODUCTION,
    STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET,
)
from core.db import get_db, init_core_tables, TABLE_OPTIONS
from core.leader import run_as_leader
from core.logging_setup import setup_logging
from core.rate_limit import enforce as rate_limit_enforce
from core.security import client_ip, validate_production_config
from core.timeutil import utcnow_iso
from services import (
    audit_service, alert_service, media_service, photo_service, photographer_service,
    protection_service, scan_service, school_service,
)

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

EMAIL_ENABLED = config._bool("EMAIL_ENABLED", "false")
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")

# ── Base de données (commandes) ────────────────────────────────────────────────

def init_db() -> None:
    with get_db() as conn:
        conn.execute(f"""
        CREATE TABLE IF NOT EXISTS orders (
            id                       VARCHAR(36)  NOT NULL PRIMARY KEY,
            family_code              VARCHAR(32)  NOT NULL,
            customer_name            VARCHAR(200),
            customer_email           VARCHAR(200),
            shipping_address         TEXT,
            total                    DOUBLE NOT NULL,
            status                   VARCHAR(20)  NOT NULL DEFAULT 'pending',
            items_json               MEDIUMTEXT NOT NULL,
            created_at               VARCHAR(40)  NOT NULL,
            payment_status           VARCHAR(20)  NOT NULL DEFAULT 'unpaid',
            stripe_payment_intent_id VARCHAR(80),
            school_id                VARCHAR(64),
            batch_status             VARCHAR(20)  NOT NULL DEFAULT 'to_print',
            KEY idx_orders_code (family_code),
            KEY idx_orders_school (school_id),
            KEY idx_orders_pi (stripe_payment_intent_id)
        ) {TABLE_OPTIONS}""")
        conn.commit()
    log.info("Database ready at %s:%s/%s", config.MYSQL_HOST, config.MYSQL_PORT, config.MYSQL_DB)


def init_all() -> None:
    """Crée dossiers et tables, migre les anciennes installations. Idempotent.
    Appelé au démarrage (lifespan) et par les tests."""
    import admin as admin_module
    for d in (PHOTOS_DIR, THUMBS_DIR, INCOMING_DIR):
        d.mkdir(parents=True, exist_ok=True)
    init_core_tables()
    init_db()
    admin_module.init_admin_db()
    photographer_service.init_photographer_table()
    from services import shoot_service
    shoot_service.init_shoot_tables()


# ── Cycle de vie ───────────────────────────────────────────────────────────────

@asynccontextmanager
async def lifespan(app: FastAPI):
    problems = validate_production_config()
    if problems:
        if IS_PRODUCTION:
            raise RuntimeError("Configuration de production invalide :\n - " + "\n - ".join(problems))
        for p in problems:
            log.warning("Config (non bloquant hors production) : %s", p)
    init_all()

    tasks: list[asyncio.Task] = []
    if config.BACKGROUND_TASKS_ENABLED:
        from services.cleanup_service import run_cleanup_loop
        from services.backup_service import run_backup_loop
        from services.monitor_service import run_monitor_loop
        tasks.append(asyncio.create_task(run_as_leader("cleanup", run_cleanup_loop), name="leader-cleanup"))
        tasks.append(asyncio.create_task(run_as_leader("backup", run_backup_loop), name="leader-backup"))
        tasks.append(asyncio.create_task(run_as_leader("monitor", run_monitor_loop), name="leader-monitor"))
        if config.ORGANIZER_ENABLED:
            from services.organizer_service import run_all_organizers
            tasks.append(asyncio.create_task(run_as_leader("organizer", run_all_organizers), name="leader-organizer"))
    log.info("Meeconic Portal API ready (env=%s, port=%d)", config.APP_ENV, PORT)
    try:
        yield
    finally:
        for t in tasks:
            t.cancel()
        await asyncio.gather(*tasks, return_exceptions=True)
        log.info("Meeconic Portal API stopped cleanly")


app = FastAPI(title="Meeconic Portal API", version="2.0.0", lifespan=lifespan,
              docs_url=None if IS_PRODUCTION else "/docs", redoc_url=None, openapi_url=None if IS_PRODUCTION else "/openapi.json")

import admin as admin_module  # noqa: E402
import ia as ia_module  # noqa: E402
import shoot_api  # noqa: E402
app.include_router(admin_module.router)
app.include_router(ia_module.router)
app.include_router(shoot_api.router)

app.add_middleware(
    CORSMiddleware,
    allow_origins=CORS_ORIGINS,
    allow_credentials=True,
    allow_methods=["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
    allow_headers=["Authorization", "Content-Type", "X-School"],
)


class _SelectiveGZip:
    """GZip for JSON/SVG/text only: photos and zips are already compressed, gzipping them just burns CPU."""

    _SKIP = ("/api/media/", "/api/portal/download", "/api/admin/cards.zip", "/api/admin/export")

    def __init__(self, app):
        self.app = app
        self.gzip = GZipMiddleware(app, minimum_size=1024)

    async def __call__(self, scope, receive, send):
        if scope["type"] == "http" and not scope["path"].startswith(self._SKIP):
            await self.gzip(scope, receive, send)
        else:
            await self.app(scope, receive, send)


app.add_middleware(_SelectiveGZip)


@app.middleware("http")
async def security_headers(request: Request, call_next):
    response = await call_next(request)
    h = response.headers
    h.setdefault("X-Content-Type-Options", "nosniff")
    h.setdefault("Referrer-Policy", "same-origin")
    h.setdefault("X-Frame-Options", "DENY")
    # The family portal scans QR codes with the camera: allow it for ourselves only.
    h.setdefault("Permissions-Policy", "camera=(self), microphone=(), geolocation=(), payment=(self)")
    h.setdefault("Cross-Origin-Opener-Policy", "same-origin")
    if IS_PRODUCTION:
        h.setdefault("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
    if request.url.path.startswith("/api/"):
        h.setdefault("Cache-Control", "no-store")
    return response


class ClientError(BaseModel):
    message: str = Field("", max_length=500)
    stack: str = Field("", max_length=1500)
    url: str = Field("", max_length=200)


@app.post("/api/client-error", status_code=204)
async def client_error(body: ClientError, request: Request):
    """Browser crash report (ErrorBoundary beacon). Rate limited, logged only: never trust or echo the content."""
    rate_limit_enforce(f"client-error:{client_ip(request)}", 20, 3600)
    flat = lambda v: " ".join(v.split())  # noqa: E731  (one log line per report, no injected line breaks)
    log.warning("Browser error on %s: %s | %s", flat(body.url), flat(body.message), flat(body.stack)[:300])


@app.exception_handler(Exception)
async def unhandled_exception(request: Request, exc: Exception):
    log.exception("Unhandled error on %s %s", request.method, request.url.path)
    alert_service.notify(f"http-500:{request.url.path}", f"Erreur 500 sur {request.method} {request.url.path}",
                         f"{type(exc).__name__}: {exc}")
    return JSONResponse(status_code=500, content={"detail": "Erreur interne du serveur."})


# ── Modèles ────────────────────────────────────────────────────────────────────

class OrderItem(BaseModel):
    photo_filename:  str = Field(min_length=1, max_length=200)
    format_id:       str = Field(min_length=1, max_length=40)
    format_label:    str = Field(default="", max_length=120)
    quantity:        int = Field(ge=1, le=20)
    unit_price:      float
    is_magic:        bool = False
    job_label:       Optional[str] = Field(default=None, max_length=120)


class CreateOrderRequest(BaseModel):
    family_code:      str
    # The schools may not give us names, so the order carries the class (picked from the school's list)
    # and an e-mail — both mandatory: the e-mail is where the HD files are delivered.
    class_name:       str = Field(min_length=1, max_length=40)
    customer_email:   str = Field(min_length=3, max_length=200)
    shipping_address: Optional[str] = Field(default=None, max_length=500)
    items:            list[OrderItem] = Field(min_length=1, max_length=50)
    total:            float


class ConsentRequest(BaseModel):
    family_code: str


# ── Helpers ────────────────────────────────────────────────────────────────────

def _norm_code(code: str) -> str:
    """Mayúsculas y sin espacios: lo que la familia teclea ('tst 1001') no siempre es lo impreso."""
    return "".join((code or "").split()).upper()


def _get_family(code: str):
    """Famille connue en base + son école (ou 404 générique)."""
    if not media_service.valid_code(code):
        raise HTTPException(404, detail="Code introuvable — vérifiez votre carte.")
    # Tolera guiones/espacios ('MARABC123' == 'MAR-ABC-123'): lo que escribe la familia no siempre es lo impreso.
    with get_db() as conn:
        fam = conn.execute(
            "SELECT f.*, s.active AS school_active FROM families f "
            "LEFT JOIN schools s ON s.id = f.school_id "
            "WHERE f.code=? OR REPLACE(f.code,'-','')=? ORDER BY (f.code=?) DESC LIMIT 1",
            (code, photo_service.norm_code(code), code)).fetchone()
    if not fam or not fam["school_id"] or not fam["school_active"]:
        raise HTTPException(404, detail="Code introuvable — vérifiez votre carte.")
    return fam


def _active_products(school_id: str) -> list[dict]:
    """Catalogue items the family can add next to prints and digital files (with their example visual)."""
    with get_db() as conn:
        rows = conn.execute("SELECT * FROM school_products WHERE school_id=? AND active=1 ORDER BY sort_order, id",
                            (school_id,)).fetchall()
    return [{
        "id": r["id"], "format_id": f"product-{r['id']}", "name_fr": r["name_fr"],
        "name_en": r["name_en"] or r["name_fr"], "name_es": r["name_es"] or r["name_fr"],
        "price": r["price"], "physical": bool(r["physical"]),
        "image_url": f"/api/portal/product-image/{school_id}/{r['id']}.webp?v={r['has_image']}" if r["has_image"] else None,
    } for r in rows]


def entitled_downloads(items: list[dict]) -> list[dict]:
    """Fichiers originaux auxquels une commande payée donne droit."""
    seen, out = set(), []
    for it in items:
        if it.get("format_id") in school_service.DIGITAL_FORMATS and it["photo_filename"] not in seen:
            seen.add(it["photo_filename"])
            out.append(it)
    return out


# ── Routes ─────────────────────────────────────────────────────────────────────

@app.get("/api/health")
async def health():
    db_ok = True
    try:
        with get_db() as conn:
            conn.execute("SELECT 1").fetchone()
    except Exception:
        db_ok = False
    return JSONResponse(status_code=200 if db_ok else 503,
                        content={"status": "ok" if db_ok else "degraded", "db": db_ok, "version": "2.0.0"})


@app.get("/api/portal/photos")
async def get_photos(request: Request, code: str = Query(..., min_length=2, max_length=24)):
    code = _norm_code(code)
    ip = client_ip(request)
    # Un code famille se devine : on limite les ÉCHECS par IP (une famille légitime
    # ne rate presque jamais son code, alors qu'un scanner enchaîne les 404).
    from core.rate_limit import is_blocked, record_hit
    key = f"photos-lookup:{ip}"
    if is_blocked(key, max_attempts=15, window_seconds=600):
        audit_service.log_event("auth.code_bruteforce_blocked", ip_address=ip, severity="critical",
                                details={"max_attempts": 15, "window_seconds": 600})
        raise HTTPException(429, detail="Too many attempts — try again later.")
    try:
        fam = _get_family(code)
    except HTTPException:
        record_hit(key)
        raise

    code = fam["code"]                       # el código canónico, no el que tecleó la familia
    school_id = fam["school_id"]
    settings = school_service.get_settings(school_id)
    consent_given = bool(fam["consent_at"])
    photos = media_service.list_family_photos(school_id, code) if consent_given else []
    ai_used = sum(1 for p in photos if p["filename"].startswith("ia_"))
    ai_max = int(settings["ai_max_per_family"])
    return {
        "family_code":    code,
        "school_name":    settings["school_name"],
        "photos":         photos,
        "photos_pending": consent_given and not photos,
        "pricing":        school_service.pricing_from_settings(settings),
        "products":       _active_products(school_id),
        "consent_given":  consent_given,
        "rgpd_days":      settings["rgpd_days"],
        "ai_enabled":     settings["ai_enabled"] == "true" and ai_max > 0,
        "ai_remaining":   max(0, ai_max - ai_used),
        # Overlay animado (lens/puzzle) que dibuja el navegador sobre la foto. Las caras ya vienen
        # protegidas en el archivo que se sirve (protection_service); esto es la capa anti-captura.
        "protection":     protection_service.public_view(protection_service.get_protection(school_id)),
    }


@app.get("/api/media/preview/{code}/{filename}")
async def media_preview(code: str, filename: str, exp: int = Query(...), sig: str = Query(...),
                        size: str = Query("full", pattern="^(full|thumb)$")):
    if not media_service.check_preview_signature(code, filename, exp, sig):
        raise HTTPException(403, detail="Lien expiré ou invalide.")
    school_id = await asyncio.to_thread(school_service.school_id_for_family, code)
    path = await media_service.render_preview(school_id, code, filename, size) if school_id else None
    if path is None:
        raise HTTPException(404, detail="Photo introuvable.")
    return FileResponse(path, media_type="image/webp",
                        headers={"Cache-Control": "private, max-age=3600"})


@app.get("/api/media/admin-thumb/{school_id}/{photographer}/{code}/{filename}")
async def media_admin_thumb(school_id: str, photographer: str, code: str, filename: str,
                            exp: int = Query(...), sig: str = Query(...)):
    path = await asyncio.to_thread(media_service.admin_thumb_path, school_id, photographer, code, filename, exp, sig)
    if path is None:
        raise HTTPException(403, detail="Lien expiré ou invalide.")
    return FileResponse(path, media_type="image/webp", headers={"Cache-Control": "private, max-age=600"})


@app.post("/api/portal/consent")
async def give_consent(body: ConsentRequest, request: Request):
    """Enregistre le consentement RGPD explicite. Idempotent : le premier
    consentement fait foi."""
    fam = _get_family(_norm_code(body.family_code))
    code = fam["code"]
    ip = client_ip(request)
    now = utcnow_iso()
    with get_db() as conn:
        conn.execute(
            "UPDATE families SET consent_at = COALESCE(consent_at, ?), consent_ip = COALESCE(consent_ip, ?) WHERE code=?",
            (now, ip, code))
        row = conn.execute("SELECT consent_at FROM families WHERE code=?", (code,)).fetchone()
        conn.commit()
    audit_service.log_event("consent.given", ip_address=ip, resource_type="family", resource_id=code,
                            severity="info", school_id=fam["school_id"])
    return {"consent_given": True, "consent_at": row["consent_at"]}


@app.get("/api/portal/classes")
async def get_classes(code: str = Query(..., min_length=2, max_length=24)):
    """Classes of the family's school, for the checkout dropdown (typed once in the back-office)."""
    fam = _get_family(_norm_code(code))
    with get_db() as conn:
        rows = conn.execute("SELECT name FROM school_classes WHERE school_id=?", (fam["school_id"],)).fetchall()
        mine = [r["class_name"] for r in conn.execute(
            "SELECT class_name FROM students WHERE family_code=? ORDER BY id", (fam["code"],))]
    from admin import class_sort_key
    names = sorted((r["name"] for r in rows), key=class_sort_key)
    return {"classes": names, "family_classes": mine}


@app.post("/api/portal/orders")
async def create_order(body: CreateOrderRequest, request: Request):
    code = _norm_code(body.family_code)
    ip = client_ip(request)
    rate_limit_enforce(f"order-create:{ip}", max_attempts=20, window_seconds=3600)
    fam = _get_family(code)
    code = fam["code"]
    if not fam["consent_at"]:
        raise HTTPException(403, detail="Le consentement RGPD est requis avant toute commande.")

    settings = school_service.get_settings(fam["school_id"])
    if settings["online_payment"] != "true":
        raise HTTPException(403, detail="Le paiement en ligne n'est pas activé pour cette école.")
    email = body.customer_email.strip()
    if not _EMAIL_RE.match(email):
        raise HTTPException(400, detail="Adresse e-mail invalide.")
    class_name = " ".join(body.class_name.split())
    with get_db() as conn:
        known = conn.execute("SELECT 1 FROM school_classes WHERE school_id=? AND name=?",
                             (fam["school_id"], class_name)).fetchone()
    if not known:
        raise HTTPException(400, detail="Classe inconnue pour cette école.")

    # Le serveur est seul juge des prix et des fichiers commandés.
    catalogue = {p["format_id"]: p for p in _active_products(fam["school_id"])}
    for it in body.items:
        if it.format_id.startswith("product-"):
            product = catalogue.get(it.format_id)
            if product is None:
                raise HTTPException(400, detail="Produit inconnu ou retiré du catalogue.")
            if abs(it.unit_price - float(product["price"])) > 0.001:
                raise HTTPException(400, detail="Les tarifs ont changé — rechargez la page.")
            if it.is_magic:
                raise HTTPException(400, detail="Format incohérent.")
            if media_service.safe_photo_path(fam["school_id"], code, it.photo_filename) is None:
                raise HTTPException(400, detail=f"Photo introuvable : {it.photo_filename}")
            continue
        price_key = school_service.FORMAT_PRICE_KEY.get(it.format_id)
        if price_key is None:
            raise HTTPException(400, detail=f"Format inconnu : {it.format_id}")
        if abs(it.unit_price - float(settings[price_key])) > 0.001:
            raise HTTPException(400, detail="Les tarifs ont changé — rechargez la page.")
        if it.is_magic != (it.format_id in school_service.MAGIC_FORMATS):
            raise HTTPException(400, detail="Format incohérent.")
        if it.is_magic and not it.photo_filename.startswith("ia_"):
            raise HTTPException(400, detail="Une photo magique doit être générée avant d'être commandée.")
        if media_service.safe_photo_path(fam["school_id"], code, it.photo_filename) is None:
            raise HTTPException(400, detail=f"Photo introuvable : {it.photo_filename}")

    computed = sum(it.unit_price * it.quantity for it in body.items)
    if abs(computed - body.total) > 0.01:
        raise HTTPException(400, detail="Total incohérent.")

    order_id = str(uuid.uuid4())
    now = utcnow_iso()
    items_data = [it.model_dump() for it in body.items]

    # PaymentIntent AVANT l'insertion : pas de commande « unpaid » orpheline si Stripe échoue.
    client_secret = None
    payment_intent_id = None
    if STRIPE_SECRET_KEY:
        try:
            intent = await asyncio.to_thread(
                stripe.PaymentIntent.create,
                api_key=STRIPE_SECRET_KEY,
                amount=round(computed * 100),
                currency="eur",
                metadata={"order_id": order_id, "family_code": code, "school_id": fam["school_id"]},
                receipt_email=email,
                automatic_payment_methods={"enabled": True},
            )
            client_secret = intent.client_secret
            payment_intent_id = intent.id
        except Exception as e:
            log.error("Stripe PaymentIntent error: %s", e)
            alert_service.notify("stripe-intent-failed", "Stripe : création de PaymentIntent impossible", str(e))
            raise HTTPException(502, detail="Le service de paiement est momentanément indisponible.")
    else:
        log.warning("STRIPE_SECRET_KEY missing — order created without payment (development only).")

    with get_db() as conn:
        conn.execute(
            """INSERT INTO orders (id, family_code, customer_name, class_name, customer_email, shipping_address,
               total, status, items_json, created_at, payment_status, stripe_payment_intent_id, school_id)
               VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
            (order_id, code, "", class_name, email, body.shipping_address, round(computed, 2), "pending",
             json.dumps(items_data, ensure_ascii=False), now, "unpaid", payment_intent_id, fam["school_id"]),
        )
        conn.commit()

    log.info("Order created: %s family=%s total=%.2f", order_id, code, computed)
    audit_service.log_event(
        "sale.create", ip_address=ip, resource_type="order", resource_id=order_id, school_id=fam["school_id"],
        details={"family_code": code, "total": computed, "items": len(body.items)}, severity="info")
    return {"order_id": order_id, "client_secret": client_secret, "success": True}


# ── Escaneo de la tarjeta con la cámara ────────────────────────────────────────

class ScanRequest(BaseModel):
    image: str = Field(min_length=20, max_length=int(config.SCAN_MAX_IMAGE_BYTES * 4 / 3) + 100)


_prefix_cache: dict = {"at": 0.0, "value": set()}


def _known_prefixes() -> set:
    """Prefijos de código de todos los colegios activos (config + carpetas en disco) para el OCR del escaneo.
    En caché 60 s: recorrer el disco en cada fotograma sería absurdo."""
    import time
    from services import qr_detect
    now = time.monotonic()
    if now - _prefix_cache["at"] > 60:
        prefixes: set = set()
        for s in school_service.list_schools(include_inactive=False):
            prefixes |= qr_detect.valid_prefixes(school_service.code_prefix_for(s["id"]), photo_service.school_root(s["id"]))
        _prefix_cache.update(at=now, value=prefixes)
    return _prefix_cache["value"]


@app.post("/api/portal/scan")
async def portal_scan(body: ScanRequest, request: Request):
    """Fotograma de la cámara del móvil → código de familia (OpenCV completo + OCR del código impreso).

    Misma idea que `/api/scan/frame` de hec-photo-app: el navegador prueba antes con jsQR (gratis, en local)
    y solo manda aquí los fotogramas que no ha podido leer. Devuelve únicamente el texto leído en la tarjeta;
    no consulta la base, así que no revela si el código existe. Limitado por IP."""
    rate_limit_enforce(f"portal-scan:{client_ip(request)}", max_attempts=120, window_seconds=300)
    prefixes = await asyncio.to_thread(_known_prefixes)
    return await asyncio.to_thread(scan_service.scan_frame, body.image, prefixes)


@app.get("/r/{code}")
async def qr_redirect(code: str, request: Request):
    """Punto de entrada de las URLs embebidas en el QR (`https://dominio/r/CÓDIGO`) → portal con el código.

    El esquema es HTTPS por defecto: tras un proxy inverso `request.url.scheme` llega como http (la conexión
    interna), y no todos los vhost de :80 redirigen a HTTPS (QR_REDIRECT_SCHEME lo cambia si hiciera falta)."""
    from services import qr_detect
    from fastapi.responses import RedirectResponse
    clean = qr_detect.extract_code(code)
    if not clean:
        raise HTTPException(404, detail="Code introuvable.")
    host = request.headers.get("host", "")
    return RedirectResponse(url=f"{config.QR_REDIRECT_SCHEME}://{host}/?code={clean}", status_code=302)


# ── Webhook Stripe ─────────────────────────────────────────────────────────────

@app.post("/api/stripe/webhook")
async def stripe_webhook(request: Request):
    payload = await request.body()
    sig_header = request.headers.get("stripe-signature", "")

    if STRIPE_WEBHOOK_SECRET:
        try:
            event = stripe.Webhook.construct_event(payload, sig_header, STRIPE_WEBHOOK_SECRET)
        except Exception as e:
            log.warning("Stripe webhook rejected (invalid signature): %s", e)
            alert_service.notify("stripe-webhook-signature", "Webhook Stripe rejeté (signature invalide)",
                                 "Vérifiez que STRIPE_WEBHOOK_SECRET correspond à l'endpoint configuré dans Stripe.")
            raise HTTPException(400, detail="Signature invalide.")
    elif IS_PRODUCTION:
        raise HTTPException(503, detail="Webhook non configuré.")
    else:
        log.warning("STRIPE_WEBHOOK_SECRET missing — signature not verified (development only).")
        try:
            event = json.loads(payload)
        except Exception:
            raise HTTPException(400, detail="Payload invalide.")

    def g(obj, key, default=None):
        return obj.get(key, default) if isinstance(obj, dict) else getattr(obj, key, default)

    event_type = g(event, "type")
    data_object = g(g(event, "data"), "object")
    pi_id = g(data_object, "id")

    try:
        if event_type == "payment_intent.succeeded":
            await _mark_order_paid(pi_id, g(data_object, "amount_received") or g(data_object, "amount"))
        elif event_type == "payment_intent.payment_failed":
            await _mark_order_failed(pi_id)
    except Exception as e:
        log.exception("Webhook %s processing failed", event_type)
        alert_service.notify("stripe-webhook-processing", f"Webhook Stripe {event_type} en échec",
                             f"payment_intent={pi_id} : {type(e).__name__}: {e}")
        raise HTTPException(500, detail="Traitement en échec — Stripe réessaiera.")
    return {"received": True}


def _collect_attachments(school_id: str, code: str, entitled: list[dict]) -> list[tuple[str, bytes]]:
    """Read the paid HD originals to attach to the confirmation e-mail.

    All-or-nothing on size: a partial set would look like the order was short-changed, so if the total
    exceeds EMAIL_ATTACH_MAX_MB nothing is attached and the family uses the portal download instead.
    A missing file (already purged) is skipped, never fatal — the payment is already booked."""
    limit = config.EMAIL_ATTACH_MAX_MB * 1024 * 1024
    files: list[tuple[str, bytes]] = []
    total = 0
    for it in entitled:
        path = media_service.safe_photo_path(school_id, code, it["photo_filename"])
        if path is None:
            log.warning("Attachment skipped, original not found: %s/%s", code, it["photo_filename"])
            continue
        data = path.read_bytes()
        total += len(data)
        if total > limit:
            log.info("Originals for %s exceed %d MB — not attached, portal download only", code, config.EMAIL_ATTACH_MAX_MB)
            return []
        files.append((path.name, data))
    return files


async def _mark_order_paid(payment_intent_id: str, amount_cents: Optional[int]) -> None:
    with get_db() as conn:
        row = conn.execute("SELECT * FROM orders WHERE stripe_payment_intent_id=?", (payment_intent_id,)).fetchone()
        if not row:
            log.warning("Webhook succeeded pour un payment_intent inconnu: %s", payment_intent_id)
            return
        if amount_cents is not None and int(amount_cents) != round(row["total"] * 100):
            alert_service.notify(
                f"stripe-amount-mismatch:{row['id']}", "Paiement Stripe : montant différent de la commande",
                f"commande {row['id']} attendu {round(row['total'] * 100)} centimes, reçu {amount_cents}. "
                f"La commande n'a PAS été marquée payée.")
            return
        cur = conn.execute(
            "UPDATE orders SET payment_status='paid', status=CASE WHEN status='cancelled' THEN 'pending' ELSE status END "
            "WHERE id=? AND payment_status != 'paid'", (row["id"],))
        conn.commit()
        newly_paid = cur.rowcount > 0
    if not newly_paid:
        return  # idempotence : le webhook peut être livré plusieurs fois

    order_id, code = row["id"], row["family_code"]
    items = json.loads(row["items_json"])
    log.info("Order paid: %s family=%s total=%.2f", order_id, code, row["total"])
    audit_service.log_event("sale.paid", resource_type="order", resource_id=order_id, school_id=row["school_id"],
                            details={"family_code": code, "total": row["total"]}, severity="info")

    if EMAIL_ENABLED:
        from services.email_service import send_order_confirmation, send_company_notification
        settings = school_service.get_settings(row["school_id"]) if row["school_id"] else {}
        portal_url = settings.get("portal_url", "")
        if portal_url and not portal_url.startswith("http"):
            portal_url = "https://" + portal_url
        entitled = entitled_downloads(items)
        attachments = await asyncio.to_thread(_collect_attachments, row["school_id"] or "", code, entitled)
        asyncio.create_task(send_order_confirmation(
            to=row["customer_email"], name="", order_id=order_id, family_code=code,
            items=items, total=row["total"], has_digital=bool(entitled), portal_url=portal_url,
            attachments=attachments))
        asyncio.create_task(send_company_notification(
            order_id=order_id, family_code=code, customer_name=row["class_name"] or "",
            customer_email=row["customer_email"], items=items, total=row["total"],
            to=settings.get("notification_email", "")))


from services import cleanup_service  # noqa: E402  (registers the webhook's booking as the reconciler)
cleanup_service.set_payment_reconciler(_mark_order_paid)


async def _mark_order_failed(payment_intent_id: str) -> None:
    with get_db() as conn:
        row = conn.execute("SELECT * FROM orders WHERE stripe_payment_intent_id=?", (payment_intent_id,)).fetchone()
        if not row or row["payment_status"] == "paid":
            return
        was_failed = row["payment_status"] == "failed"
        conn.execute("UPDATE orders SET payment_status='failed' WHERE id=?", (row["id"],))
        conn.commit()
    log.info("Payment failed for payment_intent=%s", payment_intent_id)
    if EMAIL_ENABLED and not was_failed:
        from services.email_service import send_payment_failed
        settings = school_service.get_settings(row["school_id"]) if row["school_id"] else {}
        portal_url = settings.get("portal_url", "")
        if portal_url and not portal_url.startswith("http"):
            portal_url = "https://" + portal_url
        asyncio.create_task(send_payment_failed(row["customer_email"], "", row["id"], portal_url))


# ── Historique + téléchargements ───────────────────────────────────────────────

@app.get("/api/portal/orders")
async def get_orders(request: Request, code: str = Query(..., min_length=2, max_length=24)):
    code = _norm_code(code)
    rate_limit_enforce(f"orders-lookup:{client_ip(request)}", max_attempts=60, window_seconds=300)
    with get_db() as conn:
        rows = conn.execute("SELECT * FROM orders WHERE family_code = ? ORDER BY created_at DESC", (code,)).fetchall()

    orders = []
    for row in rows:
        items = json.loads(row["items_json"])
        downloads = []
        if row["payment_status"] == "paid" and row["status"] != "cancelled":
            for it in entitled_downloads(items):
                downloads.append({
                    "filename": it["photo_filename"],
                    "label": it.get("job_label") or it.get("format_label") or it["photo_filename"],
                    "url": media_service.download_url(row["id"], it["photo_filename"]),
                })
        orders.append({
            "id": row["id"], "family_code": row["family_code"],
            "customer_email": row["customer_email"], "class_name": row["class_name"] or "",
            "total": row["total"], "status": row["status"], "payment_status": row["payment_status"],
            "items": items, "downloads": downloads, "created_at": row["created_at"],
            "zip_url": media_service.download_zip_url(row["id"]) if len(downloads) > 1 else None,
        })
    return {"orders": orders}


@app.get("/api/portal/download/{order_id}/{filename}")
async def download_original(order_id: str, filename: str, request: Request,
                            exp: int = Query(...), sig: str = Query(...)):
    if not media_service.check_download_signature(order_id, filename, exp, sig):
        raise HTTPException(403, detail="Lien expiré — rechargez « Mes commandes ».")
    with get_db() as conn:
        row = conn.execute("SELECT * FROM orders WHERE id=?", (order_id,)).fetchone()
    if not row or row["payment_status"] != "paid" or row["status"] == "cancelled":
        raise HTTPException(403, detail="Commande non payée.")
    items = json.loads(row["items_json"])
    if filename not in {it["photo_filename"] for it in entitled_downloads(items)}:
        raise HTTPException(403, detail="Cette commande ne comprend pas le téléchargement de cette photo.")
    path = media_service.safe_photo_path(row["school_id"] or "", row["family_code"], filename)
    if path is None:
        raise HTTPException(404, detail="Photo indisponible (supprimée après la durée de conservation).")
    audit_service.log_event("photo.download", ip_address=client_ip(request), resource_type="order",
                            resource_id=order_id, details={"file": filename}, severity="info",
                            school_id=row["school_id"])
    return FileResponse(path, filename=path.name, headers={"Cache-Control": "private, no-store"})


@app.get("/api/portal/download-zip/{order_id}")
async def download_order_zip(order_id: str, request: Request, exp: int = Query(...), sig: str = Query(...)):
    """All purchased originals of one paid order in a single ZIP (JPEGs are already compressed: stored, not deflated)."""
    if not media_service.check_download_zip_signature(order_id, exp, sig):
        raise HTTPException(403, detail="Link expired — reload “My orders”.")
    with get_db() as conn:
        row = conn.execute("SELECT * FROM orders WHERE id=?", (order_id,)).fetchone()
    if not row or row["payment_status"] != "paid" or row["status"] == "cancelled":
        raise HTTPException(403, detail="Order not paid.")
    names = sorted({it["photo_filename"] for it in entitled_downloads(json.loads(row["items_json"]))})
    paths = [p for p in (media_service.safe_photo_path(row["school_id"] or "", row["family_code"], n) for n in names) if p]
    if not paths:
        raise HTTPException(404, detail="Photos unavailable (deleted after the retention period).")
    import tempfile
    import zipfile
    from starlette.background import BackgroundTask
    tmp = tempfile.NamedTemporaryFile(prefix="meec-", suffix=".zip", delete=False)
    tmp.close()
    with zipfile.ZipFile(tmp.name, "w", zipfile.ZIP_STORED) as zf:
        for p in paths:
            zf.write(p, arcname=p.name)
    audit_service.log_event("photo.download_zip", ip_address=client_ip(request), resource_type="order",
                            resource_id=order_id, details={"files": len(paths)}, severity="info",
                            school_id=row["school_id"])
    return FileResponse(tmp.name, filename=f"meeconic-{row['family_code']}-{order_id[:8]}.zip",
                        headers={"Cache-Control": "private, no-store"},
                        background=BackgroundTask(lambda: os.unlink(tmp.name)))


# ── Point d'entrée ─────────────────────────────────────────────────────────────

if __name__ == "__main__":
    import os
    import uvicorn
    reload = config._bool("UVICORN_RELOAD", "false")
    workers = int(os.getenv("WEB_CONCURRENCY", "1"))
    uvicorn.run("main:app", host=HOST, port=PORT, reload=reload, workers=1 if reload else workers)
