"""In-process cron job scheduler: watches cron_jobs.json, schedules and executes jobs."""

from __future__ import annotations

import asyncio
import contextlib
import logging
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING

from cronsim import CronSim, CronSimError

from ductor_bot.cli.param_resolver import TaskOverrides, resolve_cli_config
from ductor_bot.config import resolve_user_timezone
from ductor_bot.cron.execution import (
    build_cmd,
    enrich_instruction,
    execute_one_shot,
)
from ductor_bot.cron.manager import CronManager
from ductor_bot.log_context import set_log_context
from ductor_bot.utils.quiet_hours import check_quiet_hour

if TYPE_CHECKING:
    from ductor_bot.cli.codex_cache import CodexModelCache
    from ductor_bot.cli.param_resolver import TaskExecutionConfig
    from ductor_bot.config import AgentConfig
    from ductor_bot.cron.manager import CronJob
    from ductor_bot.workspace.paths import DuctorPaths

logger = logging.getLogger(__name__)

# Callback signature: (job_title, result_text, status)
CronResultCallback = Callable[[str, str, str], Awaitable[None]]


@dataclass(slots=True)
class _ScheduledJob:
    """Scheduling payload for one cron entry."""

    id: str
    schedule: str
    instruction: str
    task_folder: str
    timezone: str


@dataclass(slots=True)
class _PreparedCronExecution:
    """Resolved execution inputs for one cron run."""

    folder: Path
    exec_config: TaskExecutionConfig
    cmd: list[str]
    timeout: float


