"""
Scheduler for running tasks at specific times or intervals with flexible triggers.

The Scheduler provides intuitive methods for scheduling one-time and recurring tasks using
cron expressions, intervals, or simple time delays. Jobs are automatically cleaned up when
the app shuts down, and support both async and sync callables.

Examples:
    One-time delayed execution::

        # Run in 30 seconds
        self.scheduler.run_in(self.cleanup_task, 30)

        # Run once at 7:00 AM today (if already past, fires immediately)
        self.scheduler.run_once(self.morning_routine, start=(7, 0))

    Recurring execution with intervals::

        # Every 5 minutes
        self.scheduler.run_every(self.check_sensors, interval=300)

        # Every hour starting in 10 minutes
        self.scheduler.run_every(self.hourly_report, interval=3600, start=600)

    Convenience helpers::

        # Every hour
        self.scheduler.run_hourly(self.log_status)

        # Every day, first run anchored to 6:30 AM
        self.scheduler.run_daily(self.morning_routine, start=(6, 30))

        # Every 5 minutes
        self.scheduler.run_minutely(self.quick_check, minutes=5)

    Cron-style scheduling::

        # Weekdays at 9 AM
        self.scheduler.run_cron(
            self.workday_routine,
            hour=9,
            minute=0,
            day_of_week="1-5",
        )

        # Every 15 minutes during business hours on weekdays
        self.scheduler.run_cron(
            self.business_check,
            minute="*/15",
            hour="9-17",
            day_of_week="1-5",
        )

    Job management::

        # Named job for easier management
        job = self.scheduler.run_daily(
            self.backup_data,
            name="daily_backup",
            start=(2, 0),
        )

        # Cancel a specific job
        job.cancel()
"""

import asyncio
import typing
from collections.abc import Mapping
from datetime import time
from typing import Any, Literal

from whenever import Time, TimeDelta, ZonedDateTime

from hassette.core.scheduler_service import SchedulerService
from hassette.resources.base import Resource
from hassette.types.types import LOG_LEVEL_TYPE
from hassette.utils.date_utils import now
from hassette.utils.source_capture import capture_registration_source

from .classes import CronTrigger, IntervalTrigger, ScheduledJob

if typing.TYPE_CHECKING:
    from hassette import Hassette
    from hassette.types import JobCallable, ScheduleStartType, TriggerProtocol


