"""Routine scheduler — fires time-based routines server-side (APScheduler).

Phase-2 routines have two kinds of trigger:
  * TIME (or the time half of BOTH) — fires from this gateway on a cron, whether
    or not the phone is even awake. That's what this module owns.
  * PLACE (or the place half of BOTH) — an on-device geofence; the phone sends a
    ``new_location`` event naming the matched place, which ``main`` matches to
    routines directly. Nothing here.

``RoutineScheduler`` loads routines from the phone store on startup, schedules a
cron job per time/both routine, and calls the injected ``dispatch(routine)``
coroutine when one fires. ``reload()`` is re-run after any routine
create/cancel so the live schedule always matches storage.

APScheduler is imported LAZILY inside ``start`` so ``import scheduler`` (and the
test suite) works with no event loop and even if the package is missing until
runtime. Tests inject a fake scheduler backend via ``sched=`` and never import
APScheduler.
"""

from __future__ import annotations

import logging

logger = logging.getLogger("jarvis.gateway.scheduler")

_WEEKDAYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
DEFAULT_TZ = "Asia/Riyadh"


def parse_cron(trigger: dict) -> dict | None:
    """Turn a routine trigger into cron fields, or None if it isn't time-based.

    Returns ``{"hour", "minute", "day_of_week"}`` where ``day_of_week`` is a
    comma list ('mon,wed') or None for every day. Place-only triggers, or
    malformed times, return None (the routine simply isn't time-scheduled)."""
    if not isinstance(trigger, dict):
        return None
    if trigger.get("type") not in ("time", "both"):
        return None
    at = str(trigger.get("at") or "").strip()
    if ":" not in at:
        return None
    hh, _, mm = at.partition(":")
    try:
        hour = int(hh)
        minute = int(mm)
    except ValueError:
        return None
    if not (0 <= hour <= 23 and 0 <= minute <= 59):
        return None
    days = trigger.get("days") or []
    picked = []
    if isinstance(days, list):
        for d in days:
            key = str(d).strip().lower()[:3]
            if key in _WEEKDAYS and key not in picked:
                picked.append(key)
    return {"hour": hour, "minute": minute,
            "day_of_week": ",".join(picked) if picked else None}


class RoutineScheduler:
    """Owns the APScheduler AsyncIOScheduler and the routine→cron-job mapping.

    ``dispatch``       async callable invoked with the routine dict when it fires.
    ``list_routines``  async callable returning the current list of routine dicts.
    ``sched``          optional pre-built scheduler backend (tests inject a fake).
    """

    def __init__(self, dispatch, list_routines, tz: str = DEFAULT_TZ,
                 sched=None) -> None:
        self.dispatch = dispatch
        self.list_routines = list_routines
        self.tz = tz
        self._sched = sched

    async def start(self) -> int:
        """Build+start the scheduler (if not injected) and load routines."""
        if self._sched is None:
            from apscheduler.schedulers.asyncio import AsyncIOScheduler
            self._sched = AsyncIOScheduler(timezone=self.tz)
        if not getattr(self._sched, "running", False):
            self._sched.start()
        return await self.reload()

    async def reload(self) -> int:
        """Rebuild all jobs from storage. Returns the number of time-scheduled
        routines. Never raises — a storage hiccup just leaves the schedule empty."""
        if self._sched is None:
            return 0
        for job in list(self._sched.get_jobs()):
            job.remove()
        try:
            routines = await self.list_routines()
        except Exception as e:  # noqa: BLE001 — storage down: no jobs, no crash
            logger.warning("routine reload failed: %s", str(e)[:120])
            return 0
        n = 0
        for r in routines or []:
            cron = parse_cron(r.get("trigger") or {})
            if not cron:
                continue
            self._add_job(r, cron)
            n += 1
        logger.info("routine scheduler loaded %d time-based routine(s)", n)
        return n

    def _add_job(self, routine: dict, cron: dict) -> None:
        from apscheduler.triggers.cron import CronTrigger
        trig = CronTrigger(hour=cron["hour"], minute=cron["minute"],
                           day_of_week=cron["day_of_week"], timezone=self.tz)
        self._sched.add_job(
            self._run, trigger=trig, args=[routine],
            id="routine:" + str(routine.get("name", "")),
            replace_existing=True, misfire_grace_time=300, coalesce=True)

    async def _run(self, routine: dict) -> None:
        try:
            await self.dispatch(routine)
        except Exception as e:  # noqa: BLE001 — one bad fire must not kill others
            logger.warning("routine '%s' dispatch failed: %s",
                           routine.get("name"), str(e)[:160])

    async def shutdown(self) -> None:
        if self._sched is not None and getattr(self._sched, "running", False):
            try:
                self._sched.shutdown(wait=False)
            except Exception:  # noqa: BLE001
                pass
