"""Tests for the cron scheduler module."""

from __future__ import annotations

from datetime import datetime
from unittest.mock import patch

import pytest

try:
    from zoneinfo import ZoneInfo

    HAS_ZONEINFO = True
    # Check if tzdata is available by trying to access a timezone that requires tzdata
    try:
        ZoneInfo("US/Eastern")
        HAS_TZDATA = True
    except Exception:
        HAS_TZDATA = False
except ImportError:
    HAS_ZONEINFO = False
    HAS_TZDATA = False

try:
    import pytz

    HAS_PYTZ = True
except ImportError:
    HAS_PYTZ = False

from oclawma.scheduler.cron import (
    CronError,
    CronExpression,
    get_next_run,
    validate_cron,
)


class TestCronExpressionParsing:
    """Test parsing of cron expressions."""

    def test_standard_5_field_basic(self):
        """Test basic 5-field cron expression parsing."""
        cron = CronExpression("0 0 * * *")
        assert cron.minutes == [0]
        assert cron.hours == [0]
        assert cron.days_of_month == list(range(1, 32))
        assert cron.months == list(range(1, 13))
        assert cron.days_of_week == list(range(7))

    def test_standard_5_field_with_ranges(self):
        """Test 5-field cron with ranges."""
        cron = CronExpression("0-30 9-17 * * 1-5")
        assert cron.minutes == list(range(0, 31))
        assert cron.hours == list(range(9, 18))
        assert cron.days_of_week == list(range(1, 6))

    def test_standard_5_field_with_steps(self):
        """Test 5-field cron with step values."""
        cron = CronExpression("*/15 */6 * * *")
        assert cron.minutes == [0, 15, 30, 45]
        assert cron.hours == [0, 6, 12, 18]

    def test_standard_5_field_with_lists(self):
        """Test 5-field cron with list values."""
        cron = CronExpression("0,30 9,12,18 * * 1,3,5")
        assert cron.minutes == [0, 30]
        assert cron.hours == [9, 12, 18]
        assert cron.days_of_week == [1, 3, 5]

    def test_invalid_expression_not_enough_fields(self):
        """Test validation fails with too few fields."""
        with pytest.raises(CronError, match="5 fields"):
            CronExpression("0 0 * *")

    def test_invalid_expression_too_many_fields(self):
        """Test validation fails with too many fields."""
        with pytest.raises(CronError, match="5 fields"):
            CronExpression("0 0 * * * *")

    def test_invalid_minute_value(self):
        """Test validation fails with invalid minute."""
        with pytest.raises(CronError, match="minute"):
            CronExpression("60 0 * * *")

    def test_invalid_hour_value(self):
        """Test validation fails with invalid hour."""
        with pytest.raises(CronError, match="hour"):
            CronExpression("0 24 * * *")

    def test_invalid_day_of_month(self):
        """Test validation fails with invalid day of month."""
        with pytest.raises(CronError, match="day of month"):
            CronExpression("0 0 32 * *")

    def test_invalid_month(self):
        """Test validation fails with invalid month."""
        with pytest.raises(CronError, match="month"):
            CronExpression("0 0 * 13 *")

    def test_invalid_day_of_week(self):
        """Test validation fails with invalid day of week."""
        with pytest.raises(CronError, match="day of week"):
            CronExpression("0 0 * * 8")


