"""APScheduler bootstrap (``docs/06-operational-flow.md``).

Wraps :class:`AsyncIOScheduler` so the orchestrator can register the
documented cron jobs in one place. The scheduler is built but not
started; ``start()`` must be called from inside the running event
loop.
"""

from __future__ import annotations

import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass

from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger

__all__ = ["JobSpec", "build_scheduler"]


_log = logging.getLogger("cerbero_bite.runtime.scheduler")


@dataclass(frozen=True)
class JobSpec:
    """One row in the scheduler manifest."""

    name: str
    cron: str
    coro_factory: Callable[[], Awaitable[None]]


def _parse_cron(expr: str) -> CronTrigger:
    parts = expr.split()
    if len(parts) != 5:
        raise ValueError(f"cron must have 5 fields, got: {expr!r}")
    minute, hour, day, month, day_of_week = parts
    return CronTrigger(
        minute=minute,
        hour=hour,
        day=day,
        month=month,
        day_of_week=day_of_week,
        timezone="UTC",
    )


def build_scheduler(jobs: list[JobSpec]) -> AsyncIOScheduler:
    """Return an :class:`AsyncIOScheduler` with all *jobs* registered.

    The scheduler is *not* started — the caller is responsible for
    invoking ``start()`` after constructing it on a running event loop.
    """
    scheduler = AsyncIOScheduler(timezone="UTC")
    for spec in jobs:
        scheduler.add_job(
            spec.coro_factory,
            trigger=_parse_cron(spec.cron),
            id=spec.name,
            name=spec.name,
            replace_existing=True,
            coalesce=True,
            misfire_grace_time=300,
        )
        _log.info("scheduled job %s with cron %s", spec.name, spec.cron)
    return scheduler
