"""Unit tests for ``mgf.common.schedule`` — cron + scheduler (0.35.0)."""

from __future__ import annotations

import logging
from datetime import UTC, datetime, timedelta, timezone

import pytest

from mgf.common.schedule import (
    CronExpression,
    InProcessScheduler,
    ScheduledTask,
    Scheduler,
)

# ---------------------------------------------------------------------------
# CronExpression — basic parsing
# ---------------------------------------------------------------------------


class TestCronParsing:
    def test_wildcard_all_fields(self) -> None:
        e = CronExpression.parse("* * * * *")
        assert len(e.minutes) == 60
        assert len(e.hours) == 24
        assert len(e.days_of_month) == 31
        assert len(e.months) == 12
        assert len(e.days_of_week) == 7

    def test_specific_value(self) -> None:
        e = CronExpression.parse("30 14 * * *")
        assert e.minutes == frozenset({30})
        assert e.hours == frozenset({14})

    def test_range(self) -> None:
        e = CronExpression.parse("0 9-17 * * *")
        assert e.hours == frozenset(range(9, 18))

    def test_step(self) -> None:
        e = CronExpression.parse("*/15 * * * *")
        assert e.minutes == frozenset({0, 15, 30, 45})

    def test_range_with_step(self) -> None:
        e = CronExpression.parse("0 9-17/2 * * *")
        assert e.hours == frozenset({9, 11, 13, 15, 17})

    def test_comma_list(self) -> None:
        e = CronExpression.parse("0 8,12,17 * * *")
        assert e.hours == frozenset({8, 12, 17})

    def test_mixed_comma(self) -> None:
        e = CronExpression.parse("0,30 * 1-7,15-21 * *")
        assert e.minutes == frozenset({0, 30})
        assert e.days_of_month == frozenset(set(range(1, 8)) | set(range(15, 22)))

    def test_dow_7_means_sunday(self) -> None:
        # POSIX-cron quirk: both 0 and 7 = Sunday.
        e = CronExpression.parse("0 0 * * 7")
        assert e.days_of_week == frozenset({0})

    def test_raw_preserved(self) -> None:
        e = CronExpression.parse("  */15 * * * *  ")
        assert e.raw == "  */15 * * * *  "


