# Scheduler plugins for Cherrypy
# Copyright (C) 2026 IKUS Software
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
import logging
from concurrent.futures import Future, ThreadPoolExecutor
from datetime import datetime, timedelta
from threading import Event, RLock, Thread
from typing import Callable, Optional

import cherrypy
from cherrypy.process.plugins import SimplePlugin


class _Task:
    """Internal representation of a scheduled task."""

    def __init__(self, func, args, kwargs, interval_seconds, name, next_run):
        if not callable(func):
            raise TypeError(f"func must be callable, got {type(func).__name__}")

        if not isinstance(args, (list, tuple)):
            raise TypeError(f"args must be a list, got {type(args).__name__}")

        if not isinstance(kwargs, dict):
            raise TypeError(f"kwargs must be a dict, got {type(kwargs).__name__}")

        if not isinstance(interval_seconds, int):
            raise TypeError(f"interval_seconds must be an int, got {type(interval_seconds).__name__}")

        if interval_seconds < 0:
            raise ValueError(f"interval_seconds must be a positive integer, got {interval_seconds}")

        if not isinstance(name, str):
            raise TypeError(f"name must be a str, got {type(name).__name__}")

        if not name.strip():
            raise ValueError("name must not be empty or blank")

        if next_run is not None and not isinstance(next_run, datetime):
            raise TypeError(f"next_run must be a datetime or None, got {type(next_run).__name__}")

        self.func = func
        self.args = args
        self.kwargs = kwargs
        self.interval_seconds = interval_seconds
        self.name = name
        self.next_run = next_run

    def is_due(self, now):
        return self.next_run is not None and now >= self.next_run

    def reschedule(self):
        self.next_run = datetime.now() + timedelta(seconds=self.interval_seconds)


