"""Persistent cron scheduler — disk-backed jobs that survive restarts.

Jobs are stored in SQLite. The scheduler checks every minute for due jobs,
executes them, and records the result. Supports cron expressions (via croniter)
and simple interval-based schedules.

Jobs can deliver output to:
- Agent observations (default)
- A specific channel (Telegram, Discord, etc.)
- A webhook URL
"""

from __future__ import annotations

import asyncio
import json
import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING

import aiosqlite

if TYPE_CHECKING:
    from agntspace.automation.heartbeat import Heartbeat
    from agntspace.journal.service import JournalService

log = logging.getLogger(__name__)


class CronScheduler:
    """Persistent cron scheduler backed by SQLite."""

    def __init__(
        self,
        db: aiosqlite.Connection,
        journal: JournalService,
        heartbeat: Heartbeat | None = None,
    ) -> None:
        self._db = db
        self._journal = journal
        self._heartbeat = heartbeat
        self._handlers: dict[str, object] = {}
        self._task: asyncio.Task | None = None  # type: ignore[type-arg]
        self._running = False

    async def init_schema(self) -> None:
        """Create cron tables if they don't exist."""
        await self._db.executescript("""
            CREATE TABLE IF NOT EXISTS cron_jobs (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL UNIQUE,
                schedule TEXT NOT NULL,
                action TEXT NOT NULL,
                action_args TEXT NOT NULL DEFAULT '{}',
                enabled INTEGER NOT NULL DEFAULT 1,
                last_run TEXT,
                next_run TEXT,
                last_result TEXT,
                created_at TEXT NOT NULL,
                deliver_to TEXT DEFAULT 'observations'
            );

            CREATE TABLE IF NOT EXISTS cron_history (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                job_name TEXT NOT NULL,
                started_at TEXT NOT NULL,
                finished_at TEXT,
                status TEXT NOT NULL DEFAULT 'running',
                result TEXT
            );
        """)
        await self._db.commit()

    async def add_job(
        self,
        name: str,
        schedule: str,
        action: str,
        action_args: dict | None = None,
        deliver_to: str = "observations",
    ) -> dict:
        """Add or update a cron job.

        Args:
            name: Unique job identifier
            schedule: Cron expression (e.g., '0 7 * * *') or interval ('every 15m', 'every 2h')
            action: Action type ('briefing', 'eod', 'memory', 'reflection', 'custom')
            action_args: Optional arguments for the action
            deliver_to: Where to deliver output ('observations', 'channel:telegram', 'webhook:url')
        """
        now = datetime.now(UTC).isoformat()
        next_run = self._compute_next_run(schedule, now)

        await self._db.execute(
            """INSERT INTO cron_jobs (name, schedule, action, action_args, enabled, next_run, created_at, deliver_to)
               VALUES (?, ?, ?, ?, 1, ?, ?, ?)
               ON CONFLICT(name) DO UPDATE SET
                 schedule=excluded.schedule, action=excluded.action,
                 action_args=excluded.action_args, next_run=excluded.next_run,
                 deliver_to=excluded.deliver_to, enabled=1""",
            (name, schedule, action, json.dumps(action_args or {}), next_run, now, deliver_to),
        )
        await self._db.commit()
        log.info("Cron job added/updated: %s (%s) → next: %s", name, schedule, next_run)
        return {"name": name, "schedule": schedule, "next_run": next_run}

    async def remove_job(self, name: str) -> bool:
        """Remove a cron job."""
        cursor = await self._db.execute("DELETE FROM cron_jobs WHERE name = ?", (name,))
        await self._db.commit()
        removed = cursor.rowcount > 0
        if removed:
            log.info("Cron job removed: %s", name)
        return removed

    async def enable_job(self, name: str, enabled: bool = True) -> None:
        """Enable or disable a cron job."""
        await self._db.execute(
            "UPDATE cron_jobs SET enabled = ? WHERE name = ?",
            (1 if enabled else 0, name),
        )
        await self._db.commit()

    async def list_jobs(self) -> list[dict]:
        """List all cron jobs."""
        cursor = await self._db.execute(
            "SELECT name, schedule, action, enabled, last_run, next_run, deliver_to FROM cron_jobs ORDER BY name"
        )
        rows = await cursor.fetchall()
        return [
            {
                "name": row[0],
                "schedule": row[1],
                "action": row[2],
                "enabled": bool(row[3]),
                "last_run": row[4],
                "next_run": row[5],
                "deliver_to": row[6],
            }
            for row in rows
        ]

    async def get_history(self, job_name: str | None = None, limit: int = 20) -> list[dict]:
        """Get execution history."""
        if job_name:
            cursor = await self._db.execute(
                "SELECT job_name, started_at, finished_at, status, result FROM cron_history "
                "WHERE job_name = ? ORDER BY started_at DESC LIMIT ?",
                (job_name, limit),
            )
        else:
            cursor = await self._db.execute(
                "SELECT job_name, started_at, finished_at, status, result FROM cron_history "
                "ORDER BY started_at DESC LIMIT ?",
                (limit,),
            )
        rows = await cursor.fetchall()
        return [
            {"job_name": r[0], "started_at": r[1], "finished_at": r[2], "status": r[3], "result": r[4]}
            for r in rows
        ]

    def register_handler(self, action: str, handler: object) -> None:
        """Register an action handler (callable or coroutine)."""
        self._handlers[action] = handler

    def start(self) -> None:
        """Start the cron check loop (runs every 60 seconds)."""
        if self._running:
            return
        self._running = True
        self._task = asyncio.create_task(self._loop())
        log.debug("Cron scheduler started")

    def stop(self) -> None:
        self._running = False
        if self._task:
            self._task.cancel()
            self._task = None

    async def check_and_run(self) -> list[str]:
        """Check for due jobs and execute them. Returns list of jobs run.

        Each job runs inside a ``cron-<name>-*`` correlation scope
        (Rule #17 Tier B) so its handler's activity events, decisions,
        and vault writes share one causal chain visible in /decisions.
        """
        from agntspace.activity.decisions import (
            correlation_scope,
            new_correlation_id,
        )

        now = datetime.now(UTC).isoformat()
        cursor = await self._db.execute(
            "SELECT name, action, action_args, schedule, deliver_to FROM cron_jobs "
            "WHERE enabled = 1 AND next_run <= ?",
            (now,),
        )
        due_jobs = await cursor.fetchall()
        executed = []

        for name, action, args_json, schedule, deliver_to in due_jobs:
            with correlation_scope(new_correlation_id(f"cron-{name}")):
                args = json.loads(args_json)
                log.info("Cron executing: %s (action: %s)", name, action)

                # Record start
                started = datetime.now(UTC).isoformat()
                await self._db.execute(
                    "INSERT INTO cron_history (job_name, started_at, status) VALUES (?, ?, 'running')",
                    (name, started),
                )
                await self._db.commit()

                # Execute
                result_text = ""
                status = "success"
                try:
                    handler = self._handlers.get(action)
                    if handler:
                        if asyncio.iscoroutinefunction(handler):
                            result = await handler(**args)
                        else:
                            result = handler(**args)
                        result_text = str(result) if result else "ok"
                    else:
                        result_text = f"No handler registered for action: {action}"
                        status = "error"
                except Exception as e:
                    result_text = str(e)
                    status = "error"
                    log.exception("Cron job failed: %s", name)

                # Record finish
                finished = datetime.now(UTC).isoformat()
                await self._db.execute(
                    "UPDATE cron_history SET finished_at = ?, status = ?, result = ? "
                    "WHERE job_name = ? AND started_at = ?",
                    (finished, status, result_text[:500], name, started),
                )

                # Update job: last_run, next_run
                next_run = self._compute_next_run(schedule, finished)
                await self._db.execute(
                    "UPDATE cron_jobs SET last_run = ?, next_run = ?, last_result = ? WHERE name = ?",
                    (finished, next_run, status, name),
                )
                await self._db.commit()

                # Deliver output
                if status == "success" and result_text and deliver_to == "observations":
                    self._journal.append_observation(f"Cron [{name}]: {result_text[:200]}")

                # Record pulse on cardiogram
                if self._heartbeat:
                    await self._heartbeat.record_pulse(
                        "cron", f"{name}: {status}", status="deviation" if status == "error" else "normal"
                    )

                executed.append(name)

        return executed

    async def _loop(self) -> None:
        """Main cron loop — checks every 60 seconds."""
        while self._running:
            try:
                executed = await self.check_and_run()
                if executed:
                    log.info("Cron cycle: executed %s", ", ".join(executed))
            except asyncio.CancelledError:
                break
            except Exception:
                log.exception("Cron loop error")
            await asyncio.sleep(60)

    @staticmethod
    def _compute_next_run(schedule: str, after: str) -> str:
        """Compute the next run time from a schedule expression.

        Supports:
        - Cron expressions: '0 7 * * *' (requires croniter)
        - Intervals: 'every 15m', 'every 2h', 'every 1d'
        """
        from datetime import timedelta

        after_dt = datetime.fromisoformat(after.replace("Z", "+00:00"))
        if after_dt.tzinfo is None:
            after_dt = after_dt.replace(tzinfo=UTC)

        # Interval format: 'every Nm', 'every Nh', 'every Nd'
        if schedule.startswith("every "):
            part = schedule[6:].strip()
            unit = part[-1]
            amount = int(part[:-1])
            if unit == "m":
                delta = timedelta(minutes=amount)
            elif unit == "h":
                delta = timedelta(hours=amount)
            elif unit == "d":
                delta = timedelta(days=amount)
            else:
                delta = timedelta(minutes=amount)
            return (after_dt + delta).isoformat()

        # Cron expression — use croniter if available
        try:
            from croniter import croniter

            cron = croniter(schedule, after_dt)
            return cron.get_next(datetime).isoformat()
        except ImportError:
            # Fallback: basic cron parsing without croniter.
            # Handles: fixed values, "*", and "*/N" step syntax.
            parts = schedule.split()
            if len(parts) >= 2:
                minute_str = parts[0]
                hour_str = parts[1]

                def _parse_field(field: str, current: int, max_val: int) -> tuple[int, int | None]:
                    """Return (value, step) for a cron field."""
                    if field == "*":
                        return 0, None
                    if field.startswith("*/"):
                        step = int(field[2:])
                        # Next aligned value >= current
                        aligned = ((current // step) + 1) * step
                        if aligned >= max_val:
                            aligned = 0
                        return aligned, step
                    return int(field), None

                # Handle */N in hour field (e.g. "0 */6 * * *" = every 6 hours)
                if hour_str.startswith("*/"):
                    step = int(hour_str[2:])
                    minute = int(minute_str) if minute_str != "*" else 0
                    # Find next hour that's a multiple of step
                    next_hour = ((after_dt.hour // step) + 1) * step
                    next_dt = after_dt.replace(minute=minute, second=0, microsecond=0)
                    if next_hour >= 24:
                        # Roll to next day at hour 0
                        next_dt = next_dt.replace(hour=0) + timedelta(days=1)
                    else:
                        next_dt = next_dt.replace(hour=next_hour)
                    if next_dt <= after_dt:
                        next_dt += timedelta(hours=step)
                    return next_dt.isoformat()

                minute = int(minute_str) if minute_str != "*" else 0
                hour = int(hour_str) if hour_str != "*" else 0
                next_dt = after_dt.replace(hour=hour, minute=minute, second=0, microsecond=0)
                if next_dt <= after_dt:
                    next_dt += timedelta(days=1)
                return next_dt.isoformat()
            # Default: 1 hour from now
            return (after_dt + timedelta(hours=1)).isoformat()