# ---------------------------------------------------------------------------
# CronExpression — aliases
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    ("alias", "expected_raw"),
    [
        ("@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 test_aliases(alias: str, expected_raw: str) -> None:
    aliased = CronExpression.parse(alias)
    explicit = CronExpression.parse(expected_raw)
    # Field sets should be identical.
    assert aliased.minutes == explicit.minutes
    assert aliased.hours == explicit.hours
    assert aliased.days_of_month == explicit.days_of_month
    assert aliased.months == explicit.months
    assert aliased.days_of_week == explicit.days_of_week
    # raw is preserved as the alias string.
    assert aliased.raw == alias


# ---------------------------------------------------------------------------
# CronExpression — parse errors
# ---------------------------------------------------------------------------


class TestCronParseErrors:
    def test_empty(self) -> None:
        with pytest.raises(ValueError, match="empty"):
            CronExpression.parse("")

    def test_wrong_field_count(self) -> None:
        with pytest.raises(ValueError, match="expected 5"):
            CronExpression.parse("* * * *")  # 4 fields

    def test_out_of_range_minute(self) -> None:
        with pytest.raises(ValueError, match="out of allowed range"):
            CronExpression.parse("60 * * * *")

    def test_out_of_range_hour(self) -> None:
        with pytest.raises(ValueError, match="out of allowed range"):
            CronExpression.parse("0 24 * * *")

    def test_out_of_range_month(self) -> None:
        with pytest.raises(ValueError, match="out of allowed range"):
            CronExpression.parse("0 0 1 13 *")

    def test_negative_step(self) -> None:
        with pytest.raises(ValueError, match="step must be positive"):
            CronExpression.parse("*/0 * * * *")

    def test_reversed_range(self) -> None:
        with pytest.raises(ValueError, match="range start > end"):
            CronExpression.parse("0 17-9 * * *")

    def test_unknown_alias(self) -> None:
        with pytest.raises(ValueError, match="unknown cron alias"):
            CronExpression.parse("@nonexistent")

    def test_unsupported_L_shortcut(self) -> None:
        with pytest.raises(ValueError, match="not supported"):
            CronExpression.parse("0 0 L * *")

    def test_unsupported_W_shortcut(self) -> None:
        with pytest.raises(ValueError, match="not supported"):
            CronExpression.parse("0 0 15W * *")

    def test_unsupported_hash(self) -> None:
        with pytest.raises(ValueError, match="not supported"):
            CronExpression.parse("0 0 * * 2#1")

    def test_day_name_unsupported(self) -> None:
        with pytest.raises(ValueError, match="day-name"):
            CronExpression.parse("0 0 * * MON")


# ---------------------------------------------------------------------------
# CronExpression — matches() + next_fire()
# ---------------------------------------------------------------------------


class TestCronMatches:
    def test_matches_specific_minute(self) -> None:
        e = CronExpression.parse("30 14 * * *")
        ts = datetime(2026, 5, 18, 14, 30, tzinfo=UTC)
        assert e.matches(ts)
        # Off-by-one minute: no match.
        assert not e.matches(ts.replace(minute=29))
        assert not e.matches(ts.replace(minute=31))

    def test_matches_sunday_via_0(self) -> None:
        # 2026-05-17 is a Sunday.
        sunday = datetime(2026, 5, 17, 0, 0, tzinfo=UTC)
        assert sunday.weekday() == 6  # Python: Sunday=6
        e = CronExpression.parse("0 0 * * 0")  # POSIX Sun
        assert e.matches(sunday)

    def test_matches_via_dow_7(self) -> None:
        sunday = datetime(2026, 5, 17, 0, 0, tzinfo=UTC)
        e = CronExpression.parse("0 0 * * 7")
        assert e.matches(sunday)

    def test_naive_datetime_rejected(self) -> None:
        e = CronExpression.parse("* * * * *")
        with pytest.raises(ValueError, match="timezone-aware"):
            e.matches(datetime(2026, 5, 18, 14, 30))  # noqa: DTZ001

    def test_non_utc_tz_works(self) -> None:
        # Any tz-aware datetime works (per DP-12). The expression
        # doesn't have its own tz — it matches against whatever
        # tz the consumer supplies.
        ist = timezone(timedelta(hours=5, minutes=30))
        ts = datetime(2026, 5, 18, 14, 30, tzinfo=ist)
        e = CronExpression.parse("30 14 * * *")
        assert e.matches(ts)


class TestCronNextFire:
    def test_next_minute_match(self) -> None:
        e = CronExpression.parse("* * * * *")  # every minute
        after = datetime(2026, 5, 18, 14, 30, 25, tzinfo=UTC)
        # Strictly after; second + microsecond truncated.
        assert e.next_fire(after=after) == datetime(2026, 5, 18, 14, 31, tzinfo=UTC)

    def test_next_hourly(self) -> None:
        e = CronExpression.parse("@hourly")  # 0 * * * *
        after = datetime(2026, 5, 18, 14, 30, tzinfo=UTC)
        assert e.next_fire(after=after) == datetime(2026, 5, 18, 15, 0, tzinfo=UTC)

    def test_next_daily(self) -> None:
        e = CronExpression.parse("@daily")  # 0 0 * * *
        after = datetime(2026, 5, 18, 14, 0, tzinfo=UTC)
        assert e.next_fire(after=after) == datetime(2026, 5, 19, 0, 0, tzinfo=UTC)

    def test_next_skips_year(self) -> None:
        # Feb 29 — should skip to next leap year.
        e = CronExpression.parse("0 0 29 2 *")
        after = datetime(2026, 3, 1, 0, 0, tzinfo=UTC)
        # 2028 is the next leap year.
        assert e.next_fire(after=after) == datetime(2028, 2, 29, 0, 0, tzinfo=UTC)

    def test_impossible_combo_raises_eventually(self) -> None:
        # Feb 31 — never matches. Should raise RuntimeError within
        # 4 years of iteration.
        e = CronExpression.parse("0 0 31 2 *")
        after = datetime(2026, 5, 18, 0, 0, tzinfo=UTC)
        with pytest.raises(RuntimeError, match="no match within 4 years"):
            e.next_fire(after=after)


# ---------------------------------------------------------------------------
# Scheduler Protocol
# ---------------------------------------------------------------------------


class TestSchedulerProtocol:
    def test_inprocess_is_scheduler(self) -> None:
        assert isinstance(InProcessScheduler(), Scheduler)

    def test_duck_typed_passes(self) -> None:
        class MySched:
            def schedule(self, expr, *, fn, name=None):
                return ScheduledTask(
                    expr=CronExpression.parse(expr),
                    fn=fn,
                    name=name or "x",
                )

            def tick(self, *, now=None):
                return []

        assert isinstance(MySched(), Scheduler)


# ---------------------------------------------------------------------------
# InProcessScheduler — register + tick
# ---------------------------------------------------------------------------


class TestInProcessScheduler:
    def test_schedule_returns_task(self) -> None:
        sched = InProcessScheduler()
        called = []
        task = sched.schedule("@hourly", fn=lambda: called.append(1), name="heartbeat")
        assert task.name == "heartbeat"
        assert task.expr.raw == "@hourly"

    def test_default_name_from_callable(self) -> None:
        sched = InProcessScheduler()

        def my_task() -> None: ...

        task = sched.schedule("@hourly", fn=my_task)
        assert "my_task" in task.name

    def test_reschedule_replaces(self) -> None:
        sched = InProcessScheduler()
        sched.schedule("@hourly", fn=lambda: None, name="x")
        sched.schedule("@daily", fn=lambda: None, name="x")
        assert len(sched.tasks()) == 1
        assert sched.tasks()[0].expr.raw == "@daily"

    def test_tick_fires_matching(self) -> None:
        sched = InProcessScheduler()
        fired = []
        sched.schedule("* * * * *", fn=lambda: fired.append("a"), name="every-minute")

        now = datetime(2026, 5, 18, 14, 30, tzinfo=UTC)
        result = sched.tick(now=now)

        assert fired == ["a"]
        assert len(result) == 1
        assert result[0].name == "every-minute"

    def test_tick_skips_non_matching(self) -> None:
        sched = InProcessScheduler()
        fired = []
        sched.schedule("@daily", fn=lambda: fired.append("a"), name="x")

        # Non-midnight: not fired.
        result = sched.tick(now=datetime(2026, 5, 18, 14, 30, tzinfo=UTC))
        assert fired == []
        assert result == []

        # Midnight: fired.
        result = sched.tick(now=datetime(2026, 5, 18, 0, 0, tzinfo=UTC))
        assert fired == ["a"]

    def test_tick_does_not_double_fire_within_minute(self) -> None:
        sched = InProcessScheduler()
        fired = []
        sched.schedule("* * * * *", fn=lambda: fired.append(1), name="x")

        now = datetime(2026, 5, 18, 14, 30, 0, tzinfo=UTC)
        sched.tick(now=now)
        sched.tick(now=now.replace(second=15))
        sched.tick(now=now.replace(second=45))
        assert fired == [1]  # fired exactly once

        # Next minute fires again.
        sched.tick(now=now + timedelta(minutes=1))
        assert fired == [1, 1]

    def test_tick_naive_datetime_rejected(self) -> None:
        sched = InProcessScheduler()
        with pytest.raises(ValueError, match="timezone-aware"):
            sched.tick(now=datetime(2026, 5, 18, 14, 30))  # noqa: DTZ001

    def test_task_exception_logged_not_propagated(
        self,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        sched = InProcessScheduler()

        def boom() -> None:
            raise RuntimeError("kaboom")

        sched.schedule("* * * * *", fn=boom, name="bad")

        with caplog.at_level(logging.ERROR, logger="mgf.common.schedule"):
            result = sched.tick(now=datetime(2026, 5, 18, 14, 30, tzinfo=UTC))

        # Task was attempted (and crashed), so it's in the fired list.
        assert len(result) == 1
        assert result[0].name == "bad"
        # Error was logged with the standard event marker.
        err_records = [
            r for r in caplog.records if getattr(r, "event", None) == "schedule.task_raised"
        ]
        assert len(err_records) == 1

    def test_multiple_tasks_all_fire(self) -> None:
        sched = InProcessScheduler()
        fired = []
        sched.schedule("* * * * *", fn=lambda: fired.append("a"), name="a")
        sched.schedule("* * * * *", fn=lambda: fired.append("b"), name="b")

        sched.tick(now=datetime(2026, 5, 18, 14, 30, tzinfo=UTC))
        assert sorted(fired) == ["a", "b"]
