"""Schedule — cron-expression parser + scheduler Protocol.

Stability tier: **experimental** (AP-11) — added in 0.35.0 to
close the federation-wide pattern where three real consumers
(vm-vmanager-app via ``croniter``, iosRecovery via systemd timers,
PlasmaMapper via ``arq`` cron syntax) each rolled their own
cron-expression handling.

Why this module exists
----------------------

Three-consumer signal (the strongest evidence the federation has
collected). See `docs/inprogress/pre_release_additions.md` §4.1
+ §5 (C-NEW-1 row). Each consumer wires the same cron-shape
input ("every hour on the half-hour" / "@daily" / "0 2 * * 1-5")
against a different downstream backend (a 3rd-party library, a
systemd unit, an async-task-runner). Standardising the
**parser** + the **Protocol** lets all three keep their
backends while sharing the input shape.

This module is **stdlib-only by design** (feedback memory
`feedback_split_by_runtime_shape` — don't drag heavy deps into
core). The cron-expression parser is hand-rolled; the Scheduler
Protocol is `@runtime_checkable`.

Usage
-----

Just the parser (the most common case — wire to an existing
runner):

.. code-block:: python

    from datetime import UTC, datetime
    from mgf.common.schedule import CronExpression

    expr = CronExpression.parse("0 */2 * * *")  # every 2 hours
    next_run = expr.next_fire(after=datetime.now(UTC))

The Protocol + a default in-process scheduler (useful for
desktop apps + scratch services):

.. code-block:: python

    from mgf.common.schedule import InProcessScheduler

    sched = InProcessScheduler()
    sched.schedule("@hourly", fn=lambda: print("tick"), name="heartbeat")

    # In your runner / event loop:
    while True:
        for task in sched.tick():
            task.fn()
        time.sleep(30)

Supported cron syntax
---------------------

Standard 5-field POSIX cron::

    minute  hour  day-of-month  month  day-of-week
       *     *        *           *         *

Per-field grammar:

- ``*``                  — every value in the field's range
- ``N``                  — specific value
- ``N-M``                — inclusive range
- ``*/N`` / ``N-M/N``    — step
- ``N,M,K``              — comma-separated list

Plus the canonical aliases:

- ``@yearly`` / ``@annually``  ≡ ``0 0 1 1 *``
- ``@monthly``                 ≡ ``0 0 1 * *``
- ``@weekly``                  ≡ ``0 0 * * 0``
- ``@daily`` / ``@midnight``   ≡ ``0 0 * * *``
- ``@hourly``                  ≡ ``0 * * * *``

Day-of-week: 0 = Sunday, 6 = Saturday. (7 is also accepted as
Sunday — matches Vixie / POSIX cron.)

NOT supported (defer to future v1.1+ if a consumer needs it):

- ``L`` (last day of month / last weekday)
- ``W`` (nearest weekday)
- ``#`` (Nth weekday — e.g., ``2#1`` = first Monday)
- Day-name aliases (``MON``, ``TUE``, ...) — use ``1``, ``2``, ...
- Month-name aliases (``JAN``, ``FEB``, ...) — use ``1``, ``2``, ...

Cross-references
----------------

- **AP-11** — stability tiers (this module ships ``experimental``).
- **DP-12** — datetimes carry tz info; **never naive** at
  module boundaries. ``CronExpression.next_fire`` requires a
  timezone-aware ``after``.

Cross-consumer pattern: see
`docs/inprogress/pre_release_additions.md` §4.1.
"""

from __future__ import annotations

import logging
import re
import threading
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import (
    TYPE_CHECKING,
    Protocol,
    runtime_checkable,
)

if TYPE_CHECKING:
    from collections.abc import Callable

_LOGGER = logging.getLogger(__name__)


__all__ = [
    "CronExpression",
    "InProcessScheduler",
    "ScheduledTask",
    "Scheduler",
]


# ---------------------------------------------------------------------------
# CronExpression — the parser
# ---------------------------------------------------------------------------


_FIELD_RANGES = (
    (0, 59),  # minute
    (0, 23),  # hour
    (1, 31),  # day of month
    (1, 12),  # month
    (0, 6),  # day of week (0 = Sunday)
)
_FIELD_NAMES = ("minute", "hour", "day_of_month", "month", "day_of_week")