class Scheduler(Resource):
    """Scheduler resource for managing scheduled jobs."""

    scheduler_service: SchedulerService
    """The scheduler service instance."""

    _jobs_by_name: dict[str, "ScheduledJob"]
    """Tracks jobs by name for uniqueness validation within this scheduler instance."""

    def __init__(self, hassette: "Hassette", *, parent: Resource | None = None) -> None:
        super().__init__(hassette, parent=parent)
        self.scheduler_service = self.hassette._scheduler_service
        assert self.scheduler_service is not None, "Scheduler service not initialized"
        self._jobs_by_name = {}

    async def on_initialize(self) -> None:
        self.mark_ready(reason="Scheduler initialized")

    async def on_shutdown(self) -> None:
        await self.remove_all_jobs()

    @property
    def config_log_level(self) -> LOG_LEVEL_TYPE:
        """Return the log level from the config for this resource."""
        return self.hassette.config.scheduler_service_log_level

    def add_job(self, job: "ScheduledJob", *, if_exists: Literal["error", "skip"] = "error") -> "ScheduledJob":
        """Add a job to the scheduler.

        Args:
            job: The job to add.
            if_exists: Behavior when a job with the same name already exists.
                ``"error"`` (default) raises ``ValueError``.
                ``"skip"`` returns the existing job if it matches; raises
                ``ValueError`` if the name matches but the configuration differs.

        Returns:
            The added job, or the existing job when ``if_exists="skip"`` and a
            matching job is already registered.

        Raises:
            TypeError: If job is not a ScheduledJob.
            ValueError: If a job with the same name already exists and either
                ``if_exists="error"`` or the existing job's configuration differs.
        """

        if not isinstance(job, ScheduledJob):
            raise TypeError(f"Expected ScheduledJob, got {type(job).__name__}")

        existing = self._jobs_by_name.get(job.name)
        if existing is not None:
            if if_exists == "skip" and existing.matches(job):
                return existing
            raise ValueError(
                f"A job named '{job.name}' already exists in scheduler for '{self.owner_id}'. "
                "Job names must be unique per scheduler instance."
            )

        self._jobs_by_name[job.name] = job

        self.scheduler_service.add_job(job)

        return job

    def remove_job(self, job: "ScheduledJob") -> asyncio.Task:
        """Remove a job from the scheduler.

        Args:
            job: The job to remove.
        """
        self._jobs_by_name.pop(job.name, None)

        return self.scheduler_service.remove_job(job)

    def remove_all_jobs(self) -> asyncio.Task:
        """Remove all jobs for the owner of this scheduler."""
        self._jobs_by_name.clear()
        return self.scheduler_service.remove_jobs_by_owner(self.owner_id)

    def schedule(
        self,
        func: "JobCallable",
        run_at: ZonedDateTime,
        trigger: "TriggerProtocol | None" = None,
        repeat: bool = False,
        name: str = "",
        *,
        if_exists: Literal["error", "skip"] = "error",
        args: tuple[Any, ...] | None = None,
        kwargs: Mapping[str, Any] | None = None,
    ) -> "ScheduledJob":
        """Schedule a job to run at a specific time or based on a trigger.

        Args:
            func: The function to run.
            run_at: The time to run the job.
            trigger: Optional trigger for repeating jobs.
            repeat: Whether the job should repeat.
            name: Optional name for the job.
            if_exists: Behavior when a job with the same name already exists.
                See :meth:`add_job` for details.
            args: Positional arguments to pass to the callable when it executes.
            kwargs: Keyword arguments to pass to the callable when it executes.

        Returns:
            The scheduled job.
        """

        app_key = getattr(self.parent, "app_key", "") if self.parent else ""
        instance_index = getattr(self.parent, "index", 0) if self.parent else 0

        # Capture source while user code is still on the stack (before async spawn boundary)
        source_location, registration_source = capture_registration_source()

        job = ScheduledJob(
            owner_id=self.owner_id,
            next_run=run_at,
            job=func,
            trigger=trigger,
            repeat=repeat,
            name=name,
            args=tuple(args) if args else (),
            kwargs=dict(kwargs) if kwargs else {},
            app_key=app_key,
            instance_index=instance_index,
            source_location=source_location,
            registration_source=registration_source or "",
        )
        return self.add_job(job, if_exists=if_exists)

    def run_once(
        self,
        func: "JobCallable",
        start: "ScheduleStartType",
        name: str = "",
        *,
        if_exists: Literal["error", "skip"] = "error",
        args: tuple[Any, ...] | None = None,
        kwargs: Mapping[str, Any] | None = None,
    ) -> "ScheduledJob":
        """Schedule a job to run once at a specific time.

        Args:
            func: The function to run.
            start: The time to run the job.
            name: Optional name for the job.
            if_exists: Behavior when a job with the same name already exists.
                See :meth:`add_job` for details.
            args: Positional arguments to pass to the callable when it executes.
            kwargs: Keyword arguments to pass to the callable when it executes.

        Returns:
            The scheduled job.
        """

        start_dtme = get_start_dtme(start)
        if start_dtme is None:
            raise ValueError("start must be a valid start time")

        return self.schedule(func, start_dtme, name=name, if_exists=if_exists, args=args, kwargs=kwargs)

    def run_every(
        self,
        func: "JobCallable",
        interval: TimeDelta | float,
        name: str = "",
        start: "ScheduleStartType" = None,
        *,
        if_exists: Literal["error", "skip"] = "error",
        args: tuple[Any, ...] | None = None,
        kwargs: Mapping[str, Any] | None = None,
    ) -> "ScheduledJob":
        """Schedule a job to run at a fixed interval.

        Args:
            func: The function to run.
            interval: The interval between runs. If a float is provided, it is treated as seconds.
            name: Optional name for the job.
            start: Optional start time for the first run. If provided the job will run at this time. Otherwise it will
                run at the current time plus the interval.
            if_exists: Behavior when a job with the same name already exists.
                See :meth:`add_job` for details.
            args: Positional arguments to pass to the callable when it executes.
            kwargs: Keyword arguments to pass to the callable when it executes.

        Returns:
            The scheduled job.
        """

        interval_seconds = interval if isinstance(interval, float | int) else interval.in_seconds()

        start_dtme = get_start_dtme(start)

        first_run = start_dtme if start_dtme else now().add(seconds=interval_seconds)
        trigger = IntervalTrigger.from_arguments(seconds=interval_seconds, start=first_run)

        return self.schedule(
            func, first_run, trigger=trigger, repeat=True, name=name, if_exists=if_exists, args=args, kwargs=kwargs
        )

    def run_in(
        self,
        func: "JobCallable",
        delay: TimeDelta | float,
        name: str = "",
        start: "ScheduleStartType" = None,
        *,
        if_exists: Literal["error", "skip"] = "error",
        args: tuple[Any, ...] | None = None,
        kwargs: Mapping[str, Any] | None = None,
    ) -> "ScheduledJob":
        """Schedule a job to run after a delay.

        Args:
            func: The function to run.
            delay: The delay before running the job.
            name: Optional name for the job.
            start: Optional start time for the job. If provided the job will run at this time, otherwise it will run at
                the current time plus the delay.
            if_exists: Behavior when a job with the same name already exists.
                See :meth:`add_job` for details.
            args: Positional arguments to pass to the callable when it executes.
            kwargs: Keyword arguments to pass to the callable when it executes.

        Returns:
            The scheduled job.
        """

        delay_seconds = delay if isinstance(delay, float | int) else delay.in_seconds()

        start_dtme = get_start_dtme(start)

        run_at = start_dtme if start_dtme else now().add(seconds=delay_seconds)
        return self.schedule(func, run_at, name=name, if_exists=if_exists, args=args, kwargs=kwargs)

    def run_minutely(
        self,
        func: "JobCallable",
        minutes: int = 1,
        name: str = "",
        start: "ScheduleStartType" = None,
        *,
        if_exists: Literal["error", "skip"] = "error",
        args: tuple[Any, ...] | None = None,
        kwargs: Mapping[str, Any] | None = None,
    ) -> "ScheduledJob":
        """Schedule a job to run every N minutes.

        Args:
            func: The function to run.
            minutes: The minute interval to run the job.
            name: Optional name for the job.
            start: Optional start time for the first run. If provided the job will run at this time. Otherwise, the job
                will run at now + N minutes.
            if_exists: Behavior when a job with the same name already exists.
                See :meth:`add_job` for details.
            args: Positional arguments to pass to the callable when it executes.
            kwargs: Keyword arguments to pass to the callable when it executes.

        Returns:
            The scheduled job.
        """
        if minutes < 1:
            raise ValueError("Minute interval must be at least 1")

        start_dtme = get_start_dtme(start)

        trigger = IntervalTrigger.from_arguments(minutes=minutes, start=start_dtme)
        first_run = start_dtme if start_dtme else now().add(minutes=minutes)
        return self.schedule(
            func, first_run, trigger=trigger, repeat=True, name=name, if_exists=if_exists, args=args, kwargs=kwargs
        )

    def run_hourly(
        self,
        func: "JobCallable",
        hours: int = 1,
        name: str = "",
        start: "ScheduleStartType" = None,
        *,
        if_exists: Literal["error", "skip"] = "error",
        args: tuple[Any, ...] | None = None,
        kwargs: Mapping[str, Any] | None = None,
    ) -> "ScheduledJob":
        """Schedule a job to run every N hours.

        Args:
            func: The function to run.
            hours: The hour interval to run the job.
            name: Optional name for the job.
            start: Optional start time for the first run. If provided the job will run at this time, otherwise the job
                will run at now + N hours.
            if_exists: Behavior when a job with the same name already exists.
                See :meth:`add_job` for details.
            args: Positional arguments to pass to the callable when it executes.
            kwargs: Keyword arguments to pass to the callable when it executes.

        Returns:
            The scheduled job.
        """
        if hours < 1:
            raise ValueError("Hour interval must be at least 1")

        start_dtme = get_start_dtme(start)

        trigger = IntervalTrigger.from_arguments(hours=hours, start=start_dtme)
        first_run = start_dtme if start_dtme else now().add(hours=hours)
        return self.schedule(
            func, first_run, trigger=trigger, repeat=True, name=name, if_exists=if_exists, args=args, kwargs=kwargs
        )

    def run_daily(
        self,
        func: "JobCallable",
        days: int = 1,
        name: str = "",
        start: "ScheduleStartType" = None,
        *,
        if_exists: Literal["error", "skip"] = "error",
        args: tuple[Any, ...] | None = None,
        kwargs: Mapping[str, Any] | None = None,
    ) -> "ScheduledJob":
        """Schedule a job to run every N days.

        Args:
            func: The function to run.
            days: The day interval to run the job.
            name: Optional name for the job.
            start: Optional start time for the first run. If provided the job will run at this time, otherwise the job
                will run at now + N days.
            if_exists: Behavior when a job with the same name already exists.
                See :meth:`add_job` for details.
            args: Positional arguments to pass to the callable when it executes.
            kwargs: Keyword arguments to pass to the callable when it executes.

        Returns:
            The scheduled job.
        """
        if days < 1:
            raise ValueError("Day interval must be at least 1")
        if days > 365:
            raise ValueError("Day interval must not exceed 365")

        hours = 24 * days

        start_dtme = get_start_dtme(start)

        trigger = IntervalTrigger.from_arguments(hours=hours, start=start_dtme)
        first_run = start_dtme if start_dtme else now().add(hours=hours)
        return self.schedule(
            func, first_run, trigger=trigger, repeat=True, name=name, if_exists=if_exists, args=args, kwargs=kwargs
        )

    def run_cron(
        self,
        func: "JobCallable",
        second: int | str = 0,
        minute: int | str = 0,
        hour: int | str = 0,
        day_of_month: int | str = "*",
        month: int | str = "*",
        day_of_week: int | str = "*",
        name: str = "",
        start: "ScheduleStartType" = None,
        *,
        if_exists: Literal["error", "skip"] = "error",
        args: tuple[Any, ...] | None = None,
        kwargs: Mapping[str, Any] | None = None,
    ) -> "ScheduledJob":
        """Schedule a job using a cron expression.

        Uses a 6-field format (seconds, minutes, hours, day of month, month, day of week).

        Args:
            func: The function to run.
            second: Seconds field of the cron expression.
            minute: Minutes field of the cron expression.
            hour: Hours field of the cron expression.
            day_of_month: Day of month field of the cron expression.
            month: Month field of the cron expression.
            day_of_week: Day of week field of the cron expression.
            name: Optional name for the job.
            start: Optional start time for the first run. If provided the job will run at this time, otherwise the job
                will run at the next scheduled time based on the cron expression.
            if_exists: Behavior when a job with the same name already exists.
                See :meth:`add_job` for details.
            args: Positional arguments to pass to the callable when it executes.
            kwargs: Keyword arguments to pass to the callable when it executes.

        Returns:
            The scheduled job.
        """
        start_dtme = get_start_dtme(start)

        trigger = CronTrigger.from_arguments(
            second=second,
            minute=minute,
            hour=hour,
            day_of_month=day_of_month,
            month=month,
            day_of_week=day_of_week,
            start=start_dtme,
        )
        run_at = trigger.first_run_time(now())
        return self.schedule(
            func, run_at, trigger=trigger, repeat=True, name=name, if_exists=if_exists, args=args, kwargs=kwargs
        )


