"""Cron-Engine: Zeitgesteuerte und event-basierte Aufgaben.

Nutzt APScheduler 3.x (AsyncIOScheduler) um CronJobs periodisch
auszuführen. Jeder Job erzeugt eine IncomingMessage die über den
Gateway-Handler verarbeitet wird -- identisch zu einer User-Nachricht.

Bibel-Referenz: §10 (Cron-Engine & Proaktive Autonomie)
"""

from __future__ import annotations

import contextlib
import logging
from collections.abc import Callable, Coroutine
from pathlib import Path
from typing import TYPE_CHECKING, Any

from jarvis.cron.jobs import JobStore
from jarvis.models import CronJob, IncomingMessage

if TYPE_CHECKING:
    from jarvis.proactive import HeartbeatScheduler

if TYPE_CHECKING:
    from datetime import datetime

logger = logging.getLogger(__name__)

# Typ für den Message-Handler (Gateway.handle_message)
CronHandler = Callable[[IncomingMessage], Coroutine[Any, Any, Any]]


def _parse_cron_fields(expression: str) -> dict[str, str]:
    """Parst einen Cron-Ausdruck in APScheduler-kompatible Felder.

    Unterstützt Standard-5-Feld-Format: minute hour day month day_of_week

    Args:
        expression: Cron-Ausdruck (z.B. "0 7 * * 1-5")

    Returns:
        Dict mit APScheduler CronTrigger-Feldern.

    Raises:
        ValueError: Bei ungültigem Cron-Ausdruck.
    """
    parts = expression.strip().split()
    if len(parts) != 5:
        msg = f"Cron-Ausdruck muss 5 Felder haben, hat {len(parts)}: '{expression}'"
        raise ValueError(msg)

    return {
        "minute": parts[0],
        "hour": parts[1],
        "day": parts[2],
        "month": parts[3],
        "day_of_week": parts[4],
    }