_ALIAS_EXPANSIONS = {
    "@yearly": "0 0 1 1 *",
    "@annually": "0 0 1 1 *",
    "@monthly": "0 0 1 * *",
    "@weekly": "0 0 * * 0",
    "@daily": "0 0 * * *",
    "@midnight": "0 0 * * *",
    "@hourly": "0 * * * *",
}


def _parse_field(spec: str, lo: int, hi: int, field_name: str) -> frozenset[int]:
    """Parse one cron field into the set of integers it matches.

    Accepts ``*``, ``N``, ``N-M``, ``*/N``, ``N-M/N``, comma-lists
    of any of the above. The day-of-week field accepts 7 as
    Sunday for POSIX-cron compatibility (normalised to 0
    internally).
    """
    result: set[int] = set()
    for piece in spec.split(","):
        piece = piece.strip()
        if not piece:
            raise ValueError(f"cron field {field_name!r}: empty component in {spec!r}")

        # Step: thing/N
        step = 1
        if "/" in piece:
            base, step_str = piece.split("/", 1)
            try:
                step = int(step_str)
            except ValueError as exc:
                raise ValueError(
                    f"cron field {field_name!r}: step must be integer, "
                    f"got {step_str!r} in {piece!r}"
                ) from exc
            if step <= 0:
                raise ValueError(
                    f"cron field {field_name!r}: step must be positive, got {step} in {piece!r}"
                )
        else:
            base = piece

        # Base: *, N, or N-M
        if base == "*":
            start, end = lo, hi
        elif "-" in base:
            start_s, end_s = base.split("-", 1)
            try:
                start, end = int(start_s), int(end_s)
            except ValueError as exc:
                raise ValueError(
                    f"cron field {field_name!r}: range bounds must be integer, got {base!r}"
                ) from exc
            if start > end:
                raise ValueError(f"cron field {field_name!r}: range start > end in {base!r}")
        else:
            try:
                start = end = int(base)
            except ValueError as exc:
                raise ValueError(
                    f"cron field {field_name!r}: expected integer, '*', or range; got {base!r}"
                ) from exc

        # Day-of-week 7 → 0 normalisation (POSIX cron quirk).
        if field_name == "day_of_week":
            if start == 7:
                start = 0
            if end == 7:
                end = 0

        if start < lo or end > hi:
            raise ValueError(
                f"cron field {field_name!r}: value {start}..{end} out of allowed range {lo}..{hi}"
            )

        for v in range(start, end + 1, step):
            result.add(v)

    return frozenset(result)