def get_start_dtme(start: "ScheduleStartType") -> ZonedDateTime | None:
    """Convert a start time to a ZonedDateTime.

    Args:
        start: The start time to convert.

    Returns:
        The converted start time, or None if no start time was provided.

    Raises:
        TypeError: If the start time is not a valid type.
    """
    start_dtme: ZonedDateTime | None = None

    if start is None:
        return start

    if isinstance(start, ZonedDateTime):
        # provided as a full datetime, just use it
        return start

    if isinstance(start, TimeDelta):
        # we can add these directly to get a new ZonedDateTime
        return now() + start

    # if we have time/Time then no change
    # if we have (hour, minute) tuple then convert to time
    if isinstance(start, Time | time):
        start_time = start
    elif isinstance(start, tuple) and len(start) == 2:
        if not all(isinstance(x, int) for x in start):
            raise TypeError(f"Start time tuple must contain two integers (hour, minute), got {start}")
        start_time = time(*start)
    elif isinstance(start, int | float):
        # treat as seconds from now
        return now().add(seconds=start)
    else:
        raise TypeError(f"Start time must be a Time, time, or (hour, minute) tuple, got {type(start).__name__}")

    # convert to ZonedDateTime for today at the specified time
    # if this ends up in the past, the trigger will handle advancing to the next valid time
    start_dtme = ZonedDateTime.from_system_tz(
        year=now().year, month=now().month, day=now().day, hour=start_time.hour, minute=start_time.minute
    )
    return start_dtme
