"""SP17 — Backup scheduler.

Wraps :class:`cyclone.backup_service.BackupService` in an
asyncio task, mirroring the MFT scheduler pattern (SP16). A backup
tick:

1. Calls :meth:`BackupService.create_now` to take + encrypt a backup.
2. Calls :meth:`BackupService.prune` to apply the retention policy.
3. Writes a tamper-evident ``audit_log`` row (SP11) for each outcome.

The scheduler is OFF by default. Operators opt in via
``CYCLONE_BACKUP_AUTOSTART=true``. The poll interval is
``CYCLONE_BACKUP_INTERVAL_HOURS`` (default 24).

Like the MFT scheduler, this is single-asyncio-task — no
threading, no APScheduler. All access (start/stop/tick/status)
must happen on the same event loop; the FastAPI app satisfies
that trivially because endpoints run on the loop.
"""
from __future__ import annotations

import asyncio
import logging
import os
import traceback
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional

from cyclone import backup_service as svc_mod
from cyclone.audit_log import AuditEvent, append_event
from cyclone.backup_service import BackupService


log = logging.getLogger(__name__)


@dataclass
class BackupTickResult:
    """Outcome of a single backup tick (one cycle of create + prune + audit)."""

    started_at: datetime
    finished_at: Optional[datetime] = None
    created: Optional[svc_mod.BackupRecord] = None
    pruned_paths: list[str] = field(default_factory=list)
    error: Optional[str] = None

    @property
    def ok(self) -> bool:
        return self.error is None and self.created is not None

    def as_dict(self) -> dict[str, Any]:
        return {
            "started_at": self.started_at.isoformat(),
            "finished_at": (
                self.finished_at.isoformat() if self.finished_at else None
            ),
            "ok": self.ok,
            "created": (
                {
                    "id": self.created.id,
                    "filename": self.created.filename,
                    "size_bytes": self.created.size_bytes,
                    "db_fingerprint": self.created.db_fingerprint,
                    "table_count": self.created.table_count,
                }
                if self.created else None
            ),
            "pruned_paths": list(self.pruned_paths),
            "error": self.error,
        }


@dataclass
class BackupSchedulerStatus:
    running: bool
    interval_hours: float
    backup_dir: str
    retention_days: int
    last_tick: Optional[BackupTickResult] = None
    tick_count: int = 0
    total_created: int = 0
    total_errors: int = 0

    def as_dict(self) -> dict[str, Any]:
        return {
            "running": self.running,
            "interval_hours": self.interval_hours,
            "backup_dir": self.backup_dir,
            "retention_days": self.retention_days,
            "tick_count": self.tick_count,
            "total_created": self.total_created,
            "total_errors": self.total_errors,
            "last_tick": self.last_tick.as_dict() if self.last_tick else None,
        }