@dataclass(frozen=True)
class CronExpression:
    """Parsed cron expression. Immutable; safe to share across threads.

    Construct via :meth:`parse`. The expression is decomposed into
    five integer sets (one per field); :meth:`matches` is a pure
    five-set membership test.

    .. code-block:: python

        expr = CronExpression.parse("*/15 * * * *")  # every 15 min
        expr.matches(datetime(2026, 5, 18, 9, 30, tzinfo=UTC))  # True
        expr.next_fire(after=datetime.now(UTC))                  # next slot
    """

    minutes: frozenset[int]
    hours: frozenset[int]
    days_of_month: frozenset[int]
    months: frozenset[int]
    days_of_week: frozenset[int]
    raw: str = ""

    @classmethod
    def parse(cls, expr: str) -> CronExpression:
        """Parse a cron expression string (5-field or @alias).

        Raises ``ValueError`` on any of:

        - Wrong number of fields (not 5, not a recognised alias).
        - Out-of-range value (e.g., hour=24, month=13).
        - Unrecognised alias.
        - Unsupported syntax (L / W / # / day-name / month-name).
        """
        original = expr
        expr = expr.strip()
        if not expr:
            raise ValueError("cron expression is empty")

        # Alias?
        if expr.startswith("@"):
            if expr not in _ALIAS_EXPANSIONS:
                raise ValueError(
                    f"unknown cron alias {original!r}; supported: {sorted(_ALIAS_EXPANSIONS)}"
                )
            expr = _ALIAS_EXPANSIONS[expr]

        # Reject unsupported syntax up-front for clearer errors.
        for unsupported in ("L", "W", "#"):
            if unsupported in expr:
                raise ValueError(
                    f"cron expression {original!r} uses the {unsupported!r} "
                    f"shortcut; not supported in mgf.common.schedule v0.35. "
                    f"Use plain numeric fields or file a paper if you need "
                    f"this (see mgf-common/FEEDBACK.md)."
                )
        # Day / month name aliases unsupported — fail with a hint.
        if re.search(r"[A-Za-z]", expr):
            raise ValueError(
                f"cron expression {original!r} contains letters; "
                f"day-name / month-name aliases (MON, JAN, ...) are not "
                f"supported. Use 1..7 for days of week, 1..12 for months."
            )

        fields = expr.split()
        if len(fields) != 5:
            raise ValueError(
                f"cron expression {original!r}: expected 5 space-separated "
                f"fields (minute hour day-of-month month day-of-week), "
                f"got {len(fields)}: {fields!r}"
            )

        parsed = []
        for field_str, name, (lo, hi) in zip(
            fields,
            _FIELD_NAMES,
            _FIELD_RANGES,
            strict=True,
        ):
            parsed.append(_parse_field(field_str, lo, hi, name))

        return cls(
            minutes=parsed[0],
            hours=parsed[1],
            days_of_month=parsed[2],
            months=parsed[3],
            days_of_week=parsed[4],
            raw=original,
        )

    def matches(self, ts: datetime) -> bool:
        """Return True if the expression fires at the given timestamp.

        The timestamp's `weekday()` is mapped to POSIX-cron's
        day-of-week (0=Sunday) — Python's stdlib uses 0=Monday,
        so we add 1 and mod 7.

        Requires ``ts`` to be timezone-aware (per **DP-12**).
        """
        if ts.tzinfo is None:
            raise ValueError(
                "CronExpression.matches() requires a timezone-aware "
                "datetime; got a naive one. Per DP-12, datetimes carry "
                "tz info at module boundaries."
            )

        # Python: Monday=0..Sunday=6.  POSIX-cron: Sunday=0..Saturday=6.
        posix_dow = (ts.weekday() + 1) % 7

        return (
            ts.minute in self.minutes
            and ts.hour in self.hours
            and ts.day in self.days_of_month
            and ts.month in self.months
            and posix_dow in self.days_of_week
        )

    def next_fire(self, *, after: datetime) -> datetime:
        """Return the next timestamp at-or-after ``after`` that
        matches the expression.

        Resolution is one minute (cron's native granularity);
        sub-minute precision in the ``after`` argument is
        truncated. The search is bounded — if no match within
        4 years, raises ``RuntimeError`` (which would indicate a
        nonsensical expression like ``0 0 31 2 *`` — Feb 31st).

        Requires ``after`` to be timezone-aware.
        """
        if after.tzinfo is None:
            raise ValueError(
                "CronExpression.next_fire(after=...) requires a timezone-aware datetime."
            )

        # Truncate to minute boundary + advance one minute (we want
        # STRICTLY AFTER `after`, matching cron semantics — if we're
        # currently at a firing minute, the NEXT match is one minute
        # later, not now).
        candidate = after.replace(second=0, microsecond=0) + timedelta(minutes=1)
        max_iters = 4 * 366 * 24 * 60  # 4 years of minutes
        for _ in range(max_iters):
            if self.matches(candidate):
                return candidate
            candidate += timedelta(minutes=1)
        raise RuntimeError(
            f"cron expression {self.raw!r} has no match within 4 years "
            f"of {after.isoformat()} — likely an impossible combination "
            f"(e.g., day=31 + month=2)."
        )


# ---------------------------------------------------------------------------
# Scheduler — Protocol + scheduled-task dataclass + in-process default
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class ScheduledTask:
    """A task registered with a :class:`Scheduler`.

    Returned by :meth:`Scheduler.schedule`; the consumer holds
    onto it for introspection (e.g., to log "registered N tasks").
    Identity is by ``(expr, name)`` — the scheduler dedups on
    that pair.
    """

    expr: CronExpression
    fn: Callable[[], None]
    name: str

    # When this task last fired (None = never). Tracked by the
    # Scheduler implementation; defaulted to None for static
    # construction.
    last_fired_at: datetime | None = field(default=None)