class CronObserver:
    """Watches cron_jobs.json and schedules jobs in-process.

    On start: reads all jobs, calculates next run times via cronsim,
    and schedules asyncio tasks. A background watcher polls the JSON
    file's mtime every 5 seconds; on change it reloads and reschedules.
    """

    def __init__(
        self,
        paths: DuctorPaths,
        manager: CronManager,
        *,
        config: AgentConfig,
        codex_cache: CodexModelCache,
    ) -> None:
        self._paths = paths
        self._manager = manager
        self._config = config
        self._codex_cache = codex_cache
        self._on_result: CronResultCallback | None = None
        self._scheduled: dict[str, asyncio.Task[None]] = {}
        self._watcher_task: asyncio.Task[None] | None = None
        self._reschedule_lock = asyncio.Lock()
        self._last_mtime: float = 0.0
        self._running = False

    def set_result_handler(self, handler: CronResultCallback) -> None:
        """Set callback for job results (called after each execution)."""
        self._on_result = handler

    async def start(self) -> None:
        """Start the observer: schedule all jobs and begin watching."""
        self._running = True
        await self._schedule_all()
        self._watcher_task = asyncio.create_task(self._watch_loop())
        logger.info("CronObserver started (%d jobs scheduled)", len(self._scheduled))

    async def stop(self) -> None:
        """Stop the observer: cancel all scheduled jobs and the watcher."""
        self._running = False
        if self._watcher_task:
            self._watcher_task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await self._watcher_task
            self._watcher_task = None
        tasks = list(self._scheduled.values())
        for task in tasks:
            task.cancel()
        self._scheduled.clear()
        if tasks:
            await asyncio.gather(*tasks, return_exceptions=True)
        logger.info("CronObserver stopped")

    async def reschedule_now(self) -> None:
        """Reschedule all jobs immediately (used by interactive cron toggles)."""
        if not self._running:
            return
        await self._update_mtime()
        await self._reschedule_locked()

    # -- File watcher --

    async def _watch_loop(self) -> None:
        """Poll cron_jobs.json mtime every 5 seconds, reschedule on change."""
        while self._running:
            await asyncio.sleep(5)
            try:
                current_mtime = await asyncio.to_thread(
                    lambda: self._paths.cron_jobs_path.stat().st_mtime,
                )
            except FileNotFoundError:
                continue
            if current_mtime != self._last_mtime:
                self._last_mtime = current_mtime
                await asyncio.to_thread(self._manager.reload)
                await self._reschedule_locked()

    # -- Scheduling --

    async def _schedule_all(self) -> None:
        """Schedule asyncio tasks for all enabled jobs."""
        await self._update_mtime()
        for job in self._manager.list_jobs():
            if job.enabled:
                self._schedule_job(
                    job.id,
                    job.schedule,
                    job.agent_instruction,
                    job.task_folder,
                    job.timezone,
                )

    async def _reschedule_all(self) -> None:
        """Cancel existing schedules, await their termination, then reschedule.

        Awaiting cancellation prevents a race where the old task (executing a
        subprocess via asyncio.to_thread) is not yet interrupted and runs
        concurrently with the newly created replacement task.
        """
        tasks = list(self._scheduled.values())
        for task in tasks:
            task.cancel()
        self._scheduled.clear()
        if tasks:
            await asyncio.gather(*tasks, return_exceptions=True)
        await self._schedule_all()
        logger.info("Rescheduled %d jobs", len(self._scheduled))

    async def _reschedule_locked(self) -> None:
        """Serialize reschedules from watcher and interactive updates."""
        async with self._reschedule_lock:
            await self._reschedule_all()

    def _schedule_job(
        self,
        job_id: str,
        schedule: str,
        instruction: str,
        task_folder: str,
        job_timezone: str = "",
    ) -> None:
        """Calculate next run time and schedule an asyncio task.

        Uses the job's timezone (if set), then the global ``user_timezone``
        config, then the host OS timezone, and finally UTC as last resort.
        CronSim iterates in the resolved local timezone so that ``0 9 * * *``
        means 09:00 in the user's wall-clock time.
        """
        try:
            tz = resolve_user_timezone(job_timezone or self._config.user_timezone)
            now_local = datetime.now(tz)
            # CronSim works on time components; feed it the local time
            # so hour fields match the user's wall clock.
            now_naive = now_local.replace(tzinfo=None)
            it = CronSim(schedule, now_naive)
            next_naive: datetime = next(it)
            # Re-attach the timezone using fold=0 (prefer pre-DST interpretation
            # for ambiguous times).  For non-existent times (DST spring-forward
            # gap) the delay becomes negative; in that case advance to the next
            # cron slot so the job fires at the correct wall-clock time.
            next_aware = next_naive.replace(tzinfo=tz)  # fold=0 default
            delay = (next_aware - datetime.now(tz)).total_seconds()
            if delay < 0:
                next_naive = next(it)
                next_aware = next_naive.replace(tzinfo=tz)
                delay = (next_aware - datetime.now(tz)).total_seconds()
            delay = max(delay, 0)
            scheduled_job = _ScheduledJob(
                id=job_id,
                schedule=schedule,
                instruction=instruction,
                task_folder=task_folder,
                timezone=job_timezone,
            )
            task = asyncio.create_task(
                self._run_at(delay, scheduled_job),
            )
            self._scheduled[job_id] = task
            logger.debug(
                "Scheduled %s: next run %s (%s), delay %.0fs",
                job_id,
                next_naive.isoformat(),
                tz.key,
                delay,
            )
        except (CronSimError, StopIteration):
            logger.warning("Invalid cron expression for job %s: %s", job_id, schedule)

    async def _run_at(self, delay: float, scheduled_job: _ScheduledJob) -> None:
        """Wait for delay, execute the job, then reschedule for next occurrence."""
        try:
            await asyncio.sleep(delay)
            await self._execute_job(
                scheduled_job.id,
                scheduled_job.instruction,
                scheduled_job.task_folder,
            )
        except asyncio.CancelledError:
            logger.debug("Cron job %s cancelled during execution", scheduled_job.id)
            return
        if self._running:
            self._schedule_job(
                scheduled_job.id,
                scheduled_job.schedule,
                scheduled_job.instruction,
                scheduled_job.task_folder,
                scheduled_job.timezone,
            )

    # -- Execution --

    def _resolve_execution_config(
        self,
        task_overrides: TaskOverrides,
    ) -> TaskExecutionConfig:
        """Use param_resolver to get final config for this task."""
        return resolve_cli_config(
            self._config,
            self._codex_cache,
            task_overrides=task_overrides,
        )

    async def _execute_job(
        self,
        job_id: str,
        instruction: str,
        task_folder: str,
    ) -> None:
        """Spawn a fresh CLI session in the cron_task folder."""
        set_log_context(operation="cron")
        job = self._manager.get_job(job_id)
        job_title = job.title if job else job_id

        # Acquire dependency lock (if needed)
        from ductor_bot.cron.dependency_queue import get_dependency_queue

        dep_queue = get_dependency_queue()
        dependency = job.dependency if job else None

        async with dep_queue.acquire(job_id, job_title, dependency):
            logger.info("Cron job starting job=%s", job_title)

            if self._is_quiet_hours(job, job_title):
                return

            t0 = time.monotonic()
            prepared = await self._prepare_execution(
                job_id=job_id,
                instruction=instruction,
                task_folder=task_folder,
                job=job,
            )
            if prepared is None:
                return

            logger.debug(
                "Cron subprocess cmd=%s cwd=%s provider=%s model=%s timeout=%.0fs",
                " ".join(prepared.cmd[:3]),
                prepared.folder,
                prepared.exec_config.provider,
                prepared.exec_config.model,
                prepared.timeout,
            )

            try:
                execution = await execute_one_shot(
                    prepared.cmd,
                    cwd=prepared.folder,
                    provider=prepared.exec_config.provider,
                    timeout_seconds=prepared.timeout,
                    timeout_label="Cron job",
                )
            except asyncio.CancelledError:
                logger.debug("Cron job %s cancelled, subprocess terminated", job_id)
                raise

            if execution.timed_out:
                logger.warning(
                    "Cron job %s timed out after %.0fs, killing process",
                    job_id,
                    prepared.timeout,
                )

            if execution.stderr:
                logger.debug(
                    "Cron stderr (%s): %s",
                    job_id,
                    execution.stderr.decode(errors="replace")[:500],
                )

            result_text = execution.result_text
            status = execution.status

            self._manager.update_run_status(job_id, status=status)
            # Refresh our mtime baseline so the file-watcher doesn't treat the
            # run-status write as a user-initiated change and trigger a full
            # reschedule of all other jobs.
            await self._update_mtime()
            elapsed_ms = (time.monotonic() - t0) * 1000
            logger.info(
                "Cron job completed job=%s status=%s duration_ms=%.0f stdout=%d result=%d",
                job_title,
                status,
                elapsed_ms,
                len(execution.stdout),
                len(result_text),
            )

            if self._on_result and job:
                try:
                    await self._on_result(job.title, result_text, status)
                except Exception:
                    logger.exception("Error in cron result handler for job %s", job_id)

    def _is_quiet_hours(self, job: CronJob | None, job_title: str) -> bool:
        """Return True when the job must be skipped due to quiet-hour settings."""
        is_quiet, now_hour, tz = check_quiet_hour(
            quiet_start=job.quiet_start if job else None,
            quiet_end=job.quiet_end if job else None,
            user_timezone=self._config.user_timezone,
            global_quiet_start=self._config.heartbeat.quiet_start,
            global_quiet_end=self._config.heartbeat.quiet_end,
        )
        if not is_quiet:
            return False

        logger.debug(
            "Cron job skipped: quiet hours (%d:00 %s) job=%s",
            now_hour,
            tz.key,
            job_title,
        )
        return True

    async def _prepare_execution(
        self,
        *,
        job_id: str,
        instruction: str,
        task_folder: str,
        job: CronJob | None,
    ) -> _PreparedCronExecution | None:
        """Resolve command, provider config, timeout, and folder for one cron run."""
        folder = self._paths.cron_tasks_dir / task_folder
        if not await asyncio.to_thread(folder.is_dir):
            logger.error("Cron task folder missing: %s", folder)
            self._manager.update_run_status(job_id, status="error:folder_missing")
            return None

        overrides = TaskOverrides(
            provider=job.provider if job else None,
            model=job.model if job else None,
            reasoning_effort=job.reasoning_effort if job else None,
            cli_parameters=job.cli_parameters if job else [],
        )
        exec_config = self._resolve_execution_config(overrides)
        enriched = enrich_instruction(instruction, task_folder)
        cmd = build_cmd(exec_config, enriched)

        if cmd is None:
            logger.error("%s CLI not found for cron job %s", exec_config.provider, job_id)
            self._manager.update_run_status(
                job_id,
                status=f"error:cli_not_found_{exec_config.provider}",
            )
            return None

        return _PreparedCronExecution(
            folder=folder,
            exec_config=exec_config,
            cmd=cmd,
            timeout=self._config.cli_timeout,
        )

    async def _update_mtime(self) -> None:
        """Cache the current mtime of the jobs file."""
        try:
            self._last_mtime = await asyncio.to_thread(
                lambda: self._paths.cron_jobs_path.stat().st_mtime,
            )
        except FileNotFoundError:
            self._last_mtime = 0.0
