"""Scan Scheduler for MCP Security Audit.

Manages scheduled security scans with:
- Cron-like scheduling (daily, weekly, monthly, hourly)
- Multiple scan profiles per schedule
- Persistent schedule storage (JSON)
- Scan history tracking with result summaries
- Next-run computation and due-scan detection
- Markdown table export of schedules

CLI: mcpaudit schedule list | add | remove | due | history

Zero external dependencies. Part of mcpaudit v2.1.0.
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

_CRON_PRESETS: dict[str, str] = {
    "hourly": "0 * * * *",
    "daily": "0 0 * * *",
    "weekly": "0 0 * * 1",
    "monthly": "0 0 1 * *",
}

_VALID_PROFILES = frozenset({
    "default", "quick", "deep", "ci", "full", "custom",
})


# ---------------------------------------------------------------------------
# Data models
# ---------------------------------------------------------------------------

@dataclass
class ScanSchedule:
    """A scheduled scan configuration."""

    schedule_id: str
    cron_expr: str
    target_paths: list[str] = field(default_factory=list)
    profile: str = "default"
    enabled: bool = True
    description: str = ""
    created_at: str = ""

    def to_dict(self) -> dict[str, Any]:
        return {
            "schedule_id": self.schedule_id,
            "cron_expr": self.cron_expr,
            "target_paths": list(self.target_paths),
            "profile": self.profile,
            "enabled": self.enabled,
            "description": self.description,
            "created_at": self.created_at,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> ScanSchedule:
        return cls(
            schedule_id=data.get("schedule_id", ""),
            cron_expr=data.get("cron_expr", ""),
            target_paths=list(data.get("target_paths", [])),
            profile=data.get("profile", "default"),
            enabled=data.get("enabled", True),
            description=data.get("description", ""),
            created_at=data.get("created_at", ""),
        )


@dataclass
class ScanHistoryEntry:
    """Record of a completed scan."""

    schedule_id: str
    run_at: str
    duration_ms: float = 0.0
    findings_count: int = 0
    grade: str = "A+"
    status: str = "success"
    summary: str = ""

    def to_dict(self) -> dict[str, Any]:
        return {
            "schedule_id": self.schedule_id,
            "run_at": self.run_at,
            "duration_ms": round(self.duration_ms, 2),
            "findings_count": self.findings_count,
            "grade": self.grade,
            "status": self.status,
            "summary": self.summary,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> ScanHistoryEntry:
        return cls(
            schedule_id=data.get("schedule_id", ""),
            run_at=data.get("run_at", ""),
            duration_ms=data.get("duration_ms", 0.0),
            findings_count=data.get("findings_count", 0),
            grade=data.get("grade", "A+"),
            status=data.get("status", "success"),
            summary=data.get("summary", ""),
        )


# ---------------------------------------------------------------------------
# Cron parser (simplified)
# ---------------------------------------------------------------------------

class CronParser:
    """Parse and evaluate simplified cron expressions.

    Supports: minute hour day_of_month month day_of_week
    Special values: * (any), presets (hourly/daily/weekly/monthly)
    """

    @staticmethod
    def normalize(expr: str) -> str:
        """Normalize a cron expression, expanding presets."""
        lower = expr.strip().lower()
        if lower in _CRON_PRESETS:
            return _CRON_PRESETS[lower]
        return expr.strip()

    @staticmethod
    def validate(expr: str) -> list[str]:
        """Validate a cron expression.

        Returns:
            List of error strings (empty = valid).
        """
        errors: list[str] = []
        normalized = CronParser.normalize(expr)
        parts = normalized.split()

        if len(parts) != 5:
            errors.append(
                f"Expected 5 fields, got {len(parts)}: '{normalized}'"
            )
            return errors

        labels = ["minute", "hour", "day_of_month", "month", "day_of_week"]
        ranges = [(0, 59), (0, 23), (1, 31), (1, 12), (0, 6)]

        for i, (part, label, (lo, hi)) in enumerate(
            zip(parts, labels, ranges)
        ):
            if part == "*":
                continue
            try:
                val = int(part)
                if val < lo or val > hi:
                    errors.append(
                        f"{label}: {val} out of range [{lo}-{hi}]"
                    )
            except ValueError:
                errors.append(
                    f"{label}: '{part}' is not a valid value"
                )

        return errors

    @staticmethod
    def is_preset(expr: str) -> bool:
        """Check if expression is a known preset."""
        return expr.strip().lower() in _CRON_PRESETS

    @staticmethod
    def get_interval(expr: str) -> timedelta | None:
        """Get approximate interval for simple cron expressions.

        Returns None for complex expressions.
        """
        normalized = CronParser.normalize(expr)

        intervals: dict[str, timedelta] = {
            "0 * * * *": timedelta(hours=1),
            "0 0 * * *": timedelta(days=1),
            "0 0 * * 1": timedelta(weeks=1),
            "0 0 1 * *": timedelta(days=30),
        }
        return intervals.get(normalized)

    @staticmethod
    def next_run(expr: str, after: datetime) -> datetime | None:
        """Compute the next run time after a given datetime.

        Uses simplified logic for common presets.
        Returns None if the expression is not a recognized preset.
        """
        interval = CronParser.get_interval(expr)
        if interval is None:
            return None

        # Align to the start of the relevant period
        base = after.replace(second=0, microsecond=0)

        normalized = CronParser.normalize(expr)
        if normalized == "0 * * * *":
            # Next hour at :00
            candidate = base.replace(minute=0) + timedelta(hours=1)
        elif normalized == "0 0 * * *":
            # Next midnight
            candidate = base.replace(
                hour=0, minute=0
            ) + timedelta(days=1)
        elif normalized == "0 0 * * 1":
            # Next Monday at midnight
            days_ahead = (7 - after.weekday()) % 7
            if days_ahead == 0:
                days_ahead = 7
            candidate = base.replace(
                hour=0, minute=0
            ) + timedelta(days=days_ahead)
        elif normalized == "0 0 1 * *":
            # First of next month
            if after.month == 12:
                candidate = after.replace(
                    year=after.year + 1, month=1, day=1,
                    hour=0, minute=0, second=0, microsecond=0,
                )
            else:
                candidate = after.replace(
                    month=after.month + 1, day=1,
                    hour=0, minute=0, second=0, microsecond=0,
                )
        else:
            return None

        return candidate


# ---------------------------------------------------------------------------
# Schedule manager
# ---------------------------------------------------------------------------

class ScheduleManager:
    """Manage scan schedules with JSON persistence."""

    def __init__(self, store_path: str | Path | None = None):
        self._schedules: dict[str, ScanSchedule] = {}
        self._store_path = Path(store_path) if store_path else None

    @property
    def schedules(self) -> dict[str, ScanSchedule]:
        return dict(self._schedules)

    def add(self, schedule: ScanSchedule) -> list[str]:
        """Add a schedule. Returns list of errors (empty = success)."""
        errors = self._validate_schedule(schedule)
        if errors:
            return errors

        if not schedule.created_at:
            schedule.created_at = datetime.now(
                timezone.utc
            ).strftime("%Y-%m-%dT%H:%M:%SZ")

        self._schedules[schedule.schedule_id] = schedule
        return []

    def remove(self, schedule_id: str) -> bool:
        """Remove a schedule by ID. Returns True if found and removed."""
        if schedule_id in self._schedules:
            del self._schedules[schedule_id]
            return True
        return False

    def get(self, schedule_id: str) -> ScanSchedule | None:
        """Get a schedule by ID."""
        return self._schedules.get(schedule_id)

    def list_schedules(
        self, enabled_only: bool = False,
    ) -> list[ScanSchedule]:
        """List all schedules, optionally filtered."""
        schedules = list(self._schedules.values())
        if enabled_only:
            schedules = [s for s in schedules if s.enabled]
        return schedules

    def get_due_scans(
        self,
        now: datetime,
        history: ScanHistory | None = None,
    ) -> list[ScanSchedule]:
        """Get schedules that are due for a scan.

        A schedule is due if:
        - It is enabled
        - It has no history, OR its next run time is <= now
        """
        due: list[ScanSchedule] = []
        for schedule in self._schedules.values():
            if not schedule.enabled:
                continue

            last_run = None
            if history:
                last_entry = history.last_run(schedule.schedule_id)
                if last_entry:
                    try:
                        last_run = datetime.fromisoformat(
                            last_entry.run_at.replace("Z", "+00:00")
                        )
                    except (ValueError, AttributeError):
                        pass

            if last_run is None:
                due.append(schedule)
                continue

            next_time = CronParser.next_run(
                schedule.cron_expr, last_run,
            )
            if next_time is None or next_time <= now:
                due.append(schedule)

        return due

    def save(self, path: str | Path | None = None) -> None:
        """Save schedules to JSON file."""
        target = Path(path) if path else self._store_path
        if target is None:
            raise ValueError("No store path configured")

        data = {
            "schedules": {
                sid: s.to_dict()
                for sid, s in self._schedules.items()
            }
        }
        target.write_text(
            json.dumps(data, indent=2, ensure_ascii=False) + "\n",
            encoding="utf-8",
        )

    def load(self, path: str | Path | None = None) -> list[str]:
        """Load schedules from JSON file. Returns errors."""
        target = Path(path) if path else self._store_path
        if target is None:
            return ["No store path configured"]

        if not target.exists():
            return [f"File not found: {target}"]

        try:
            text = target.read_text(encoding="utf-8")
            data = json.loads(text)
        except (json.JSONDecodeError, OSError) as exc:
            return [f"Load error: {exc}"]

        schedules_data = data.get("schedules", {})
        if not isinstance(schedules_data, dict):
            return ["'schedules' must be an object"]

        self._schedules.clear()
        for sid, sdata in schedules_data.items():
            if isinstance(sdata, dict):
                schedule = ScanSchedule.from_dict(sdata)
                if not schedule.schedule_id:
                    schedule.schedule_id = sid
                self._schedules[schedule.schedule_id] = schedule

        return []

    def _validate_schedule(
        self, schedule: ScanSchedule,
    ) -> list[str]:
        """Validate a schedule before adding."""
        errors: list[str] = []
        if not schedule.schedule_id:
            errors.append("schedule_id is required")
        if not schedule.cron_expr:
            errors.append("cron_expr is required")
        else:
            cron_errors = CronParser.validate(schedule.cron_expr)
            errors.extend(cron_errors)
        if schedule.profile not in _VALID_PROFILES:
            errors.append(
                f"Invalid profile: '{schedule.profile}'. "
                f"Valid: {', '.join(sorted(_VALID_PROFILES))}"
            )
        return errors


# ---------------------------------------------------------------------------
# Scan history
# ---------------------------------------------------------------------------

class ScanHistory:
    """Track scan execution history."""

    def __init__(self) -> None:
        self._entries: list[ScanHistoryEntry] = []

    @property
    def entries(self) -> list[ScanHistoryEntry]:
        return list(self._entries)

    def add(self, entry: ScanHistoryEntry) -> None:
        """Record a scan execution."""
        self._entries.append(entry)

    def last_run(
        self, schedule_id: str,
    ) -> ScanHistoryEntry | None:
        """Get the most recent run for a schedule."""
        matching = [
            e for e in self._entries
            if e.schedule_id == schedule_id
        ]
        if not matching:
            return None
        return matching[-1]

    def runs_for(
        self, schedule_id: str,
    ) -> list[ScanHistoryEntry]:
        """Get all runs for a schedule."""
        return [
            e for e in self._entries
            if e.schedule_id == schedule_id
        ]

    def total_runs(self) -> int:
        """Total number of recorded runs."""
        return len(self._entries)

    def clear(self) -> None:
        """Clear all history."""
        self._entries.clear()

    def to_dict(self) -> dict[str, Any]:
        return {
            "entries": [e.to_dict() for e in self._entries],
            "total_runs": self.total_runs(),
        }

    def load_from_dict(self, data: dict[str, Any]) -> None:
        """Load history from dict."""
        self._entries.clear()
        for entry_data in data.get("entries", []):
            if isinstance(entry_data, dict):
                self._entries.append(
                    ScanHistoryEntry.from_dict(entry_data)
                )


# ---------------------------------------------------------------------------
# Formatting
# ---------------------------------------------------------------------------

def format_schedules_markdown(
    schedules: list[ScanSchedule],
) -> str:
    """Export schedules as a Markdown table.

    Args:
        schedules: List of schedules.

    Returns:
        Markdown string with table.
    """
    if not schedules:
        return "No schedules configured.\n"

    lines = [
        "| ID | Cron | Profile | Enabled | Paths |",
        "|---|---|---|---|---|",
    ]
    for s in schedules:
        paths = ", ".join(s.target_paths) if s.target_paths else "-"
        enabled = "Yes" if s.enabled else "No"
        lines.append(
            f"| {s.schedule_id} | `{s.cron_expr}` "
            f"| {s.profile} | {enabled} | {paths} |"
        )
    return "\n".join(lines) + "\n"


def format_history_text(history: ScanHistory) -> str:
    """Format scan history as text.

    Args:
        history: Scan history.

    Returns:
        Text summary.
    """
    if not history.entries:
        return "No scan history.\n"

    lines = [f"Scan History ({history.total_runs()} runs)"]
    lines.append("=" * 40)
    for entry in history.entries:
        status_icon = "OK" if entry.status == "success" else "FAIL"
        lines.append(
            f"  [{status_icon}] {entry.schedule_id} "
            f"@ {entry.run_at} — "
            f"{entry.findings_count} findings, "
            f"grade {entry.grade}"
        )
    return "\n".join(lines) + "\n"
