# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""
Scheduler System

Enables proactive agent behavior:
- Scheduled tasks (cron-like)
- Reminders
- Heartbeat check-ins
- Triggered events

The scheduler runs in a background thread and can
invoke callbacks that send messages to channels.

Tasks are persisted to disk. Callbacks must be re-registered
after restart using register_callback() or by task_type.
"""

import json
import logging
import threading
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta
from enum import Enum
from typing import Any, Callable, Optional

from .config import DATA_DIR
from .utils import atomic_write_json

logger = logging.getLogger(__name__)

TASKS_FILE = DATA_DIR / "scheduled_tasks.json"


class TaskFrequency(Enum):
    ONCE = "once"
    MINUTELY = "minutely"
    HOURLY = "hourly"
    DAILY = "daily"
    WEEKLY = "weekly"
    MONTHLY = "monthly"
    CRON = "cron"  # Custom cron expression


class TaskType(Enum):
    """
    Built-in task types with pre-defined handlers.
    Use CUSTOM for tasks with dynamically registered callbacks.
    """

    CUSTOM = "custom"  # Requires manual callback registration
    REMINDER = "reminder"  # Simple reminder message
    BRIEFING = "briefing"  # Daily briefing
    HEARTBEAT = "heartbeat"  # System health check
    CLEANUP = "cleanup"  # Cleanup old files/data


@dataclass
class ScheduledTask:
    """A scheduled task."""

    id: str
    name: str
    description: str
    frequency: str  # TaskFrequency value
    next_run: str  # ISO datetime
    last_run: Optional[str]
    enabled: bool
    payload: dict  # Data to pass to handler
    channel: Optional[str]  # Which channel to notify
    chat_id: Optional[str]  # Which chat to notify

    # Task type for automatic callback resolution
    task_type: str = "custom"  # TaskType value

    # For CRON frequency
    cron_expression: Optional[str] = None

    # For interval-based
    interval_minutes: Optional[int] = None


class Scheduler:
    """
    Background scheduler for proactive tasks.

    Tasks are persisted to disk. For tasks to survive restarts:
    1. Use a built-in TaskType (REMINDER, BRIEFING, etc.)
    2. Or call register_callback() after restart for CUSTOM tasks

    Usage:
        scheduler = Scheduler()

        # Add a daily check-in (uses built-in BRIEFING handler)
        scheduler.add_task(
            name="morning_briefing",
            description="Send morning briefing",
            frequency=TaskFrequency.DAILY,
            task_type=TaskType.BRIEFING,
            time_of_day="08:00",
            channel="telegram",
            chat_id="12345"
        )

        # Add custom task with callback
        scheduler.add_task(
            name="custom_task",
            description="Do something custom",
            frequency=TaskFrequency.HOURLY,
            task_type=TaskType.CUSTOM,
            callback=my_callback_function
        )

        # Start scheduler
        scheduler.start()
    """

    def __init__(self, agent=None):
        self.agent = agent  # Reference to agent for built-in handlers
        self.tasks: dict[str, ScheduledTask] = {}
        self.callbacks: dict[str, Callable] = {}
        self._thread: Optional[threading.Thread] = None
        self._stop_event = threading.Event()
        self._lock = threading.RLock()

        # Built-in handlers for task types
        self._builtin_handlers: dict[TaskType, Callable] = {
            TaskType.REMINDER: self._handle_reminder,
            TaskType.BRIEFING: self._handle_briefing,
            TaskType.HEARTBEAT: self._handle_heartbeat,
            TaskType.CLEANUP: self._handle_cleanup,
        }

        self._load_tasks()

    def set_agent(self, agent):
        """Set agent reference (for built-in handlers that need it)."""
        self.agent = agent

    def _load_tasks(self):
        """Load tasks from disk."""
        if TASKS_FILE.exists():
            try:
                with open(TASKS_FILE) as f:
                    data = json.load(f)
                    for task_data in data:
                        # Handle missing task_type for backwards compatibility
                        if "task_type" not in task_data:
                            task_data["task_type"] = "custom"
                        task = ScheduledTask(**task_data)
                        self.tasks[task.id] = task

                        # Auto-register built-in handlers
                        try:
                            task_type = TaskType(task.task_type)
                            if task_type in self._builtin_handlers:
                                self.callbacks[task.id] = self._builtin_handlers[task_type]
                        except ValueError:
                            pass  # Unknown task type, needs manual callback

                logger.info(f"Loaded {len(self.tasks)} scheduled tasks")
            except Exception as e:
                logger.error(f"Failed to load tasks: {e}")

    def _save_tasks(self):
        """Save tasks to disk atomically."""
        data = [asdict(t) for t in self.tasks.values()]
        atomic_write_json(TASKS_FILE, data)

    # Built-in task handlers

    def _handle_reminder(self, task: ScheduledTask):
        """Handle reminder task - sends message to channel."""
        message = task.payload.get("message", task.description)
        logger.info(f"Reminder: {message}")
        # Actual delivery handled by channel integration
        return {"message": message, "channel": task.channel, "chat_id": task.chat_id}

    def _handle_briefing(self, task: ScheduledTask):
        """Handle daily briefing task."""
        if self.agent:
            # Generate briefing using agent
            prompt = task.payload.get("prompt", "Give me my morning briefing")
            try:
                response = self.agent.chat(prompt, user_id="scheduler", channel="internal")
                return {"briefing": response, "channel": task.channel, "chat_id": task.chat_id}
            except Exception as e:
                logger.error(f"Briefing generation failed: {e}")
                return {"error": str(e)}
        return {"error": "No agent configured for briefings"}

    def _handle_heartbeat(self, task: ScheduledTask):
        """Handle heartbeat/health check task.

        Uses the proactive skill's heartbeat_check if available,
        which respects quiet hours and only alerts on actionable items.
        Falls back to basic system health info.
        """
        # Try proactive skill's smarter heartbeat
        try:
            from familiar.skills.proactive.skill import heartbeat_check

            result = heartbeat_check({})
            if result:
                logger.info(f"Heartbeat: {result[:200]}")
                return {"message": result, "channel": task.channel, "chat_id": task.chat_id}
            # Silence is service — nothing to report
            logger.debug("Heartbeat: nothing actionable")
            return None
        except ImportError as e:
            logger.debug("Optional dependency not available: %s", e)
        # Fallback: basic health info
        import platform

        info = {
            "timestamp": datetime.now().isoformat(),
            "hostname": platform.node(),
            "status": "healthy",
        }
        logger.debug(f"Heartbeat: {info}")
        return info

    def _handle_cleanup(self, task: ScheduledTask):
        """Handle cleanup task - purges expired sessions, audit logs, and backups."""
        max_age_days = task.payload.get("max_age_days", 30)
        logger.info(f"Cleanup task running (max_age={max_age_days} days)")

        results = {
            "max_age_days": max_age_days,
            "sessions_evicted": 0,
            "audit_purged": 0,
            "backups_cleaned": 0,
        }

        # 1. Clean expired sessions
        if self.agent and hasattr(self.agent, "sessions"):
            try:
                results["sessions_evicted"] = self.agent.sessions.cleanup_idle_sessions(
                    max_idle_days=max_age_days
                )
            except Exception as e:
                logger.warning(f"Session cleanup failed: {e}")

        # 2. Purge expired audit logs
        try:
            from familiar.core.audit import get_audit_store

            store = get_audit_store()
            results["audit_purged"] = store.purge_expired()
        except Exception as e:
            logger.warning(f"Audit purge failed: {e}")

        # 3. Clean old backups
        if self.agent and hasattr(self.agent, "_backup_manager"):
            try:
                mgr = self.agent._backup_manager
                if mgr:
                    cleaned = mgr.cleanup_old_backups(max_age_days=max_age_days)
                    results["backups_cleaned"] = cleaned if isinstance(cleaned, int) else 0
            except Exception as e:
                logger.warning(f"Backup cleanup failed: {e}")

        # 4. Episodic memory consolidation (LLM-powered summarization)
        if self.agent and hasattr(self.agent, "_consolidation_summarizer"):
            try:
                from .memory_manager import MemoryManager

                mm: MemoryManager = getattr(self.agent, "_memory_manager", None)
                if mm and hasattr(mm, "_episodic_stores"):
                    consolidated = 0
                    for uid, ms in list(mm._episodic_stores.items()):
                        try:
                            n = ms.consolidate(
                                threshold=0.3,
                                summarize_fn=self.agent._consolidation_summarizer,
                            )
                            if n > 0:
                                self.agent._save_episodic_memory_async(uid)
                            consolidated += n
                        except Exception as e:
                            logger.debug(f"Consolidation failed for user {uid}: {e}")
                    results["memories_consolidated"] = consolidated
            except Exception as e:
                logger.warning(f"Memory consolidation failed: {e}")

        logger.info(f"Cleanup complete: {results}")
        return results

    def add_task(
        self,
        name: str,
        description: str,
        frequency: TaskFrequency,
        callback: Optional[Callable[..., Any]] = None,
        task_type: TaskType = TaskType.CUSTOM,
        channel: Optional[str] = None,
        chat_id: Optional[str] = None,
        time_of_day: Optional[str] = None,  # "HH:MM" for daily
        interval_minutes: Optional[int] = None,  # For custom intervals
        cron_expression: Optional[str] = None,
        payload: Optional[dict[str, Any]] = None,
        run_immediately: bool = False,
    ) -> str:
        """Add a scheduled task."""
        import uuid

        task_id = str(uuid.uuid4())[:8]

        # Calculate next run time
        now = datetime.now()

        if frequency == TaskFrequency.ONCE:
            next_run = now + timedelta(minutes=interval_minutes or 1)

        elif frequency == TaskFrequency.MINUTELY:
            next_run = now + timedelta(minutes=1)

        elif frequency == TaskFrequency.HOURLY:
            next_run = now.replace(minute=0, second=0) + timedelta(hours=1)

        elif frequency == TaskFrequency.DAILY:
            if time_of_day:
                hour, minute = map(int, time_of_day.split(":"))
                next_run = now.replace(hour=hour, minute=minute, second=0)
                if next_run <= now:
                    next_run += timedelta(days=1)
            else:
                next_run = now + timedelta(days=1)

        elif frequency == TaskFrequency.WEEKLY:
            next_run = now + timedelta(weeks=1)

        else:
            next_run = now + timedelta(minutes=interval_minutes or 60)

        if run_immediately:
            next_run = now

        task = ScheduledTask(
            id=task_id,
            name=name,
            description=description,
            frequency=frequency.value,
            next_run=next_run.isoformat(),
            last_run=None,
            enabled=True,
            payload=payload or {},
            channel=channel,
            chat_id=chat_id,
            task_type=task_type.value,
            cron_expression=cron_expression,
            interval_minutes=interval_minutes,
        )

        with self._lock:
            self.tasks[task_id] = task

            # Register callback
            if callback:
                self.callbacks[task_id] = callback
            elif task_type in self._builtin_handlers:
                self.callbacks[task_id] = self._builtin_handlers[task_type]

            self._save_tasks()

        logger.info(f"Added task: {name} (type={task_type.value}, next run: {next_run})")
        return task_id

    def remove_task(self, task_id: str) -> bool:
        """Remove a scheduled task."""
        with self._lock:
            if task_id in self.tasks:
                del self.tasks[task_id]
                self.callbacks.pop(task_id, None)
                self._save_tasks()
                return True
            return False

    def enable_task(self, task_id: str) -> bool:
        """Enable a task."""
        with self._lock:
            if task_id in self.tasks:
                self.tasks[task_id].enabled = True
                self._save_tasks()
                return True
            return False

    def disable_task(self, task_id: str) -> bool:
        """Disable a task."""
        with self._lock:
            if task_id in self.tasks:
                self.tasks[task_id].enabled = False
                self._save_tasks()
                return True
            return False

    def list_tasks(self) -> list[dict]:
        """List all tasks."""
        return [
            {
                "id": t.id,
                "name": t.name,
                "description": t.description,
                "frequency": t.frequency,
                "next_run": t.next_run,
                "enabled": t.enabled,
            }
            for t in self.tasks.values()
        ]

    def register_callback(self, task_id: str, callback: Callable):
        """Register or update callback for a task."""
        self.callbacks[task_id] = callback

    def _calculate_next_run(self, task: ScheduledTask) -> datetime:
        """Calculate the next run time for a task."""
        now = datetime.now()

        freq = TaskFrequency(task.frequency)

        if freq == TaskFrequency.ONCE:
            # Don't reschedule
            return None

        elif freq == TaskFrequency.MINUTELY:
            return now + timedelta(minutes=1)

        elif freq == TaskFrequency.HOURLY:
            return now + timedelta(hours=1)

        elif freq == TaskFrequency.DAILY:
            # Same time tomorrow
            last = datetime.fromisoformat(task.next_run)
            return last + timedelta(days=1)

        elif freq == TaskFrequency.WEEKLY:
            return now + timedelta(weeks=1)

        elif freq == TaskFrequency.MONTHLY:
            # Rough approximation
            return now + timedelta(days=30)

        else:
            # Custom interval
            minutes = task.interval_minutes or 60
            return now + timedelta(minutes=minutes)

    def set_delivery_callback(self, callback: Callable):
        """Set callback for delivering task results to channels.

        The callback receives (message: str, channel: str, chat_id: str).
        Typically set by the channel (Telegram, CLI) during startup.
        """
        self._delivery_callback = callback

    def _run_task(self, task: ScheduledTask):
        """Execute a task and deliver results."""
        callback = self.callbacks.get(task.id)
        if not callback:
            logger.warning(f"No callback for task {task.id}")
            return

        try:
            logger.info(f"Running task: {task.name}")
            result = callback(task)

            # Deliver result if there's a message and a delivery callback
            if result and hasattr(self, "_delivery_callback") and self._delivery_callback:
                message = None
                if isinstance(result, dict):
                    message = result.get("message") or result.get("briefing")
                elif isinstance(result, str):
                    message = result

                if message:
                    channel = task.channel or "internal"
                    chat_id = task.chat_id or ""
                    try:
                        self._delivery_callback(message, channel, chat_id)
                    except Exception as e:
                        logger.error(f"Failed to deliver task result: {e}")

            # Update task
            with self._lock:
                task.last_run = datetime.now().isoformat()
                next_run = self._calculate_next_run(task)
                if next_run:
                    task.next_run = next_run.isoformat()
                else:
                    # One-time task, disable it
                    task.enabled = False
                self._save_tasks()

        except Exception as e:
            logger.error(f"Task {task.name} failed: {e}")

    def _scheduler_loop(self):
        """Main scheduler loop."""
        logger.info("Scheduler started")

        while not self._stop_event.is_set():
            now = datetime.now()

            # Check each task
            with self._lock:
                tasks_to_run = []
                for task in self.tasks.values():
                    if not task.enabled:
                        continue

                    next_run = datetime.fromisoformat(task.next_run)
                    if next_run <= now:
                        tasks_to_run.append(task)

            # Run due tasks (outside lock)
            for task in tasks_to_run:
                self._run_task(task)

            # Sleep for a bit
            self._stop_event.wait(30)  # Check every 30 seconds

        logger.info("Scheduler stopped")

    def start(self):
        """Start the scheduler in a background thread."""
        if self._thread and self._thread.is_alive():
            return

        self._stop_event.clear()
        self._thread = threading.Thread(target=self._scheduler_loop, daemon=True)
        self._thread.start()

    def stop(self):
        """Stop the scheduler."""
        self._stop_event.set()
        if self._thread:
            self._thread.join(timeout=5)


# Singleton instance
_scheduler: Optional[Scheduler] = None


def get_scheduler(agent=None) -> Scheduler:
    """
    Get or create the global scheduler.

    Args:
        agent: Optional agent reference for built-in handlers.
               Only used on first call when creating the scheduler.
    """
    global _scheduler
    if _scheduler is None:
        import threading

        if not hasattr(get_scheduler, "_lock"):
            get_scheduler._lock = threading.Lock()
        with get_scheduler._lock:
            if _scheduler is None:
                _scheduler = Scheduler(agent)
    return _scheduler


def reset_scheduler():
    """Reset the scheduler singleton (for testing)."""
    global _scheduler
    if _scheduler is not None:
        _scheduler.stop()
    _scheduler = None
