"""Registre des types de jobs (spec 2026-08-31 « socle jobs » §2.2).

Le worker est neutre : il ne connaît aucun type métier, il ne connaît que ce registre.
Chaque module déclare les siens à l'import de son propre code, par exemple :

    registry.register(JobType("import_csv", run_import, max_attempts=3, on_fail=mark_failed))

Une entrée porte le handler, le plafond de tentatives, la portée de la déduplication et un
callback d'échec optionnel — possédé par le module, jamais par le socle.
"""

from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from typing import Any, Literal

from sqlalchemy.ext.asyncio import AsyncSession

from app.jobs.models import Job

DEFAULT_MAX_ATTEMPTS = 3  # architecture §9 : 3 tentatives, puis `failed`, pas de dead-letter

# 'queue'  : clé relâchée au démarrage du run — le travail redevient enfilable aussitôt,
#            donc zéro lost-wakeup (jobs idempotents et bon marché).
# 'active' : clé tenue jusqu'à done/failed — jamais deux instances actives du même travail.
DedupScope = Literal["queue", "active"]

# Le handler reçoit la session du worker : il commit son propre progrès (checkpoints) et
# rend le rapport final. Une exception non attrapée fait échouer le job, jamais le worker.
Handler = Callable[[AsyncSession, Job], Awaitable[Mapping[str, Any] | None]]
OnFail = Callable[[AsyncSession, Job], Awaitable[None]]


@dataclass(frozen=True)
class JobType:
    name: str
    handler: Handler
    max_attempts: int = DEFAULT_MAX_ATTEMPTS
    dedup_scope: DedupScope = "queue"
    on_fail: OnFail | None = None


_types: dict[str, JobType] = {}


def register(job_type: JobType) -> None:
    if job_type.name in _types:
        raise ValueError(f"type de job déjà enregistré : {job_type.name}")
    _types[job_type.name] = job_type


def get(name: str) -> JobType | None:
    """Rend `None` pour un type inconnu : un job enfilé par un déploiement plus récent que
    le worker doit échouer proprement, pas tuer la boucle."""
    return _types.get(name)


def registered_names() -> list[str]:
    return sorted(_types)


def queue_scoped_names() -> list[str]:
    """Types dont le claim doit relâcher la `dedup_key` (spec §2.1)."""
    return [job_type.name for job_type in _types.values() if job_type.dedup_scope == "queue"]


# --- tâches périodiques -------------------------------------------------------------------
#
# Le planificateur des imports récurrents (T8) est **le sweep du worker existant**, décision
# de la spec Imports §9 : pas de cron, pas de second process, pas de broker. Le worker reste
# neutre — il ne connaît pas plus les schedules que les imports —, il exécute simplement ce
# que les modules ont déposé ici, exactement comme pour les types de jobs.
#
# Une tâche périodique n'est pas un job : elle ne passe pas par la file, elle *l'alimente*.
# Elle doit donc être bon marché, idempotente et sans état — le contrat est « une passe, un
# nombre de choses faites ». Une tâche qui lève est loguée et n'empêche ni les autres tâches
# ni l'exécution des jobs (worker.py).

Periodic = Callable[[AsyncSession], Awaitable[int]]


@dataclass(frozen=True)
class PeriodicTask:
    name: str
    run: Periodic


_periodics: dict[str, PeriodicTask] = {}


def register_periodic(task: PeriodicTask) -> None:
    if task.name in _periodics:
        raise ValueError(f"tâche périodique déjà enregistrée : {task.name}")
    _periodics[task.name] = task


def periodic_tasks() -> list[PeriodicTask]:
    return [_periodics[name] for name in sorted(_periodics)]


def periodic_names() -> list[str]:
    return sorted(_periodics)
