"""Cron expression parser and scheduler for recurring jobs.

This module provides:
- Standard 5-field cron expression parsing (minute hour day month dow)
- Extended syntax support (@daily, @hourly, @weekly, @monthly, @yearly)
- Timezone-aware scheduling
- Next run time calculation
"""

from __future__ import annotations

from datetime import datetime, timedelta
from datetime import timezone as dt_timezone
from typing import Union

# Python 3.9+ has zoneinfo in stdlib
try:
    from zoneinfo import ZoneInfo
except ImportError:
    ZoneInfo = None  # type: ignore

# pytz is optional
try:
    import pytz
except ImportError:
    pytz = None  # type: ignore


class CronError(Exception):
    """Raised when there's an error parsing or validating a cron expression."""

    pass


# Extended syntax shorthands
CRON_SHORTHANDS = {
    "@yearly": "0 0 1 1 *",
    "@annually": "0 0 1 1 *",
    "@monthly": "0 0 1 * *",
    "@weekly": "0 0 * * 0",
    "@daily": "0 0 * * *",
    "@hourly": "0 * * * *",
}

# Type for timezone arguments
TimezoneType = Union["pytz.BaseTzInfo", "ZoneInfo", None]


class CronExpression:
    """Parses and evaluates cron expressions.

    Supports standard 5-field cron syntax:
        minute hour day-of-month month day-of-week

    And extended syntax:
        @yearly, @annually, @monthly, @weekly, @daily, @hourly

    Field values:
        minute: 0-59
        hour: 0-23
        day-of-month: 1-31
        month: 1-12
        day-of-week: 0-6 (0 = Sunday, 6 = Saturday)

    Special characters:
        *: Match all values
        -: Range (e.g., 1-5)
        ,: List (e.g., 1,3,5)
        /: Step (e.g., */15, 0-30/5)
    """

    def __init__(self, expression: str) -> None:
        """Initialize cron expression.

        Args:
            expression: Cron expression string

        Raises:
            CronError: If expression is invalid
        """
        self._raw = expression.strip()
        self._parse()

    def _parse(self) -> None:
        """Parse the cron expression into component fields."""
        # Check for shorthand syntax
        if self._raw.startswith("@"):
            if self._raw not in CRON_SHORTHANDS:
                raise CronError(f"Unknown shorthand: {self._raw}")
            fields = CRON_SHORTHANDS[self._raw].split()
        else:
            fields = self._raw.split()

        if len(fields) != 5:
            raise CronError(f"Cron expression must have 5 fields, got {len(fields)}: {self._raw}")

        self.minutes = self._parse_field(fields[0], 0, 59, "minute")
        self.hours = self._parse_field(fields[1], 0, 23, "hour")
        self.days_of_month = self._parse_field(fields[2], 1, 31, "day of month")
        self.months = self._parse_field(fields[3], 1, 12, "month")
        self.days_of_week = self._parse_field(fields[4], 0, 7, "day of week")

        # Normalize Sunday (7 -> 0)
        self.days_of_week = [0 if d == 7 else d for d in self.days_of_week]
        # Remove duplicates and sort
        self.days_of_week = sorted(set(self.days_of_week))

    def _parse_field(self, field: str, min_val: int, max_val: int, field_name: str) -> list[int]:
        """Parse a single cron field.

        Args:
            field: Field string (e.g., "*", "1-5", "*/15")
            min_val: Minimum allowed value
            max_val: Maximum allowed value
            field_name: Name of field for error messages

        Returns:
            List of valid values for this field

        Raises:
            CronError: If field is invalid
        """
        values: set[int] = set()

        # Handle comma-separated lists
        parts = field.split(",")

        for part in parts:
            # Handle step values
            if "/" in part:
                base, step_str = part.split("/")
                try:
                    step = int(step_str)
                except ValueError as e:
                    raise CronError(f"Invalid step value in {field_name}: {step_str}") from e

                if base == "*":
                    start, end = min_val, max_val
                elif "-" in base:
                    start, end = map(int, base.split("-"))
                else:
                    raise CronError(f"Invalid base for step in {field_name}: {base}")

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

            # Handle ranges
            elif "-" in part:
                start, end = map(int, part.split("-"))
                for v in range(start, end + 1):
                    values.add(v)

            # Handle wildcards
            elif part == "*":
                for v in range(min_val, max_val + 1):
                    values.add(v)

            # Handle single values
            else:
                try:
                    values.add(int(part))
                except ValueError as e:
                    raise CronError(f"Invalid value in {field_name}: {part}") from e

        # Validate all values are in range
        result = sorted(values)
        for v in result:
            # Allow 7 for day of week (will be normalized to 0)
            actual_max = max_val if field_name != "day of week" else max(max_val, 7)
            if v < min_val or v > actual_max:
                raise CronError(f"{field_name} value {v} out of range [{min_val}-{max_val}]")

        return result

    def get_next_run(
        self,
        timezone: TimezoneType = None,
    ) -> datetime:
        """Calculate the next run time after now.

        Args:
            timezone: Optional timezone for scheduling (default: UTC)

        Returns:
            Next scheduled run time
        """
        # Start from current time
        now = datetime.utcnow()

        # If timezone specified, we need to work in that timezone's frame
        if timezone is not None:
            # Convert now to target timezone
            now = self._to_timezone(now, timezone)

        # Start checking from next minute (cron typically doesn't run same minute)
        candidate = now.replace(second=0, microsecond=0) + timedelta(minutes=1)

        # Search for next valid time (limit to prevent infinite loops)
        for _ in range(366 * 24 * 60):  # Max 1 year of minutes
            if self._matches(candidate):
                # Convert back to UTC if we were working in a different timezone
                if timezone is not None:
                    return candidate.astimezone(dt_timezone.utc).replace(tzinfo=None)
                return candidate
            candidate += timedelta(minutes=1)

        raise CronError("Could not find next run time within 1 year")

    def _to_timezone(
        self,
        dt: datetime,
        timezone: pytz.BaseTzInfo | ZoneInfo,
    ) -> datetime:
        """Convert a datetime to the specified timezone.

        Args:
            dt: Datetime to convert (assumed UTC if naive)
            timezone: Target timezone

        Returns:
            Datetime in target timezone
        """
        # Handle pytz timezones
        if pytz is not None and hasattr(timezone, "localize"):
            # pytz timezone
            utc = pytz.UTC
            if dt.tzinfo is None:
                dt = utc.localize(dt)
            return dt.astimezone(timezone)
        else:
            # zoneinfo timezone (Python 3.9+) or other tzinfo
            if dt.tzinfo is None:
                dt = dt.replace(tzinfo=dt_timezone.utc)
            return dt.astimezone(timezone)

    def _matches(self, dt: datetime) -> bool:
        """Check if a datetime matches this cron expression.

        Args:
            dt: Datetime to check

        Returns:
            True if datetime matches all fields
        """
        # Check month
        if dt.month not in self.months:
            return False

        # Check day of month
        if dt.day not in self.days_of_month:
            return False

        # Convert Python's weekday (Mon=0) to cron format (Sun=0 or Mon=1)
        # Python: Mon=0, Tue=1, Wed=2, Thu=3, Fri=4, Sat=5, Sun=6
        # Cron:   Sun=0, Mon=1, Tue=2, Wed=3, Thu=4, Fri=5, Sat=6
        python_dow = dt.weekday()
        cron_dow = (python_dow + 1) % 7  # Convert to cron format

        if cron_dow not in self.days_of_week:
            return False

        # Check hour
        if dt.hour not in self.hours:
            return False

        # Check minute
        return dt.minute in self.minutes

    def __str__(self) -> str:
        """Return string representation."""
        return self._raw

    def __repr__(self) -> str:
        """Return detailed representation."""
        return f"CronExpression('{self._raw}')"


def validate_cron(expression: str) -> bool:
    """Validate a cron expression.

    Args:
        expression: Cron expression string

    Returns:
        True if valid, False otherwise
    """
    try:
        CronExpression(expression)
        return True
    except CronError:
        return False


def get_next_run(
    expression: str,
    timezone: TimezoneType = None,
) -> datetime:
    """Get the next run time for a cron expression.

    Args:
        expression: Cron expression string
        timezone: Optional timezone for scheduling

    Returns:
        Next scheduled run time

    Raises:
        CronError: If expression is invalid
    """
    cron = CronExpression(expression)
    return cron.get_next_run(timezone)