class TestCronExtendedSyntax:
    """Test extended cron syntax (@daily, @hourly, etc.)."""

    def test_yearly(self):
        """Test @yearly shorthand."""
        cron = CronExpression("@yearly")
        assert cron.minutes == [0]
        assert cron.hours == [0]
        assert cron.days_of_month == [1]
        assert cron.months == [1]
        assert cron.days_of_week == list(range(7))

    def test_annually_same_as_yearly(self):
        """Test @annually is same as @yearly."""
        yearly = CronExpression("@yearly")
        annually = CronExpression("@annually")
        assert yearly.minutes == annually.minutes
        assert yearly.hours == annually.hours

    def test_monthly(self):
        """Test @monthly shorthand."""
        cron = CronExpression("@monthly")
        assert cron.minutes == [0]
        assert cron.hours == [0]
        assert cron.days_of_month == [1]
        assert cron.months == list(range(1, 13))

    def test_weekly(self):
        """Test @weekly shorthand."""
        cron = CronExpression("@weekly")
        assert cron.minutes == [0]
        assert cron.hours == [0]
        assert cron.days_of_week == [0]  # Sunday

    def test_daily(self):
        """Test @daily shorthand."""
        cron = CronExpression("@daily")
        assert cron.minutes == [0]
        assert cron.hours == [0]
        assert cron.days_of_month == list(range(1, 32))

    def test_hourly(self):
        """Test @hourly shorthand."""
        cron = CronExpression("@hourly")
        assert cron.minutes == [0]
        assert cron.hours == list(range(0, 24))

    def test_invalid_shorthand(self):
        """Test invalid shorthand raises error."""
        with pytest.raises(CronError, match="Unknown shorthand"):
            CronExpression("@invalid")


class TestCronNextRunCalculation:
    """Test calculation of next run time."""

    @patch("oclawma.scheduler.cron.datetime")
    def test_next_run_every_minute(self, mock_datetime):
        """Test next run for every minute."""
        # Set current time to 2024-01-15 10:30:00
        mock_datetime.utcnow.return_value = datetime(2024, 1, 15, 10, 30, 0)
        mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)

        cron = CronExpression("* * * * *")
        next_run = cron.get_next_run()
        assert next_run == datetime(2024, 1, 15, 10, 31, 0)

    @patch("oclawma.scheduler.cron.datetime")
    def test_next_run_hourly(self, mock_datetime):
        """Test next run for hourly job."""
        mock_datetime.utcnow.return_value = datetime(2024, 1, 15, 10, 30, 0)
        mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)

        cron = CronExpression("0 * * * *")
        next_run = cron.get_next_run()
        assert next_run == datetime(2024, 1, 15, 11, 0, 0)

    @patch("oclawma.scheduler.cron.datetime")
    def test_next_run_daily(self, mock_datetime):
        """Test next run for daily job."""
        mock_datetime.utcnow.return_value = datetime(2024, 1, 15, 10, 30, 0)
        mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)

        cron = CronExpression("0 0 * * *")
        next_run = cron.get_next_run()
        assert next_run == datetime(2024, 1, 16, 0, 0, 0)

    @patch("oclawma.scheduler.cron.datetime")
    def test_next_run_specific_time(self, mock_datetime):
        """Test next run for specific time today."""
        mock_datetime.utcnow.return_value = datetime(2024, 1, 15, 10, 30, 0)
        mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)

        cron = CronExpression("30 14 * * *")  # 2:30 PM
        next_run = cron.get_next_run()
        assert next_run == datetime(2024, 1, 15, 14, 30, 0)

    @patch("oclawma.scheduler.cron.datetime")
    def test_next_run_specific_time_tomorrow(self, mock_datetime):
        """Test next run for specific time that passed today."""
        mock_datetime.utcnow.return_value = datetime(2024, 1, 15, 15, 0, 0)
        mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)

        cron = CronExpression("30 14 * * *")  # 2:30 PM
        next_run = cron.get_next_run()
        assert next_run == datetime(2024, 1, 16, 14, 30, 0)

    @patch("oclawma.scheduler.cron.datetime")
    def test_next_run_weekday(self, mock_datetime):
        """Test next run for weekday only job."""
        # Monday Jan 15, 2024
        mock_datetime.utcnow.return_value = datetime(2024, 1, 15, 10, 0, 0)
        mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)

        cron = CronExpression("0 9 * * 1-5")  # 9 AM on weekdays
        next_run = cron.get_next_run()
        assert next_run == datetime(2024, 1, 16, 9, 0, 0)  # Tuesday

    @patch("oclawma.scheduler.cron.datetime")
    def test_next_run_monthly(self, mock_datetime):
        """Test next run for monthly job."""
        mock_datetime.utcnow.return_value = datetime(2024, 1, 15, 10, 0, 0)
        mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)

        cron = CronExpression("0 0 1 * *")  # 1st of month
        next_run = cron.get_next_run()
        assert next_run == datetime(2024, 2, 1, 0, 0, 0)


