import sqlite3
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from uuid import uuid4

from croniter import croniter


class SchedulerError(Exception):
    """Raised when scheduler input or state is invalid."""


@dataclass
class ScheduledTask:
    id: str
    chat_id: str
    prompt: str
    cron: str | None
    run_at: datetime | None
    enabled: bool
    created_at: datetime


class Scheduler:
    def __init__(self, db_path: Path):
        """Open or create SQLite database for schedule storage."""
        self._db_path = Path(db_path)
        self._db_path.parent.mkdir(parents=True, exist_ok=True)

        self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
        self._conn.row_factory = sqlite3.Row
        self._init_schema()

    def add(
        self,
        chat_id: str,
        prompt: str,
        *,
        cron: str | None = None,
        run_at: datetime | None = None,
    ) -> ScheduledTask:
        """Create a scheduled task."""
        if cron is None and run_at is None:
            raise SchedulerError("Must provide cron or run_at.")
        if cron is not None and run_at is not None:
            raise SchedulerError("Provide either cron or run_at, not both.")

        now = _utc_now()
        if cron is not None:
            next_run_at = _next_run_from_cron(cron, now)
        else:
            next_run_at = _as_utc(run_at)

        task = ScheduledTask(
            id=uuid4().hex,
            chat_id=chat_id,
            prompt=prompt,
            cron=cron,
            run_at=next_run_at,
            enabled=True,
            created_at=now,
        )

        with self._conn:
            self._conn.execute(
                """
                INSERT INTO schedules (id, chat_id, prompt, cron, run_at, enabled, created_at)
                VALUES (?, ?, ?, ?, ?, ?, ?)
                """,
                (
                    task.id,
                    task.chat_id,
                    task.prompt,
                    task.cron,
                    _datetime_to_iso(task.run_at),
                    1 if task.enabled else 0,
                    _datetime_to_iso(task.created_at),
                ),
            )

        return task

    def remove(self, task_id: str) -> bool:
        """Remove a scheduled task."""
        with self._conn:
            cursor = self._conn.execute("DELETE FROM schedules WHERE id = ?", (task_id,))
        return cursor.rowcount > 0

    def list(self, chat_id: str | None = None) -> list[ScheduledTask]:
        """List scheduled tasks, optionally filtered by chat_id."""
        if chat_id is None:
            rows = self._conn.execute(
                """
                SELECT id, chat_id, prompt, cron, run_at, enabled, created_at
                FROM schedules
                ORDER BY created_at, id
                """
            ).fetchall()
        else:
            rows = self._conn.execute(
                """
                SELECT id, chat_id, prompt, cron, run_at, enabled, created_at
                FROM schedules
                WHERE chat_id = ?
                ORDER BY created_at, id
                """,
                (chat_id,),
            ).fetchall()

        return [self._row_to_task(row) for row in rows]

    def get(self, task_id: str) -> ScheduledTask | None:
        """Get a single task by ID."""
        row = self._conn.execute(
            """
            SELECT id, chat_id, prompt, cron, run_at, enabled, created_at
            FROM schedules
            WHERE id = ?
            """,
            (task_id,),
        ).fetchone()
        if row is None:
            return None
        return self._row_to_task(row)

    async def tick(self, fire: Callable[[str, str], Awaitable[bool | None]]) -> int:
        """Fire due tasks and reschedule recurring tasks.

        fire() return contract:
        - True / None: delivery attempted; recurring tasks are rescheduled
        - False: permanent invalid destination; recurring tasks are disabled
        """
        now = _utc_now()
        rows = self._conn.execute(
            """
            SELECT id, chat_id, prompt, cron, run_at, enabled, created_at
            FROM schedules
            WHERE enabled = 1 AND run_at IS NOT NULL
            """
        ).fetchall()

        due_tasks: list[ScheduledTask] = []
        for row in rows:
            task = self._row_to_task(row)
            if task.run_at is not None and task.run_at <= now:
                due_tasks.append(task)

        fired = 0
        for task in due_tasks:
            delivery = await fire(task.chat_id, task.prompt)
            fired += 1

            # One-shot reminders are always discarded once due, even if destination is invalid.
            if task.cron is None:
                with self._conn:
                    self._conn.execute("DELETE FROM schedules WHERE id = ?", (task.id,))
                continue

            # Recurring task with permanently invalid destination: disable it.
            if delivery is False:
                with self._conn:
                    self._conn.execute(
                        "UPDATE schedules SET enabled = 0, run_at = NULL WHERE id = ?",
                        (task.id,),
                    )
                continue

            next_run_at = _next_run_from_cron(task.cron, _utc_now())
            with self._conn:
                self._conn.execute(
                    "UPDATE schedules SET run_at = ? WHERE id = ?",
                    (_datetime_to_iso(next_run_at), task.id),
                )

        return fired

    def close(self) -> None:
        """Close database connection."""
        self._conn.close()

    def _init_schema(self) -> None:
        with self._conn:
            self._conn.execute(
                """
                CREATE TABLE IF NOT EXISTS schedules (
                    id TEXT PRIMARY KEY,
                    chat_id TEXT NOT NULL,
                    prompt TEXT NOT NULL,
                    cron TEXT,
                    run_at TEXT,
                    enabled INTEGER NOT NULL DEFAULT 1,
                    created_at TEXT NOT NULL
                )
                """
            )
            self._conn.execute(
                """
                CREATE INDEX IF NOT EXISTS idx_schedules_run_at
                ON schedules(run_at)
                WHERE enabled = 1
                """
            )

    def _row_to_task(self, row: sqlite3.Row) -> ScheduledTask:
        created_at = _datetime_from_iso(row["created_at"])
        if created_at is None:
            raise SchedulerError("Invalid stored created_at value.")

        return ScheduledTask(
            id=str(row["id"]),
            chat_id=str(row["chat_id"]),
            prompt=str(row["prompt"]),
            cron=str(row["cron"]) if row["cron"] is not None else None,
            run_at=_datetime_from_iso(row["run_at"]),
            enabled=bool(row["enabled"]),
            created_at=created_at,
        )


def _next_run_from_cron(cron_expr: str, now: datetime) -> datetime:
    try:
        next_run = croniter(cron_expr, now).get_next(datetime)
    except (
        Exception
    ) as exc:  # pragma: no cover - specific exception type varies by croniter version
        raise SchedulerError(f"Invalid cron expression: {cron_expr}") from exc
    return _as_utc(next_run)


def _datetime_to_iso(value: datetime | None) -> str | None:
    if value is None:
        return None
    return _as_utc(value).isoformat()


def _datetime_from_iso(value: str | None) -> datetime | None:
    if value is None:
        return None
    try:
        parsed = datetime.fromisoformat(value)
    except ValueError as exc:
        raise SchedulerError(f"Invalid stored datetime value: {value}") from exc
    return _as_utc(parsed)


def _as_utc(value: datetime) -> datetime:
    if value.tzinfo is None:
        return value.replace(tzinfo=UTC)
    return value.astimezone(UTC)


def _utc_now() -> datetime:
    return datetime.now(UTC)
