"""
CronManager — scheduled tasks and reminders.
Persists jobs to data/cron.json.
Supports one-time (trigger timestamp) and repeating (cron expression) jobs.
"""

import asyncio
import json
import time
import uuid
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

from loguru import logger

from core.events import InboundMessage

try:
    from croniter import croniter
except ImportError:
    croniter = None

RECURRING_JOB_MAX_LAG_SECONDS = 300

# Per-job in-memory dedup: job_id -> last fired unix timestamp.
# Prevents duplicate fires when the bot restarts multiple times within a period.
_job_last_fired: dict[str, float] = {}


class CronManager:
    """Manages scheduled tasks and reminders."""

    def __init__(self, bus: Any, session_manager: Any | None = None):
        self.bus = bus
        self.session_manager = session_manager
        self.jobs: List[Dict[str, Any]] = []
        self.job_state: Dict[str, Dict[str, Any]] = {}
        self._running = False
        self.data_file = Path("data/cron.json")
        self.state_file = Path("data/cron_state.json")
        self.runs_dir = Path("data/cron_runs")
        self.lock = asyncio.Lock()
        self._load_jobs()
        self._load_state()

    def _load_jobs(self) -> None:
        """Load jobs from disk (sync — called only at init)."""
        if not self.data_file.exists():
            return
        try:
            self.jobs = json.loads(self.data_file.read_text(encoding="utf-8"))

            valid = [
                j
                for j in self.jobs
                if j.get("trigger") is not None or j.get("cron_expr")
            ]
            if len(valid) != len(self.jobs):
                logger.warning(
                    f"Dropped {len(self.jobs) - len(valid)} jobs with null trigger."
                )
                self.jobs = valid
            for job in self.jobs:
                job.setdefault("active", True)
            logger.info(f"Loaded {len(self.jobs)} scheduled job(s).")
        except Exception as e:
            logger.error(f"Error loading jobs: {e}")
            self.jobs = []

    def _load_state(self) -> None:
        """Load per-job runtime state from disk."""
        state_file = getattr(self, "state_file", self.data_file.parent / "cron_state.json")
        if not state_file.exists():
            self.job_state = {}
            return
        try:
            raw = json.loads(state_file.read_text(encoding="utf-8"))
            if isinstance(raw, dict) and isinstance(raw.get("jobs"), dict):
                self.job_state = raw["jobs"]
            elif isinstance(raw, dict):
                self.job_state = raw
            else:
                self.job_state = {}
            logger.info(f"Loaded scheduler state for {len(self.job_state)} job(s).")
        except Exception as e:
            logger.error(f"Error loading cron state: {e}")
            self.job_state = {}

    def _save_jobs(self) -> None:
        """Save jobs to disk (sync — always called while lock is held)."""
        try:
            self.data_file.parent.mkdir(parents=True, exist_ok=True)
            self.data_file.write_text(json.dumps(self.jobs, indent=2), encoding="utf-8")
        except Exception as e:
            logger.error(f"Error saving jobs: {e}")

    def _save_state(self) -> None:
        """Persist per-job runtime state separately from job definitions."""
        try:
            if not hasattr(self, "job_state"):
                self.job_state = {}
            state_file = getattr(self, "state_file", self.data_file.parent / "cron_state.json")
            state_file.parent.mkdir(parents=True, exist_ok=True)
            payload = {
                "version": 1,
                "jobs": self.job_state,
                "updated_at": time.time(),
            }
            state_file.write_text(json.dumps(payload, indent=2), encoding="utf-8")
        except Exception as e:
            logger.error(f"Error saving cron state: {e}")

    def _update_job_state(self, job_id: str, **updates: Any) -> Dict[str, Any]:
        """Update in-memory runtime state for a job and return the state."""
        if not hasattr(self, "job_state"):
            self.job_state = {}
        state = dict(self.job_state.get(job_id, {}))
        state.update({k: v for k, v in updates.items() if v is not None})
        state["updated_at"] = time.time()
        self.job_state[job_id] = state
        return state

    def _append_run_event(self, job_id: str, event: Dict[str, Any]) -> None:
        try:
            runs_dir = getattr(self, "runs_dir", self.data_file.parent / "cron_runs")
            runs_dir.mkdir(parents=True, exist_ok=True)
            run_file = runs_dir / f"{job_id}.jsonl"
            payload = dict(event)
            payload.setdefault("timestamp", time.time())
            with run_file.open("a", encoding="utf-8") as f:
                f.write(json.dumps(payload, default=str) + "\n")
        except Exception as e:
            logger.error(f"Error writing cron run event for {job_id}: {e}")

    @staticmethod
    def _resolve_timezone(tz_name: Optional[str], tz_offset: Optional[int]):
        if tz_name:
            try:
                return ZoneInfo(tz_name)
            except ZoneInfoNotFoundError:
                logger.warning(f"Unknown scheduler timezone '{tz_name}', falling back to offset/UTC.")
        if tz_offset is not None:
            return timezone(timedelta(minutes=tz_offset))
        return timezone.utc

    async def add_job(
        self,
        trigger_time: Optional[float],
        message: str,
        context: Dict[str, Any],
        cron_expr: Optional[str] = None,
        tz_offset: Optional[int] = None,
        tz: Optional[str] = None,
        name: Optional[str] = None,
    ) -> str:
        """
        Add a new job.

        Args:
            trigger_time: Unix timestamp for the first (or only) execution.
            message:      Content to send/process when the job fires.
            context:      {channel, chat_id, sender_id, ...}
            cron_expr:    Standard cron expression for repeating jobs.
            tz_offset:    Timezone offset in minutes (e.g. -360 for UTC-6).
            tz:           IANA timezone name such as America/El_Salvador.
            name:         Optional human-readable job name.
        """
        async with self.lock:
            if cron_expr and not trigger_time:
                if not croniter:
                    raise ImportError(
                        "croniter library is required for cron expressions."
                    )
                tzinfo = self._resolve_timezone(tz, tz_offset)
                base = datetime.fromtimestamp(time.time(), tz=tzinfo)

                cron_it = croniter(cron_expr, base)
                trigger_time = cron_it.get_next(float)

            job_id = str(uuid.uuid4())[:8]
            job = {
                "id": job_id,
                "trigger": trigger_time,
                "cron_expr": cron_expr,
                "tz_offset": tz_offset,
                "tz": tz,
                "active": True,
                "name": name or self._default_job_name(message),
                "payload": message,
                "context": context,
                "created_at": time.time(),
            }
            self.jobs.append(job)
            self._save_jobs()

            kind = f"repeating '{cron_expr}'" if cron_expr else "one-time"
            logger.info(f"Added {kind} job {job_id}: '{message}' at {trigger_time}")
            return job_id

    @staticmethod
    def _default_job_name(message: str) -> str:
        """Generate a short display name for scheduler UIs and lists."""
        text = " ".join((message or "").split())
        if not text:
            return "Scheduled job"
        if text.startswith("@"):
            return text.split("::", 1)[0].lstrip("@").replace("_", " ").title()
        return text[:60] + ("..." if len(text) > 60 else "")

    async def remove_job(self, job_id: str) -> bool:
        """Remove a job by ID. Returns True if found and removed."""
        async with self.lock:
            before = len(self.jobs)
            self.jobs = [j for j in self.jobs if j["id"] != job_id]
            if len(self.jobs) < before:
                self._save_jobs()
                logger.info(f"Removed job {job_id}")
                return True
            return False

    async def set_job_active(self, job_id: str, active: bool) -> Optional[Dict[str, Any]]:
        """Pause or resume a job. Returns the updated job if found."""
        async with self.lock:
            for job in self.jobs:
                if job["id"] != job_id:
                    continue
                job["active"] = bool(active)
                self._update_job_state(job_id, active=bool(active))
                self._save_jobs()
                self._save_state()
                logger.info(
                    f"{'Resumed' if active else 'Paused'} job {job_id}"
                )
                return dict(job)
        return None

    async def list_jobs(self) -> List[Dict[str, Any]]:
        """Return a sorted snapshot of all pending jobs."""
        async with self.lock:
            snapshot = []
            for job in self.jobs:
                item = dict(job)
                state = dict(getattr(self, "job_state", {}).get(job["id"], {}))
                if state:
                    item["state"] = state
                snapshot.append(item)
            return sorted(snapshot, key=lambda x: x.get("trigger") or float("inf"))

    async def register_system_jobs(self, channels: list) -> None:
        """
        Register built-in system jobs (e.g., greetings, check-ins).
        'channels' should be a list of dicts: [{'channel': 'discord', 'chat_id': '...'}]
        """
        existing_payloads = {j["payload"] for j in self.jobs}

        for ch in channels:
            chat_id = ch["chat_id"]
            channel = ch["channel"]
            ctx = {
                "channel": channel,
                "chat_id": chat_id,
                "sender_id": ch.get("sender_id", "system"),
            }

            greeting_payload = f"@morning_greeting::{chat_id}"
            if greeting_payload not in existing_payloads:
                await self.add_job(
                    trigger_time=None,
                    message=greeting_payload,
                    context=ctx,
                    cron_expr="0 8 * * *",
                )
                logger.info(f"Registered morning greeting job for {chat_id}")

            checkin_payload = f"@silence_checkin::{chat_id}"
            if checkin_payload not in existing_payloads:
                await self.add_job(
                    trigger_time=None,
                    message=checkin_payload,
                    context=ctx,
                    cron_expr="0 10 * * *",
                )
                logger.info(f"Registered silence check-in job for {chat_id}")

    async def run(self) -> None:
        """Main loop — checks for triggered jobs every second."""
        self._running = True
        logger.info("Scheduler started.")

        while self._running:
            try:
                now = time.time()

                async with self.lock:
                    due = [
                        j
                        for j in self.jobs
                        if j.get("active", True)
                        and j.get("trigger") is not None
                        and j["trigger"] <= now
                    ]

                    for job in due:
                        job_id = job["id"]

                        if job.get("cron_expr"):
                            tz_off = job.get("tz_offset")
                            tzinfo = self._resolve_timezone(job.get("tz"), tz_off)

                            lag = max(0.0, now - float(job["trigger"]))
                            scheduled_dt = datetime.fromtimestamp(job["trigger"], tz=tzinfo)
                            cron_it = croniter(job["cron_expr"], scheduled_dt)
                            next_trigger = cron_it.get_next(float)
                            skipped_stale = lag > RECURRING_JOB_MAX_LAG_SECONDS

                            while next_trigger <= now:
                                skipped_stale = True
                                scheduled_dt = datetime.fromtimestamp(next_trigger, tz=tzinfo)
                                cron_it = croniter(job["cron_expr"], scheduled_dt)
                                next_trigger = cron_it.get_next(float)

                            job["trigger"] = next_trigger
                            self._update_job_state(
                                job_id,
                                next_trigger=next_trigger,
                                next_run_at=next_trigger,
                                nextRunAtMs=int(next_trigger * 1000),
                            )
                            logger.info(
                                f"Rescheduled job {job_id} → {job['trigger']} (tz_offset={tz_off})"
                            )
                            if skipped_stale:
                                state = self._update_job_state(
                                    job_id,
                                    last_status="skipped_stale",
                                    lastStatus="skipped_stale",
                                    last_lag_seconds=lag,
                                    consecutive_errors=0,
                                )
                                self._append_run_event(
                                    job_id,
                                    {
                                        "type": "job_skipped",
                                        "action": "skipped",
                                        "status": "skipped_stale",
                                        "payload": job.get("payload"),
                                        "lag_seconds": lag,
                                        "next_trigger": next_trigger,
                                        "nextRunAtMs": int(next_trigger * 1000),
                                        "state": state,
                                    },
                                )
                                logger.info(
                                    f"Skipping stale recurring job {job_id} (lag={lag:.1f}s)"
                                )
                                continue

                            # ── Dedup: skip if already fired in this same period ──
                            last_fired = _job_last_fired.get(job_id, 0.0)
                            if (now - last_fired) < lag + 1.0:
                                logger.debug(
                                    f"Dedup: skipping duplicate fire for job {job_id} "
                                    f"(last_fired={last_fired:.1f}, lag={lag:.1f}s)"
                                )
                                continue

                        else:
                            self.jobs = [j for j in self.jobs if j["id"] != job_id]

                        _job_last_fired[job_id] = now
                        self._update_job_state(
                            job_id,
                            last_scheduled_trigger=job.get("trigger"),
                            lastRunAtMs=int(now * 1000),
                            last_run_at=now,
                            last_status="running",
                            lastStatus="running",
                        )
                        asyncio.create_task(self._execute_job(job))

                    if due:
                        self._save_jobs()
                        self._save_state()

                await asyncio.sleep(1)

            except asyncio.CancelledError:
                break
            except Exception as e:
                logger.error(f"Scheduler loop error: {e}")
                await asyncio.sleep(5)

        logger.info("Scheduler stopped.")

    async def stop(self) -> None:
        """Signal the run loop to exit."""
        self._running = False

    async def _execute_job(self, job: Dict[str, Any]) -> None:
        """Fire a triggered job by publishing an inbound message."""
        from core.task_tracker import get_task_tracker

        tracker = get_task_tracker()
        started_at = time.time()
        job_id = job["id"]
        next_trigger = job.get("trigger")
        _task_id = await tracker.create_task(
            task_type="scheduled_job",
            summary=f"Cron job: {job['payload'][:80]}",
            channel=job.get("context", {}).get("channel", "unknown"),
            chat_id=job.get("context", {}).get("chat_id", "unknown"),
            metadata={"job_id": job_id},
        )
        await tracker.update_task(_task_id, status="running")

        logger.info(f"Executing job {job_id}: {job['payload']}")
        await asyncio.to_thread(
            self._append_run_event,
            job_id,
            {
                "type": "job_started",
                "action": "started",
                "status": "running",
                "payload": job["payload"],
                "context": job.get("context", {}),
                "runAtMs": int(started_at * 1000),
                "nextRunAtMs": int(next_trigger * 1000) if next_trigger else None,
            },
        )

        context = job.get("context", {})
        msg = InboundMessage(
            channel=context.get("channel", "unknown"),
            sender_id=context.get("sender_id", "unknown"),
            chat_id=context.get("chat_id", "unknown"),
            content=f"[SCHEDULER] {job['payload']}",
            metadata={
                "is_scheduler": True,
                "original_job_id": job["id"],
                "reply_to": context.get("sender_id", "unknown"),
            },
        )
        try:
            await self.bus.publish_inbound(msg)
            duration_ms = int((time.time() - started_at) * 1000)
            prior = getattr(self, "job_state", {}).get(job_id, {})
            state = self._update_job_state(
                job_id,
                last_run_at=started_at,
                lastRunAtMs=int(started_at * 1000),
                last_status="ok",
                lastStatus="ok",
                last_run_status="ok",
                lastRunStatus="ok",
                last_duration_ms=duration_ms,
                lastDurationMs=duration_ms,
                next_trigger=next_trigger,
                next_run_at=next_trigger,
                nextRunAtMs=int(next_trigger * 1000) if next_trigger else None,
                consecutive_errors=0,
                consecutiveErrors=0,
            )
            await asyncio.to_thread(self._save_state)
            await asyncio.to_thread(
                self._append_run_event,
                job_id,
                {
                    "type": "job_finished",
                    "action": "finished",
                    "status": "ok",
                    "payload": job["payload"],
                    "channel": context.get("channel", "unknown"),
                    "chat_id": context.get("chat_id", "unknown"),
                    "runAtMs": int(started_at * 1000),
                    "durationMs": duration_ms,
                    "nextRunAtMs": int(next_trigger * 1000) if next_trigger else None,
                    "previousStatus": prior.get("last_status") or prior.get("lastStatus"),
                    "state": state,
                },
            )
            await tracker.complete_task(_task_id)
        except Exception as e:
            duration_ms = int((time.time() - started_at) * 1000)
            prior_errors = int(
                getattr(self, "job_state", {}).get(job_id, {}).get(
                    "consecutive_errors",
                    getattr(self, "job_state", {}).get(job_id, {}).get("consecutiveErrors", 0),
                )
                or 0
            )
            state = self._update_job_state(
                job_id,
                last_run_at=started_at,
                lastRunAtMs=int(started_at * 1000),
                last_status="error",
                lastStatus="error",
                last_run_status="error",
                lastRunStatus="error",
                last_error=str(e),
                lastError=str(e),
                last_duration_ms=duration_ms,
                lastDurationMs=duration_ms,
                next_trigger=next_trigger,
                next_run_at=next_trigger,
                nextRunAtMs=int(next_trigger * 1000) if next_trigger else None,
                consecutive_errors=prior_errors + 1,
                consecutiveErrors=prior_errors + 1,
            )
            await asyncio.to_thread(self._save_state)
            logger.error(f"Failed to publish job {job_id}: {e}")
            await asyncio.to_thread(
                self._append_run_event,
                job_id,
                {
                    "type": "job_error",
                    "action": "finished",
                    "status": "error",
                    "payload": job["payload"],
                    "error": str(e),
                    "runAtMs": int(started_at * 1000),
                    "durationMs": duration_ms,
                    "nextRunAtMs": int(next_trigger * 1000) if next_trigger else None,
                    "state": state,
                },
            )
            await tracker.complete_task(_task_id, error=str(e))