class Scheduler(SimplePlugin):
    """
    Plugin to run jobs at fixed time (cron-like) and to schedule tasks to run immediately in background.

    Configuration:
    - max_workers: Maximum number of worker threads (default: 10)
    - tick_interval: How often the scheduler checks for due tasks in seconds (default: 30)
    """

    max_workers = 10
    tick_interval = 30

    def __init__(self, bus):
        super().__init__(bus)
        self._executor: Optional[ThreadPoolExecutor] = None
        self._tasks: list[_Task] = []
        self._lock = RLock()
        # Event used to wake up the scheduler loop early (e.g. on stop or new task)
        self._tick_event = Event()
        self._scheduler_thread: Optional[Thread] = None
        self._stop_event = Event()
        # Track running futures for wait_for_jobs()
        self._running_futures: set[Future] = set()
        self._futures_lock = RLock()
        self._futures_event = Event()

    # ------------------------------------------------------------------
    # CherryPy engine hooks
    # ------------------------------------------------------------------

    def start(self):
        self.bus.log('Start Scheduler plugin')
        self._stop_event.clear()
        self._tick_event.clear()
        self._executor = ThreadPoolExecutor(
            max_workers=self.max_workers,
            thread_name_prefix="scheduler_worker",
        )
        self._scheduler_thread = Thread(
            target=self._run_loop,
            name="scheduler",
            daemon=True,
        )
        self._scheduler_thread.start()
        # Register bus channels
        self.bus.subscribe('scheduler:add_job', self.add_job)
        self.bus.subscribe('scheduler:add_job_daily', self.add_job_daily)
        self.bus.subscribe('scheduler:add_job_now', self.add_job_now)
        self.bus.subscribe('scheduler:remove_job', self.remove_job)

    start.priority = 49

    def stop(self):
        self.bus.log('Stop Scheduler plugin')
        # Signal the scheduler loop to stop
        self._stop_event.set()
        self._tick_event.set()  # Wake up the loop so it exits immediately
        if self._scheduler_thread:
            self._scheduler_thread.join(timeout=10)
            self._scheduler_thread = None
        # Shutdown executor, wait for running tasks
        if self._executor:
            self._executor.shutdown(wait=True)
            self._executor = None
        self.bus.unsubscribe('scheduler:add_job', self.add_job)
        self.bus.unsubscribe('scheduler:add_job_daily', self.add_job_daily)
        self.bus.unsubscribe('scheduler:add_job_now', self.add_job_now)
        self.bus.unsubscribe('scheduler:remove_job', self.remove_job)

    stop.priority = 51

    def graceful(self):
        """Reload the scheduler."""
        self.stop()
        self.start()

    # ------------------------------------------------------------------
    # Scheduler loop
    # ------------------------------------------------------------------

    def _run_loop(self):
        while not self._stop_event.is_set():
            now = datetime.now()
            with self._lock:
                due_tasks = [t for t in self._tasks if t.is_due(now)]
            for task in due_tasks:
                self._submit_task(task)
                task.reschedule()
            # Sleep until next tick, or until woken up early
            self._tick_event.wait(timeout=self.tick_interval)
            self._tick_event.clear()

    def _submit_task(self, task: _Task) -> Future:
        """Submit a task to the thread pool and track it."""
        if self._executor is None:
            raise RuntimeError("Scheduler not running; cannot submit task.")
        future = self._executor.submit(self._run_task, task)
        with self._futures_lock:
            self._running_futures.add(future)
        future.add_done_callback(self._on_task_done)
        return future

    def _run_task(self, task: _Task):
        self.bus.log(f"Running scheduled task: {task.name}")
        try:
            task.func(*task.args, **task.kwargs)
        except Exception:
            cherrypy.log(
                f"Scheduled task '{task.name}' failed", context='SCHEDULER', severity=logging.ERROR, traceback=True
            )

    def _on_task_done(self, future: Future):
        with self._futures_lock:
            self._running_futures.discard(future)
        self._futures_event.set()
        if future.exception():
            cherrypy.log(f"Task raised an exception: {future.exception()}", context='SCHEDULER', severity=logging.ERROR)

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def add_job(
        self, func: Callable, interval_seconds: int, *args, name: str = None, run_on_start: bool = False, **kwargs
    ) -> _Task:
        """
        Add a recurring job.

        Called via engine.publish('scheduler:add_job', ...) or directly.

        Args:
            func:             The callable to run.
            interval_seconds: How often to run it.
            name:             Optional display name.
            run_on_start:     If True, run immediately on registration.
        """
        if self._executor is None:
            raise RuntimeError("Scheduler not running; cannot add job.")
        now = datetime.now()
        task = _Task(
            func=func,
            args=args,
            kwargs=kwargs,
            interval_seconds=interval_seconds,
            name=name or getattr(func, '__name__', str(func)),
            next_run=now if run_on_start else now + timedelta(seconds=interval_seconds),
        )
        with self._lock:
            # Replace existing task with same name
            self._tasks = [t for t in self._tasks if t.name != task.name]
            self._tasks.append(task)
        # Wake scheduler loop to pick up the new task immediately if run_on_start
        self._tick_event.set()
        return task

    def add_job_daily(self, execution_time: str, func: Callable, *args, **kwargs) -> _Task:
        """
        Add a daily job at a specific time.

        Called via engine.publish('scheduler:add_job_daily', ...) or directly.

        Args:
            execution_time: Time string in 'HH:MM' format.
            func:           The callable to run.
        """
        hour, minute = (int(x) for x in execution_time.split(':', 1))
        now = datetime.now()
        next_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
        if next_run <= now:
            next_run += timedelta(days=1)
        name = kwargs.pop('name', None) or getattr(func, '__name__', str(func))
        task = _Task(
            func=func,
            args=args,
            kwargs=kwargs,
            interval_seconds=86400,
            name=name,
            next_run=next_run,
        )
        with self._lock:
            self._tasks = [t for t in self._tasks if t.name != task.name]
            self._tasks.append(task)
        self._tick_event.set()
        return task

    def add_job_now(self, func: Callable, *args, **kwargs) -> Future:
        """
        Submit a fire-and-forget job to run immediately.

        Called via engine.publish('scheduler:add_job_now', ...) or directly.
        The job does NOT recur. Use add_job() for recurring tasks.
        """
        name = kwargs.pop('name', None) or getattr(func, '__name__', str(func))
        task = _Task(
            func=func,
            args=args,
            kwargs=kwargs,
            interval_seconds=0,
            name=name,
            next_run=None,  # Won't be picked up by the loop
        )
        return self._submit_task(task)

    def get_jobs(self) -> list[_Task]:
        """Return list of scheduled (recurring) tasks."""
        with self._lock:
            return list(self._tasks)

    def remove_job(self, job) -> bool:
        """
        Remove a scheduled recurring job by function reference or name.

        Called via engine.publish('scheduler:remove_job', ...) or directly.
        """
        with self._lock:
            before = len(self._tasks)
            self._tasks = [t for t in self._tasks if t.func != job and t.name != job]
            return len(self._tasks) < before

    def remove_all_jobs(self) -> None:
        """Remove all scheduled recurring jobs."""
        with self._lock:
            self._tasks.clear()

    def wait_for_jobs(self) -> None:
        """
        Block until all currently running jobs have completed.
        Primarily useful in tests.
        """
        while True:
            with self._futures_lock:
                if not self._running_futures:
                    break
            self._futures_event.wait(timeout=1)
            self._futures_event.clear()


# Register Scheduler plugin
cherrypy.scheduler = Scheduler(cherrypy.engine)
cherrypy.scheduler.subscribe()

cherrypy.config.namespaces['scheduler'] = lambda key, value: setattr(cherrypy.scheduler, key, value)
