"""Async scheduler — manages timers for scheduled jobs."""

import asyncio
import re
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta, timezone

import structlog
from croniter import croniter

from fliiq.runtime.scheduler.models import JobDefinition

log = structlog.get_logger()

_INTERVAL_RE = re.compile(r"^(\d+)\s*(s|m|h|d)$")
_INTERVAL_MULTIPLIERS = {"s": 1, "m": 60, "h": 3600, "d": 86400}


def parse_interval(spec: str) -> timedelta:
    """Parse an interval string like '30m', '2h', '1d' into a timedelta."""
    m = _INTERVAL_RE.match(spec.strip())
    if not m:
        raise ValueError(f"Invalid interval format: '{spec}' (expected e.g. '30m', '2h', '1d')")
    amount = int(m.group(1))
    unit = m.group(2)
    return timedelta(seconds=amount * _INTERVAL_MULTIPLIERS[unit])


def compute_next_fire(job: JobDefinition, now: datetime | None = None) -> datetime:
    """Compute when a job should next fire.

    Returns a datetime in UTC. If the computed time is in the past, returns now
    (catch-up: fire immediately, but only once).
    """
    now = now or datetime.now(timezone.utc)

    if job.trigger.type == "cron":
        cron = croniter(job.trigger.schedule, now)
        next_dt = cron.get_next(datetime)
        if next_dt.tzinfo is None:
            next_dt = next_dt.replace(tzinfo=timezone.utc)
        return next_dt

    if job.trigger.type == "at":
        at_dt = datetime.fromisoformat(job.trigger.schedule)
        if at_dt.tzinfo is None:
            at_dt = at_dt.replace(tzinfo=timezone.utc)
        return at_dt if at_dt > now else now

    if job.trigger.type == "every":
        delta = parse_interval(job.trigger.schedule)
        if job.state.last_run_at:
            next_dt = job.state.last_run_at + delta
            if next_dt.tzinfo is None:
                next_dt = next_dt.replace(tzinfo=timezone.utc)
            return next_dt if next_dt > now else now
        return now  # Never run before → fire immediately

    raise ValueError(f"Cannot compute next fire for trigger type: {job.trigger.type}")


class Scheduler:
    """Manages asyncio timers for scheduled jobs."""

    def __init__(self, on_fire: Callable[[JobDefinition], Awaitable[None]]):
        self._on_fire = on_fire
        self._tasks: dict[str, asyncio.Task] = {}
        self._running = False

    @property
    def scheduled_count(self) -> int:
        return len(self._tasks)

    async def start(self, jobs: list[JobDefinition]) -> None:
        """Schedule all eligible jobs (non-webhook, enabled)."""
        self._running = True
        for job in jobs:
            if self._should_schedule(job):
                self._schedule(job)
        log.info("scheduler_started", job_count=len(self._tasks))

    async def stop(self) -> None:
        """Cancel all timer tasks."""
        self._running = False
        for task in self._tasks.values():
            task.cancel()
        # Wait for cancellations to propagate
        if self._tasks:
            await asyncio.gather(*self._tasks.values(), return_exceptions=True)
        self._tasks.clear()
        log.info("scheduler_stopped")

    def add_job(self, job: JobDefinition) -> None:
        """Hot-add or replace a job."""
        self.remove_job(job.name)
        if self._running and self._should_schedule(job):
            self._schedule(job)

    def remove_job(self, name: str) -> None:
        """Remove a scheduled job."""
        task = self._tasks.pop(name, None)
        if task and not task.done():
            task.cancel()

    def _should_schedule(self, job: JobDefinition) -> bool:
        return job.trigger.type != "webhook" and job.enabled

    def _schedule(self, job: JobDefinition) -> None:
        task = asyncio.create_task(self._timer_loop(job), name=f"job:{job.name}")
        self._tasks[job.name] = task

    async def _timer_loop(self, job: JobDefinition) -> None:
        """Sleep until next fire time, call on_fire, then reschedule or stop."""
        try:
            first_iteration = True
            while self._running:
                now = datetime.now(timezone.utc)
                next_fire = compute_next_fire(job, now)
                delay = max(0, (next_fire - now).total_seconds())

                # Re-fire immediately if last run failed (daemon restart after error)
                if first_iteration and job.state.last_status == "error":
                    delay = 0
                    log.info("job_retry_after_failure", job=job.name)
                first_iteration = False

                if delay > 0:
                    await asyncio.sleep(delay)

                if not self._running:
                    break

                log.info("job_firing", job=job.name, trigger=job.trigger.type)

                # Retry loop: up to 3 attempts with 30s/60s backoff
                max_retries = 2
                for attempt in range(1 + max_retries):
                    try:
                        await self._on_fire(job)
                        break
                    except Exception:
                        if attempt < max_retries:
                            wait = 30 * (2 ** attempt)  # 30s, 60s
                            log.warning(
                                "job_fire_retry",
                                job=job.name,
                                attempt=attempt + 1,
                                retry_in_s=wait,
                            )
                            await asyncio.sleep(wait)
                        else:
                            log.exception(
                                "job_fire_failed",
                                job=job.name,
                                attempts=1 + max_retries,
                            )

                # Update last_run_at for interval calculation
                job.state.last_run_at = datetime.now(timezone.utc)

                # One-shot triggers don't reschedule
                if job.trigger.type == "at":
                    self._tasks.pop(job.name, None)
                    break

        except asyncio.CancelledError:
            pass