class TestCronTimezoneHandling:
    """Test timezone-aware scheduling."""

    def _check_tzdata(self):
        """Helper to check if tzdata is available."""
        if not HAS_TZDATA:
            pytest.skip("tzdata not available")

    @patch("oclawma.scheduler.cron.datetime")
    def test_next_run_with_zoneinfo(self, mock_datetime):
        """Test next run calculation with zoneinfo timezone."""
        self._check_tzdata()
        # UTC time: 2024-01-15 10:00:00
        mock_datetime.utcnow.return_value = datetime(2024, 1, 15, 10, 0, 0)
        mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)

        cron = CronExpression("0 9 * * *")  # 9 AM
        tz = ZoneInfo("US/Eastern")
        next_run = cron.get_next_run(tz)

        # 9 AM Eastern = 14:00 UTC (winter time, EST = UTC-5)
        assert next_run.hour == 14

    @patch("oclawma.scheduler.cron.datetime")
    def test_next_run_with_pytz(self, mock_datetime):
        """Test next run calculation with pytz timezone."""
        if not HAS_PYTZ:
            pytest.skip("pytz not available")
        mock_datetime.utcnow.return_value = datetime(2024, 1, 15, 10, 0, 0)
        mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)

        cron = CronExpression("0 9 * * *")  # 9 AM
        tz = pytz.timezone("US/Eastern")
        next_run = cron.get_next_run(tz)

        # 9 AM Eastern = 14:00 UTC
        assert next_run.hour == 14

    def test_zoneinfo_conversion(self):
        """Test zoneinfo timezone conversion."""
        self._check_tzdata()
        cron = CronExpression("0 9 * * *")

        # Create a naive datetime
        naive = datetime(2024, 1, 15, 9, 0, 0)

        # Convert to Eastern timezone
        eastern = ZoneInfo("US/Eastern")
        eastern_time = cron._to_timezone(naive, eastern)
        assert eastern_time.tzinfo is not None

    def test_pytz_conversion(self):
        """Test pytz timezone conversion."""
        if not HAS_PYTZ:
            pytest.skip("pytz not available")
        cron = CronExpression("0 9 * * *")

        # Create a naive datetime
        naive = datetime(2024, 1, 15, 9, 0, 0)

        # Convert to Eastern timezone
        eastern = pytz.timezone("US/Eastern")
        eastern_time = cron._to_timezone(naive, eastern)
        assert eastern_time.tzinfo is not None


class TestCronValidation:
    """Test the validate_cron function."""

    def test_valid_standard_expression(self):
        """Test validation passes for valid standard expression."""
        assert validate_cron("0 0 * * *") is True
        assert validate_cron("*/5 * * * *") is True
        assert validate_cron("0 9-17 * * 1-5") is True

    def test_valid_shorthand_expression(self):
        """Test validation passes for valid shorthand."""
        assert validate_cron("@daily") is True
        assert validate_cron("@hourly") is True
        assert validate_cron("@weekly") is True
        assert validate_cron("@monthly") is True
        assert validate_cron("@yearly") is True

    def test_invalid_expression_returns_false(self):
        """Test validation fails for invalid expressions."""
        assert validate_cron("invalid") is False
        assert validate_cron("60 0 * * *") is False
        assert validate_cron("@unknown") is False


class TestGetNextRunFunction:
    """Test the get_next_run convenience function."""

    @patch("oclawma.scheduler.cron.datetime")
    def test_get_next_run_basic(self, mock_datetime):
        """Test get_next_run with basic expression."""
        mock_datetime.utcnow.return_value = datetime(2024, 1, 15, 10, 0, 0)
        mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)

        next_run = get_next_run("0 0 * * *")
        assert next_run == datetime(2024, 1, 16, 0, 0, 0)

    @patch("oclawma.scheduler.cron.datetime")
    def test_get_next_run_with_timezone(self, mock_datetime):
        """Test get_next_run with timezone."""
        if not HAS_TZDATA:
            pytest.skip("tzdata not available")
        mock_datetime.utcnow.return_value = datetime(2024, 1, 15, 10, 0, 0)
        mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)

        tz = ZoneInfo("US/Eastern")
        next_run = get_next_run("0 9 * * *", tz)
        assert next_run.hour == 14  # 9 AM EST = 2 PM UTC

    def test_get_next_run_invalid_expression(self):
        """Test get_next_run raises error for invalid expression."""
        with pytest.raises(CronError):
            get_next_run("invalid")