@runtime_checkable
class Scheduler(Protocol):
    """Schedule tasks on cron expressions.

    Implementations decide the *execution model*: in-process tick
    loop, async-runner-driven, systemd-timer-backed, etc. The
    Protocol surface is universal.
    """

    def schedule(
        self,
        expr: str,
        *,
        fn: Callable[[], None],
        name: str | None = None,
    ) -> ScheduledTask:
        """Register a task to fire on ``expr``.

        ``name`` defaults to a derived identifier (e.g., the
        callable's ``__qualname__``) if omitted. Re-scheduling
        the same ``name`` REPLACES the previous task — the
        register-side is idempotent so consumer code can call
        :meth:`schedule` from a startup hook without dedup logic.
        """
        ...

    def tick(self, *, now: datetime | None = None) -> list[ScheduledTask]:
        """Advance the scheduler to ``now`` (defaults to current
        UTC) and return the list of tasks that fired during this
        tick.

        Implementations are tick-driven: the consumer's runner
        calls ``tick()`` periodically. The default in-process
        impl fires each task at most once per ``tick()`` call
        (catching up missed fires would surprise consumers).
        """
        ...


class InProcessScheduler:
    """Tick-driven in-process scheduler — the default.

    Holds a dict of (name → ScheduledTask). On each ``tick()``,
    iterates tasks and fires those whose ``expr.matches(now)``
    is true AND whose ``last_fired_at`` was BEFORE the current
    minute (preventing double-fire when ``tick()`` is called
    multiple times per minute).

    Thread-safety: a single internal lock guards the task dict
    + the per-task last-fired tracking. Each task's ``fn`` is
    called WITHOUT the lock held (so consumer code can re-enter
    ``schedule`` from inside a fired callback if needed).

    NOT suitable for distributed deployments (multiple replicas
    would each fire the task — duplicate execution). Consumers
    with multi-replica needs wire a distributed-lock primitive
    (future C-9) on top of this OR use an external scheduler.

    Cron firing model: a tick at time T fires every task whose
    expression matches the minute-truncated form of T, IF that
    minute hasn't fired yet. ``InProcessScheduler.tick()`` is
    safe to call sub-minute (every 30 seconds, every 10
    seconds) — only the first call per minute fires a given
    task.
    """

    backend: str = "in-process"

    def __init__(self) -> None:
        self._lock = threading.Lock()
        self._tasks: dict[str, ScheduledTask] = {}

    def schedule(
        self,
        expr: str,
        *,
        fn: Callable[[], None],
        name: str | None = None,
    ) -> ScheduledTask:
        parsed = CronExpression.parse(expr)
        resolved_name = name or getattr(fn, "__qualname__", None) or str(id(fn))
        task = ScheduledTask(expr=parsed, fn=fn, name=resolved_name)
        with self._lock:
            self._tasks[resolved_name] = task
        return task

    def tick(self, *, now: datetime | None = None) -> list[ScheduledTask]:
        from datetime import UTC

        if now is None:
            now = datetime.now(UTC)
        if now.tzinfo is None:
            raise ValueError("InProcessScheduler.tick(now=...) requires a timezone-aware datetime.")
        now_minute = now.replace(second=0, microsecond=0)

        fired: list[ScheduledTask] = []
        to_call: list[ScheduledTask] = []
        with self._lock:
            for name, task in list(self._tasks.items()):
                if not task.expr.matches(now):
                    continue
                if task.last_fired_at is not None and task.last_fired_at >= now_minute:
                    continue  # already fired this minute
                # Update the bookkeeping copy.
                updated = ScheduledTask(
                    expr=task.expr,
                    fn=task.fn,
                    name=task.name,
                    last_fired_at=now_minute,
                )
                self._tasks[name] = updated
                to_call.append(updated)

        # Call OUTSIDE the lock so re-entrant schedule() from inside
        # fn doesn't deadlock.
        for task in to_call:
            try:
                task.fn()
            except Exception:
                _LOGGER.exception(
                    "scheduled task %r raised; continuing with other tasks",
                    task.name,
                    extra={
                        "event": "schedule.task_raised",
                        "task_name": task.name,
                    },
                )
            fired.append(task)
        return fired

    def tasks(self) -> list[ScheduledTask]:
        """Snapshot of currently-registered tasks. Test ergonomics."""
        with self._lock:
            return list(self._tasks.values())
