"""Reminder scheduler — background loop that scans upcoming events and
dispatches reminders via :class:`ReminderDispatcher`.

Follows the ``start()``/``stop()`` + ``asyncio.create_task`` loop pattern from
``server/task_store.py`` (KTD-2 — conscious deviation from APScheduler).

ponytail: app.py lifespan wiring is deferred — the orchestrator will call
``start()``/``stop()`` when integrating into the application lifecycle.

ponytail: ``asyncio.sleep`` polling has second-level precision. If sub-second
scheduling is needed, upgrade to APScheduler.
"""

from __future__ import annotations

import asyncio
import logging
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path

from agentkit.calendar.db import (
    DEFAULT_CALENDAR_DB_PATH,
    get_pending_deliveries,
    insert_reminder_delivery,
    list_all_events_in_time_range,
    list_reminder_rules_for_event,
    update_delivery_status,
)
from agentkit.calendar.models import CalendarEvent, ReminderDelivery, ReminderRule
from agentkit.calendar.reminders import ReminderDispatcher

logger = logging.getLogger(__name__)


def _parse_dt(dt_str: str) -> datetime:
    """Parse ISO 8601 string to timezone-aware datetime (UTC)."""
    dt = datetime.fromisoformat(dt_str)
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return dt


class ReminderScheduler:
    """Background scheduler that scans for events entering the reminder window
    and dispatches via the configured channels.
    """

    def __init__(
        self,
        db_path: str | Path | None = None,
        dispatcher: ReminderDispatcher | None = None,
        interval_seconds: int = 60,
        lookback_seconds: int = 3600,
        max_retries: int = 3,
        retry_base_delay: float = 1.0,
    ) -> None:
        self._db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
        self._dispatcher = dispatcher or ReminderDispatcher()
        self._interval = interval_seconds
        self._lookback = lookback_seconds
        self._max_retries = max_retries
        self._retry_base_delay = retry_base_delay
        self._task: asyncio.Task[None] | None = None

    async def start(self) -> None:
        """Start the background scan loop."""
        if self._task is None:
            self._task = asyncio.create_task(self._loop())

    async def stop(self) -> None:
        """Cancel the background scan loop."""
        if self._task is not None:
            self._task.cancel()
            try:
                await self._task
            except asyncio.CancelledError:
                pass
            self._task = None

    async def _loop(self) -> None:
        """Main scan loop — runs until cancelled."""
        while True:
            try:
                await self.scan_once()
            except asyncio.CancelledError:
                break
            except Exception:
                logger.exception("Reminder scheduler scan error")
            await asyncio.sleep(self._interval)

    async def scan_once(self) -> int:
        """Run a single scan cycle. Returns the number of deliveries created.

        Public method so tests can invoke a single scan without waiting for the
        loop interval.
        """
        now = datetime.now(timezone.utc)
        window_start = now - timedelta(seconds=self._lookback)
        window_end = now + timedelta(seconds=self._interval)

        # Query a broad range of events — the reminder_time filter happens below.
        # ponytail: recurring event reminder expansion is not handled here; only
        # the event's stored start_time is used. Upgrade: expand RRULE occurrences
        # and check each occurrence's reminder time.
        query_start = (now - timedelta(hours=2)).isoformat()
        query_end = (now + timedelta(hours=48)).isoformat()
        events = await list_all_events_in_time_range(query_start, query_end, self._db_path)

        dispatched = 0
        for event in events:
            rules = await list_reminder_rules_for_event(event.id, self._db_path)
            for rule in rules:
                reminder_time = _parse_dt(event.start_time) + timedelta(minutes=rule.offset_minutes)
                if window_start <= reminder_time <= window_end:
                    dispatched += await self._process_reminder(event, rule, reminder_time)
        return dispatched

    async def _process_reminder(
        self,
        event: CalendarEvent,
        rule: ReminderRule,
        reminder_time: datetime,
    ) -> int:
        """Create delivery records and dispatch. Returns count of deliveries created.

        Idempotent: if any delivery already exists for this event+rule, skip.
        """
        existing = await get_pending_deliveries(event.id, rule.id, self._db_path, status="sent")
        if existing:
            return 0

        created = 0
        for channel in rule.channels:
            delivery = ReminderDelivery(
                id=uuid.uuid4().hex,
                reminder_rule_id=rule.id,
                event_id=event.id,
                scheduled_time=reminder_time.isoformat(),
                status="pending",
                channel=channel,
                attempts=0,
                last_error=None,
            )
            await insert_reminder_delivery(delivery, self._db_path)
            created += 1
            await self._dispatch_with_retry(event, delivery)
        return created

    async def _dispatch_with_retry(
        self,
        event: CalendarEvent,
        delivery: ReminderDelivery,
    ) -> bool:
        """Attempt dispatch up to ``max_retries`` times with exponential backoff.

        Updates the delivery record's ``attempts`` counter after each try.
        Returns ``True`` on success, ``False`` if all retries exhausted.
        """
        for attempt in range(self._max_retries):
            error: str | None = None
            try:
                success = await self._dispatcher.dispatch(delivery.channel, event, event.user_id)
                if success:
                    await update_delivery_status(delivery.id, "sent", None, self._db_path)
                    return True
            except Exception as exc:
                error = str(exc)

            await update_delivery_status(delivery.id, "failed", error, self._db_path)

            if attempt < self._max_retries - 1:
                await asyncio.sleep(self._retry_base_delay * (2**attempt))
        return False