class TestCronEdgeCases:
    """Test edge cases and special scenarios."""

    @patch("oclawma.scheduler.cron.datetime")
    def test_leap_year_february(self, mock_datetime):
        """Test February handling in leap year."""
        mock_datetime.utcnow.return_value = datetime(2024, 2, 29, 10, 0, 0)
        mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)

        cron = CronExpression("0 0 1 * *")
        next_run = cron.get_next_run()
        assert next_run == datetime(2024, 3, 1, 0, 0, 0)

    @patch("oclawma.scheduler.cron.datetime")
    def test_year_rollover(self, mock_datetime):
        """Test year rollover."""
        mock_datetime.utcnow.return_value = datetime(2024, 12, 31, 23, 30, 0)
        mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)

        cron = CronExpression("0 0 * * *")
        next_run = cron.get_next_run()
        assert next_run == datetime(2025, 1, 1, 0, 0, 0)

    @patch("oclawma.scheduler.cron.datetime")
    def test_last_day_of_month(self, mock_datetime):
        """Test handling of last day of month."""
        mock_datetime.utcnow.return_value = datetime(2024, 1, 31, 10, 0, 0)
        mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)

        cron = CronExpression("0 0 31 * *")  # 31st of every month
        next_run = cron.get_next_run()
        # March has 31 days, February doesn't
        assert next_run == datetime(2024, 3, 31, 0, 0, 0)

    @patch("oclawma.scheduler.cron.datetime")
    def test_sunday_as_0(self, mock_datetime):
        """Test that Sunday is 0."""
        mock_datetime.utcnow.return_value = datetime(2024, 1, 15, 10, 0, 0)  # Monday
        mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)

        # Sunday as 0
        cron = CronExpression("0 0 * * 0")
        next_run = cron.get_next_run()
        assert next_run.weekday() == 6  # Sunday (Python weekday: 6 = Sunday)

    @patch("oclawma.scheduler.cron.datetime")
    def test_sunday_as_7_normalized_to_0(self, mock_datetime):
        """Test that Sunday as 7 is normalized to 0."""
        mock_datetime.utcnow.return_value = datetime(2024, 1, 15, 10, 0, 0)  # Monday
        mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)

        cron = CronExpression("0 0 * * 7")
        # 7 should be normalized to 0
        assert 0 in cron.days_of_week

    def test_wildcard_in_all_fields(self):
        """Test wildcard in all fields."""
        cron = CronExpression("* * * * *")
        assert len(cron.minutes) == 60
        assert len(cron.hours) == 24
        assert len(cron.days_of_month) == 31
        assert len(cron.months) == 12
        assert len(cron.days_of_week) == 7

    @patch("oclawma.scheduler.cron.datetime")
    def test_weekday_constraint_prevents_weekend(self, mock_datetime):
        """Test that weekday constraint doesn't run on weekends."""
        # Friday Jan 19, 2024
        mock_datetime.utcnow.return_value = datetime(2024, 1, 19, 15, 0, 0)
        mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)

        cron = CronExpression("0 9 * * 1-5")  # Weekdays only
        next_run = cron.get_next_run()
        # Should be Monday Jan 22, not Saturday
        assert next_run == datetime(2024, 1, 22, 9, 0, 0)

    @patch("oclawma.scheduler.cron.datetime")
    def test_month_end_transition(self, mock_datetime):
        """Test transition at end of month."""
        # April 30, 2024 11:59 PM
        mock_datetime.utcnow.return_value = datetime(2024, 4, 30, 23, 59, 0)
        mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)

        cron = CronExpression("0 0 1 * *")  # 1st of month
        next_run = cron.get_next_run()
        assert next_run == datetime(2024, 5, 1, 0, 0, 0)