class CronEngine:
    """Async Cron-Engine mit APScheduler-Backend.

    Die CronEngine verwaltet zeitgesteuerte Jobs (CronJobs) und einen
    optionalen Heartbeat-Mechanismus. CronJobs werden aus einer YAML-
    Datei geladen und erzeugen `IncomingMessage`-Objekte, die an den
    Gateway-Handler übergeben werden -- identisch zu einer User-Nachricht.
    Der Heartbeat hingegen ist ein periodischer Check, der den Inhalt
    einer konfigurierten Checkliste liest und als Systemnachricht an den
    Handler sendet. Dieser Mechanismus ermöglicht proaktive Aktionen
    ohne explizite Nutzereingaben.

    Attributes:
        job_store: JobStore-Instanz für CronJob-Persistenz.
        running: Ob die Engine läuft.
        heartbeat_config: Optionale HeartbeatConfig.
        heartbeat_file: Pfad zur Checkliste für Heartbeats.
        _heartbeat_job_id: Interner Name des geplanten Heartbeat-Jobs.
    """

    def __init__(
        self,
        jobs_path: Path | str | None = None,
        handler: CronHandler | None = None,
        heartbeat_config: Any | None = None,
        jarvis_home: Path | None = None,
        heartbeat_scheduler: HeartbeatScheduler | None = None,
    ) -> None:
        """Initialisiert die CronEngine.

        Args:
            jobs_path: Pfad zur jobs.yaml. ``None`` bedeutet, dass
                keine CronJob-Datei verwendet wird und die Engine nur
                den Heartbeat bedient.
            handler: Async-Callback für ausgelöste Jobs und Heartbeats.
            heartbeat_config: Optionale HeartbeatConfig. Wenn ``None``
                oder ``enabled`` auf ``False`` gesetzt ist, wird
                kein Heartbeat geplant.
            jarvis_home: Basisverzeichnis von Jarvis. Wird benötigt,
                um den vollständigen Pfad der Heartbeat-Checkliste
                zu bestimmen, falls ``heartbeat_config.checklist_file``
                ein relativer Pfad ist. Wenn ``None``, wird versucht,
                ``jobs_path`` als Referenz zu verwenden.
        """
        self._scheduler: Any | None = None  # APScheduler (lazy import)
        self._handler = handler
        self._active_jobs: dict[str, str] = {}  # job_name → scheduler_job_id
        self._heartbeat_job_id: str | None = None
        self.running = False
        self._heartbeat_scheduler = heartbeat_scheduler

        # Persistente CronJobs
        if jobs_path is not None:
            self.job_store = JobStore(Path(jobs_path))
        else:
            self.job_store = None  # type: ignore[assignment]

        # Konfiguration für Heartbeat-Mechanismus
        self._heartbeat_config = heartbeat_config
        # Basisverzeichnis bestimmen
        base_dir: Path | None = None
        if jarvis_home is not None:
            base_dir = Path(jarvis_home)
        elif jobs_path is not None:
            # jobs.yaml liegt in <jarvis_home>/cron/jobs.yaml → jarvis_home = parents[1]
            p = Path(jobs_path)
            if p.parent.parent.exists():
                base_dir = p.parent.parent
        # Pfad zur Checkliste auflösen
        self._heartbeat_file: Path | None = None
        if heartbeat_config is not None and getattr(heartbeat_config, "checklist_file", None):
            checklist = Path(heartbeat_config.checklist_file)
            if not checklist.is_absolute():
                # relativer Pfad → an jarvis_home anhängen
                if base_dir is not None:
                    self._heartbeat_file = base_dir / checklist
                else:
                    self._heartbeat_file = checklist
            else:
                self._heartbeat_file = checklist
        else:
            self._heartbeat_file = None

    def set_handler(self, handler: CronHandler) -> None:
        """Setzt den Message-Handler (wird vom Gateway aufgerufen).

        Args:
            handler: Async-Callback für Job-Ausführung.
        """
        self._handler = handler

    @property
    def job_count(self) -> int:
        """Anzahl aktiver (enabled) Jobs."""
        if self.job_store is None:
            return 0
        try:
            self.job_store.load()
            return len(self.job_store.get_enabled())
        except Exception:
            logger.debug("job_count_load_skipped", exc_info=True)
            return 0

    @property
    def has_enabled_jobs(self) -> bool:
        """True wenn mindestens ein Job aktiviert ist."""
        return self.job_count > 0

    async def start(self) -> None:
        """Startet die Cron-Engine und lädt alle aktivierten Jobs."""
        if self.running:
            logger.warning("CronEngine läuft bereits")
            return

        # Lazy import APScheduler.  If unavailable, fall back to a very
        # lightweight in-process scheduler that mimics the minimal API
        # required by the unit tests.  This ensures that the CronEngine
        # can still be started and manipulated even when the optional
        # APScheduler dependency is missing.
        try:
            from apscheduler.schedulers.asyncio import AsyncIOScheduler  # type: ignore
        except Exception:
            # Define a dummy scheduler that provides the minimal API used
            # throughout the CronEngine.  Each scheduled job stores a
            # next_run_time attribute for introspection but does not
            # perform real scheduling.  Jobs are never executed unless
            # triggered manually via ``trigger_now``.
            from datetime import datetime

            class _DummyScheduledJob:
                def __init__(
                    self,
                    job_id: str,
                    func: Callable[[CronJob], Coroutine[Any, Any, Any]],
                    args: list[Any] | None,
                ) -> None:
                    self.id = job_id
                    self.func = func
                    self.args = args or []
                    # next_run_time is set to now; tests only verify that it
                    # exists and is not None.  A timezone-aware datetime
                    # could be used here if needed.
                    self.next_run_time = datetime.now()

            class _DummyScheduler:
                def __init__(self) -> None:
                    self._jobs: dict[str, _DummyScheduledJob] = {}

                def start(self) -> None:  # pragma: no cover
                    # No background thread needed for the dummy scheduler.
                    return

                def shutdown(self, wait: bool = False) -> None:
                    # Clear scheduled jobs
                    self._jobs.clear()

                def add_job(
                    self,
                    func: Callable[[CronJob], Coroutine[Any, Any, Any]],
                    trigger: Any | None = None,
                    args: list[Any] | None = None,
                    id: str | None = None,
                    name: str | None = None,
                    replace_existing: bool = False,
                ) -> _DummyScheduledJob:
                    job_id = id or name or f"job-{len(self._jobs) + 1}"
                    if replace_existing and job_id in self._jobs:
                        self._jobs.pop(job_id, None)
                    job = _DummyScheduledJob(job_id, func, args)
                    self._jobs[job_id] = job
                    return job

                def remove_job(self, job_id: str) -> None:
                    self._jobs.pop(job_id, None)

                def get_job(self, job_id: str) -> _DummyScheduledJob | None:
                    return self._jobs.get(job_id)

            self._scheduler = _DummyScheduler()
            # We intentionally do not log an error here; using the
            # dummy scheduler is a graceful fallback when APScheduler
            # isn't installed.
        else:
            # APScheduler is available; instantiate the real scheduler
            self._scheduler = AsyncIOScheduler(timezone="Europe/Berlin")
            self._scheduler.start()

        # Mark the engine as running regardless of scheduler type
        self.running = True

        # Jobs aus Datei laden
        if self.job_store is not None:
            self.job_store.load()
            for job in self.job_store.get_enabled():
                self._schedule_job(job)

        # Heartbeat planen (falls aktiviert)
        if self._heartbeat_config is not None and getattr(self._heartbeat_config, "enabled", False):
            # Versuche IntervalTrigger nur wenn APScheduler vorhanden ist.
            trigger: Any | None = None
            try:
                from apscheduler.triggers.interval import IntervalTrigger  # type: ignore
            except Exception:
                logger.debug("interval_trigger_import_skipped", exc_info=True)
                trigger = None
            else:
                # Normalisiere Interval auf Integer
                interval = max(1, int(getattr(self._heartbeat_config, "interval_minutes", 30)))
                try:
                    trigger = IntervalTrigger(minutes=interval, timezone="Europe/Berlin")
                except Exception:
                    logger.debug("interval_trigger_creation_skipped", exc_info=True)
                    trigger = None
            # Name und ID des Heartbeat-Jobs
            job_id = "jarvis-heartbeat"
            if self._scheduler is not None:
                scheduled = self._scheduler.add_job(
                    self._execute_heartbeat,
                    trigger=trigger,
                    id=job_id,
                    name="heartbeat",
                    replace_existing=True,
                )
                # Dummy scheduler gibt evtl. kein id zurück
                self._heartbeat_job_id = getattr(scheduled, "id", job_id)
                self._active_jobs["heartbeat"] = self._heartbeat_job_id
                logger.info(
                    "Heartbeat geplant: alle %d Minuten",
                    getattr(self._heartbeat_config, "interval_minutes", 30),
                )

        logger.info(
            "CronEngine gestartet mit %d aktiven Jobs",
            len(self._active_jobs),
        )

    async def stop(self) -> None:
        """Stoppt die Cron-Engine sauber."""
        if not self.running:
            return

        if self._scheduler is not None:
            self._scheduler.shutdown(wait=False)
            self._scheduler = None

        self._active_jobs.clear()
        self.running = False
        logger.info("CronEngine gestoppt")

    def _schedule_job(self, job: CronJob) -> bool:
        """Plant einen einzelnen Job im Scheduler.

        Args:
            job: Der zu planende CronJob.

        Returns:
            True wenn erfolgreich geplant.
        """
        if self._scheduler is None:
            return False

        # Always attempt to parse the cron fields for validation.  When
        # using the dummy scheduler, the trigger is ignored but invalid
        # expressions should still be rejected.
        try:
            fields = _parse_cron_fields(job.schedule)
        except ValueError as exc:
            logger.error("Ungültiger Cron-Ausdruck für '%s': %s", job.name, exc)
            return False

        trigger: Any | None = None
        # Only import CronTrigger when APScheduler is installed.  When
        # unavailable, the trigger remains None and the dummy scheduler
        # simply ignores it.
        try:
            from apscheduler.triggers.cron import CronTrigger  # type: ignore
        except Exception:
            logger.debug("cron_trigger_import_skipped", exc_info=True)
            trigger = None
        else:
            try:
                trigger = CronTrigger(**fields, timezone="Europe/Berlin")
            except Exception as exc:
                logger.error("Ungültiger Cron-Ausdruck für '%s': %s", job.name, exc)
                return False

        # Existierenden Job entfernen falls vorhanden
        if job.name in self._active_jobs:
            self._remove_scheduled(job.name)

        # Schedule job on either the real scheduler or the dummy scheduler
        if self._scheduler is None:
            return False
        scheduled = self._scheduler.add_job(
            self._execute_job,
            trigger=trigger,
            args=[job],
            id=f"jarvis-cron-{job.name}",
            name=job.name,
            replace_existing=True,
        )

        # Some dummy scheduler implementations may not assign an id; fall
        # back to the job name in that case.
        scheduled_id = getattr(scheduled, "id", job.name)
        self._active_jobs[job.name] = scheduled_id
        logger.info("Job '%s' geplant: %s", job.name, job.schedule)
        return True

    def _remove_scheduled(self, name: str) -> bool:
        """Entfernt einen geplanten Job aus dem Scheduler.

        Args:
            name: Job-Name.

        Returns:
            True wenn der Job existierte und entfernt wurde.
        """
        job_id = self._active_jobs.pop(name, None)
        if job_id is not None and self._scheduler is not None:
            with contextlib.suppress(Exception):
                self._scheduler.remove_job(job_id)
            return True
        return False

    async def _execute_job(self, job: CronJob) -> None:
        """Führt einen Cron-Job aus indem eine IncomingMessage erzeugt wird.

        Wenn der Job einen bestimmten Agenten spezifiziert, wird dieser
        über die Message-Metadata an den Gateway übergeben. Der Gateway
        umgeht dann das Keyword-Routing und nutzt den angegebenen Agenten.

        Args:
            job: Der auszuführende CronJob.
        """
        if self._handler is None:
            logger.warning("Kein Handler gesetzt, Job '%s' übersprungen", job.name)
            return

        logger.info("Cron-Job ausgelöst: '%s' (agent=%s)", job.name, job.agent or "auto")

        metadata: dict[str, Any] = {"cron_job": job.name}
        if job.agent:
            metadata["target_agent"] = job.agent

        msg = IncomingMessage(
            channel=job.channel,
            user_id="cron",
            text=f"[CRON:{job.name}] {job.prompt}",
            metadata=metadata,
        )

        try:
            await self._handler(msg)
            logger.info("Cron-Job '%s' erfolgreich", job.name)
        except Exception:
            logger.exception("Cron-Job '%s' fehlgeschlagen", job.name)

    async def _execute_heartbeat(self) -> None:
        """Führt einen Heartbeat-Lauf aus.

        Diese Methode wird regelmäßig vom Scheduler aufgerufen. Sie:
          1. Lässt den HeartbeatScheduler proaktive Tasks abarbeiten
          2. Liest die konfigurierte Heartbeat-Checkliste (falls vorhanden)
          3. Erzeugt eine IncomingMessage für den Handler
        """
        # --- Phase 1: Proaktive Tasks via HeartbeatScheduler ---
        if self._heartbeat_scheduler is not None:
            try:
                processed = await self._heartbeat_scheduler.tick()
                if processed:
                    logger.info(
                        "HeartbeatScheduler: %d Tasks verarbeitet",
                        len(processed),
                    )
            except Exception:
                logger.exception("HeartbeatScheduler.tick() fehlgeschlagen")

        # --- Phase 2: Legacy Heartbeat-Checkliste ---
        if self._handler is None:
            logger.warning("Heartbeat ohne Handler übersprungen")
            return
        # Heartbeat deaktiviert? (Prüfung bei Laufzeit falls Config geändert)
        if not (self._heartbeat_config and getattr(self._heartbeat_config, "enabled", False)):
            return
        # Lese die Checkliste
        text: str = ""
        if self._heartbeat_file is not None and self._heartbeat_file.exists():
            try:
                text = self._heartbeat_file.read_text(encoding="utf-8").strip()
            except Exception:
                logger.exception(
                    "Fehler beim Lesen der Heartbeat-Checkliste: %s", self._heartbeat_file
                )
        # Standardantwort, wenn keine Punkte definiert
        if not text:
            text = "HEARTBEAT_OK"
        # Erzeuge und sende Nachricht
        msg = IncomingMessage(
            channel=getattr(self._heartbeat_config, "channel", "cli"),
            user_id="heartbeat",
            text=f"[HEARTBEAT] {text}",
        )
        try:
            await self._handler(msg)
            logger.info("Heartbeat ausgeführt")
        except Exception:
            logger.exception("Heartbeat fehlgeschlagen")

    # === Öffentliche API für Runtime-Management ===

    def add_runtime_job(self, job: CronJob) -> bool:
        """Fügt einen Job zur Laufzeit hinzu (und persistiert ihn).

        Args:
            job: Der hinzuzufügende CronJob.

        Returns:
            True wenn erfolgreich geplant.
        """
        if self.job_store is not None:
            self.job_store.add_job(job)

        if self.running and job.enabled:
            return self._schedule_job(job)
        return True

    def remove_runtime_job(self, name: str) -> bool:
        """Entfernt einen Job zur Laufzeit.

        Args:
            name: Job-Name.

        Returns:
            True wenn der Job existierte und entfernt wurde.
        """
        was_scheduled = self._remove_scheduled(name)
        was_stored = False
        if self.job_store is not None:
            was_stored = self.job_store.remove_job(name)
        return was_scheduled or was_stored

    def list_jobs(self) -> list[CronJob]:
        """Listet alle konfigurierten Jobs.

        Returns:
            Liste aller CronJobs (aktiv und inaktiv).
        """
        if self.job_store is not None:
            return list(self.job_store.jobs.values())
        return []

    def get_next_run_times(self) -> dict[str, datetime | None]:
        """Gibt die nächsten Ausführungszeiten aller aktiven Jobs zurück.

        Returns:
            Dict: Job-Name → nächste Ausführungszeit (oder None).
        """
        result: dict[str, datetime | None] = {}
        if self._scheduler is None:
            return result

        for name, job_id in self._active_jobs.items():
            try:
                sched_job = self._scheduler.get_job(job_id)
                if sched_job is not None:
                    result[name] = sched_job.next_run_time
                else:
                    result[name] = None
            except Exception:
                logger.debug("next_run_time_fetch_skipped for job %s", name, exc_info=True)
                result[name] = None

        return result

    def add_system_job(
        self,
        name: str,
        schedule: str,
        callback: Any,
        args: list[Any] | None = None,
    ) -> bool:
        """Registriert einen System-Callback-Job (direkter async-Callable).

        Im Gegensatz zu CronJobs, die IncomingMessages erzeugen, fuehrt
        ein System-Job eine beliebige async-Funktion direkt aus.

        Args:
            name: Eindeutiger Job-Name.
            schedule: Cron-Ausdruck (5-Feld-Format).
            callback: Async-Callable das ausgefuehrt wird.
            args: Optionale Argumente fuer den Callback.

        Returns:
            True wenn erfolgreich geplant.
        """
        if self._scheduler is None:
            return False

        try:
            fields = _parse_cron_fields(schedule)
        except ValueError as exc:
            logger.error("Ungueltiger Cron-Ausdruck fuer System-Job '%s': %s", name, exc)
            return False

        trigger: Any = None
        try:
            from apscheduler.triggers.cron import CronTrigger  # type: ignore

            trigger = CronTrigger(**fields, timezone="Europe/Berlin")
        except Exception as exc:
            logger.warning("cron_trigger_creation_failed: %s", exc)

        if name in self._active_jobs:
            self._remove_scheduled(name)

        scheduled = self._scheduler.add_job(
            callback,
            trigger=trigger,
            args=args or [],
            id=f"jarvis-system-{name}",
            name=name,
            replace_existing=True,
        )

        self._active_jobs[name] = getattr(scheduled, "id", name)
        logger.info("System-Job '%s' geplant: %s", name, schedule)
        return True

    async def trigger_now(self, name: str) -> bool:
        """Löst einen Job sofort aus (unabhängig vom Schedule).

        Args:
            name: Job-Name.

        Returns:
            True wenn der Job existiert und ausgelöst wurde.
        """
        if self.job_store is None:
            return False

        job = self.job_store.jobs.get(name)
        if job is None:
            return False

        await self._execute_job(job)
        return True