class BackupScheduler:
    """Asyncio loop that ticks the BackupService on an interval.

    Lifecycle mirrors :class:`cyclone.scheduler.Scheduler`:

        sched = BackupScheduler(backup_service)
        await sched.start()      # begin ticking
        await sched.stop()       # finish current tick, exit
        status = sched.status()  # snapshot

    Threading: NOT thread-safe. All access must happen on the
    same event loop. FastAPI endpoints satisfy this automatically.
    """

    def __init__(
        self,
        service: BackupService,
        *,
        interval_hours: float = 24.0,
    ) -> None:
        self._service = service
        self._interval_hours = max(0.1, float(interval_hours))
        self._task: Optional[asyncio.Task[None]] = None
        self._stop_event = asyncio.Event()
        self._tick_in_progress = False
        self._last_tick: Optional[BackupTickResult] = None
        self._tick_count = 0
        self._total_created = 0
        self._total_errors = 0

    @property
    def service(self) -> BackupService:
        return self._service

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

    async def start(self) -> None:
        if self._task is not None and not self._task.done():
            log.info("BackupScheduler already running; start() is a no-op")
            return
        self._stop_event.clear()
        self._task = asyncio.create_task(self._run(), name="backup-scheduler")
        log.info(
            "BackupScheduler started",
            extra={
                "interval_hours": self._interval_hours,
                "backup_dir": str(self._service.backup_dir),
            },
        )

    async def stop(self) -> None:
        if self._task is None or self._task.done():
            return
        self._stop_event.set()
        try:
            await asyncio.wait_for(self._task, timeout=60)
        except asyncio.TimeoutError:
            log.warning("BackupScheduler did not stop within 60s; cancelling")
            self._task.cancel()
            try:
                await self._task
            except (asyncio.CancelledError, Exception):  # noqa: BLE001
                pass
        self._task = None
        log.info("BackupScheduler stopped")

    def status(self) -> BackupSchedulerStatus:
        return BackupSchedulerStatus(
            running=self.is_running(),
            interval_hours=self._interval_hours,
            backup_dir=str(self._service.backup_dir),
            retention_days=self._service._retention_days,
            last_tick=self._last_tick,
            tick_count=self._tick_count,
            total_created=self._total_created,
            total_errors=self._total_errors,
        )

    def is_running(self) -> bool:
        return self._task is not None and not self._task.done()

    async def tick(self) -> BackupTickResult:
        """Run a single backup tick (create + prune + audit).

        Concurrent ticks are coalesced: if a tick is already in
        progress, the second caller waits for it. This protects
        against a slow backup holding up multiple operator-driven
        ``POST /api/admin/backup/tick`` calls.
        """
        while self._tick_in_progress:
            await asyncio.sleep(0.05)
        self._tick_in_progress = True
        try:
            result = await self._tick_impl()
            self._last_tick = result
            self._tick_count += 1
            if result.created is not None:
                self._total_created += 1
            if result.error is not None:
                self._total_errors += 1
            return result
        finally:
            self._tick_in_progress = False

    # ---- Internals --------------------------------------------------------

    async def _run(self) -> None:
        # Stagger the first tick (same rationale as the MFT scheduler).
        await asyncio.sleep(5)
        while not self._stop_event.is_set():
            try:
                await self.tick()
            except Exception as exc:  # noqa: BLE001
                # tick() catches its own exceptions and returns them
                # in the result. This is the safety net for
                # programmer errors in the loop body.
                log.exception("BackupScheduler tick raised", extra={"error": str(exc)})
            try:
                await asyncio.wait_for(
                    self._stop_event.wait(),
                    timeout=self._interval_hours * 3600,
                )
            except asyncio.TimeoutError:
                pass  # interval elapsed

    async def _tick_impl(self) -> BackupTickResult:
        started = datetime.now(timezone.utc)
        result = BackupTickResult(started_at=started)
        try:
            create_result = await asyncio.to_thread(self._service.create_now)
            result.created = create_result.backup
            # Audit event for the created backup.
            await asyncio.to_thread(
                _audit_backup_created,
                create_result.backup.id,
                create_result.backup.db_fingerprint,
                create_result.backup.table_count,
                "backup-scheduler",
            )
        except Exception as exc:  # noqa: BLE001
            log.exception("Backup create failed during tick")
            result.error = f"create: {type(exc).__name__}: {exc}"
            await asyncio.to_thread(
                _audit_backup_failed,
                f"create: {type(exc).__name__}: {exc}",
                traceback.format_exc()[-500:],
                "backup-scheduler",
            )

        try:
            pruned = await asyncio.to_thread(self._service.prune)
            result.pruned_paths = pruned
            if pruned:
                await asyncio.to_thread(
                    _audit_backup_pruned, pruned, "backup-scheduler",
                )
        except Exception as exc:  # noqa: BLE001
            log.exception("Backup prune failed during tick")
            # Don't clobber the create error if there was one.
            if result.error is None:
                result.error = f"prune: {type(exc).__name__}: {exc}"
            await asyncio.to_thread(
                _audit_backup_failed,
                f"prune: {type(exc).__name__}: {exc}",
                traceback.format_exc()[-500:],
                "backup-scheduler",
            )

        result.finished_at = datetime.now(timezone.utc)
        return result


# ---------------------------------------------------------------------------
# Audit helpers (run in a thread so the asyncio loop doesn't block)
# ---------------------------------------------------------------------------


def _audit_backup_created(
    backup_id: int, db_fingerprint: str, table_count: int, actor: str,
) -> None:
    from cyclone import db
    with db.SessionLocal()() as s:
        try:
            append_event(s, AuditEvent(
                event_type="db.backup_created",
                entity_type="database",
                entity_id="cyclone.db",
                actor=actor,
                payload={
                    "backup_id": backup_id,
                    "db_fingerprint": db_fingerprint,
                    "table_count": table_count,
                },
            ))
            s.commit()
        except Exception:  # noqa: BLE001
            log.exception("Failed to write db.backup_created audit event")


def _audit_backup_failed(
    reason: str, traceback_tail: str, actor: str,
) -> None:
    from cyclone import db
    with db.SessionLocal()() as s:
        try:
            append_event(s, AuditEvent(
                event_type="db.backup_failed",
                entity_type="database",
                entity_id="cyclone.db",
                actor=actor,
                payload={"reason": reason, "traceback_tail": traceback_tail},
            ))
            s.commit()
        except Exception:  # noqa: BLE001
            log.exception("Failed to write db.backup_failed audit event")


def _audit_backup_pruned(deleted_paths: list[str], actor: str) -> None:
    from cyclone import db
    with db.SessionLocal()() as s:
        try:
            append_event(s, AuditEvent(
                event_type="db.backup_pruned",
                entity_type="database",
                entity_id="cyclone.db",
                actor=actor,
                payload={"deleted_paths": deleted_paths},
            ))
            s.commit()
        except Exception:  # noqa: BLE001
            log.exception("Failed to write db.backup_pruned audit event")


# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------


_scheduler: Optional[BackupScheduler] = None


def configure_backup_scheduler(
    service: BackupService,
    *,
    interval_hours: float = 24.0,
) -> BackupScheduler:
    """Create (or return existing) the module-level BackupScheduler."""
    global _scheduler
    if _scheduler is not None:
        return _scheduler
    hours = float(
        os.environ.get("CYCLONE_BACKUP_INTERVAL_HOURS", interval_hours),
    )
    _scheduler = BackupScheduler(service, interval_hours=hours)
    return _scheduler


def get_backup_scheduler() -> BackupScheduler:
    """Return the configured BackupScheduler. Raises if not set up."""
    if _scheduler is None:
        raise RuntimeError(
            "backup scheduler not configured; call configure_backup_scheduler() first",
        )
    return _scheduler


def reset_backup_scheduler_for_tests() -> None:
    """Clear the module-level singleton. Test-only."""
    global _scheduler
    _scheduler = None