class TestQueueSchedulerIntegration:
    """Test integration between queue and scheduler."""

    @pytest.fixture
    def queue(self, tmp_path):
        """Create a temporary queue for testing."""
        from oclawma.queue import JobQueue

        db_path = tmp_path / "test_scheduler.db"
        with JobQueue(db_path) as q:
            yield q

    @patch("oclawma.scheduler.cron.datetime")
    def test_schedule_recurring_job(self, mock_datetime, queue):
        """Test scheduling a recurring job."""
        mock_datetime.utcnow.return_value = datetime(2024, 1, 15, 10, 0, 0)
        mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)

        from oclawma.queue.scheduler import schedule_recurring_job

        job = schedule_recurring_job(
            queue=queue,
            payload={"task": "backup"},
            cron_expression="0 0 * * *",  # Daily at midnight
        )

        assert job.cron_expression == "0 0 * * *"
        assert job.timezone == "UTC"
        assert job.next_run_at == datetime(2024, 1, 16, 0, 0, 0)

    @patch("oclawma.scheduler.cron.datetime")
    def test_reschedule_job(self, mock_datetime, queue):
        """Test rescheduling a completed recurring job."""
        mock_datetime.utcnow.return_value = datetime(2024, 1, 15, 10, 0, 0)
        mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)

        from oclawma.queue.scheduler import reschedule_job, schedule_recurring_job

        # Schedule a recurring job
        job = schedule_recurring_job(
            queue=queue,
            payload={"task": "backup"},
            cron_expression="0 0 * * *",
        )

        # Complete the job
        job = queue.dequeue()
        queue.complete(job.id)

        # Reschedule
        rescheduled = reschedule_job(queue, job.id)

        assert rescheduled is not None
        assert rescheduled.next_run_at == datetime(2024, 1, 16, 0, 0, 0)
        assert rescheduled.status.value == "pending"

    def test_reschedule_non_recurring_job(self, queue):
        """Test that rescheduling a non-recurring job returns None."""
        from oclawma.queue.scheduler import reschedule_job

        # Create a regular (non-recurring) job
        job = queue.enqueue({"task": "one_time"})
        queue.dequeue()
        queue.complete(job.id)

        # Try to reschedule
        result = reschedule_job(queue, job.id)
        assert result is None

    @patch("oclawma.scheduler.cron.datetime")
    def test_get_due_jobs(self, mock_datetime, queue):
        """Test getting due recurring jobs."""
        mock_datetime.utcnow.return_value = datetime(2024, 1, 15, 10, 0, 0)
        mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)

        from oclawma.queue.scheduler import get_due_jobs, schedule_recurring_job

        # Schedule a job that should be due
        job = schedule_recurring_job(
            queue=queue,
            payload={"task": "backup"},
            cron_expression="0 0 * * *",
        )

        # Manually set next_run_at to a time in the past
        job.next_run_at = datetime(2024, 1, 15, 9, 0, 0)
        queue.store.update(job)

        # Get due jobs
        due = get_due_jobs(queue)

        assert len(due) == 1
        assert due[0].id == job.id

    def test_job_model_cron_fields(self, queue):
        """Test that Job model properly handles cron fields."""
        from oclawma.queue import Job

        job = Job(
            payload={"task": "test"},
            cron_expression="0 0 * * *",
            timezone="US/Eastern",
            next_run_at=datetime(2024, 1, 15, 0, 0, 0),
        )

        assert job.cron_expression == "0 0 * * *"
        assert job.timezone == "US/Eastern"
        assert job.next_run_at == datetime(2024, 1, 15, 0, 0, 0)

    def test_job_to_from_db_with_cron(self, queue):
        """Test database serialization with cron fields."""
        from oclawma.queue import Job

        original = Job(
            id=1,
            payload={"task": "backup"},
            cron_expression="0 0 * * *",
            timezone="UTC",
            next_run_at=datetime(2024, 1, 15, 0, 0, 0),
        )

        db_dict = original.to_db_dict()
        restored = Job.from_db_row(db_dict)

        assert restored.cron_expression == original.cron_expression
        assert restored.timezone == original.timezone
        assert restored.next_run_at == original.next_run_at
