"""Tests for Scan Scheduler."""

from __future__ import annotations

import json
from datetime import datetime, timedelta, timezone
from pathlib import Path

import pytest

from mcpaudit.scan_scheduler import (
    CronParser,
    ScanHistory,
    ScanHistoryEntry,
    ScanSchedule,
    ScheduleManager,
    format_history_text,
    format_schedules_markdown,
)


# ---------------------------------------------------------------------------
# ScanSchedule
# ---------------------------------------------------------------------------


class TestScanSchedule:
    def test_defaults(self):
        s = ScanSchedule(schedule_id="s1", cron_expr="daily")
        assert s.profile == "default"
        assert s.enabled is True
        assert s.target_paths == []
        assert s.description == ""

    def test_to_dict(self):
        s = ScanSchedule(
            schedule_id="s1", cron_expr="daily",
            target_paths=["/src"], profile="deep",
            enabled=False, description="Nightly scan",
        )
        d = s.to_dict()
        assert d["schedule_id"] == "s1"
        assert d["cron_expr"] == "daily"
        assert d["profile"] == "deep"
        assert d["enabled"] is False
        assert d["target_paths"] == ["/src"]

    def test_from_dict(self):
        data = {
            "schedule_id": "s2",
            "cron_expr": "weekly",
            "target_paths": ["/app"],
            "profile": "ci",
        }
        s = ScanSchedule.from_dict(data)
        assert s.schedule_id == "s2"
        assert s.cron_expr == "weekly"
        assert s.profile == "ci"

    def test_from_dict_defaults(self):
        s = ScanSchedule.from_dict({})
        assert s.schedule_id == ""
        assert s.profile == "default"
        assert s.enabled is True

    def test_roundtrip(self):
        original = ScanSchedule(
            schedule_id="rt", cron_expr="0 0 * * *",
            target_paths=["/a", "/b"], profile="full",
        )
        rebuilt = ScanSchedule.from_dict(original.to_dict())
        assert rebuilt.schedule_id == original.schedule_id
        assert rebuilt.target_paths == original.target_paths


# ---------------------------------------------------------------------------
# ScanHistoryEntry
# ---------------------------------------------------------------------------


class TestScanHistoryEntry:
    def test_defaults(self):
        e = ScanHistoryEntry(schedule_id="s1", run_at="2025-01-01")
        assert e.duration_ms == 0.0
        assert e.findings_count == 0
        assert e.grade == "A+"
        assert e.status == "success"

    def test_to_dict(self):
        e = ScanHistoryEntry(
            schedule_id="s1", run_at="2025-01-01",
            duration_ms=123.456, findings_count=5,
            grade="B", status="success",
        )
        d = e.to_dict()
        assert d["duration_ms"] == 123.46
        assert d["grade"] == "B"

    def test_from_dict(self):
        data = {
            "schedule_id": "x",
            "run_at": "2025-06-01",
            "findings_count": 3,
            "grade": "C",
            "status": "failed",
        }
        e = ScanHistoryEntry.from_dict(data)
        assert e.schedule_id == "x"
        assert e.status == "failed"

    def test_roundtrip(self):
        orig = ScanHistoryEntry(
            schedule_id="rt", run_at="2025-01-01T00:00:00Z",
            duration_ms=50.5, findings_count=2, grade="A",
        )
        rebuilt = ScanHistoryEntry.from_dict(orig.to_dict())
        assert rebuilt.schedule_id == orig.schedule_id
        assert rebuilt.findings_count == orig.findings_count


# ---------------------------------------------------------------------------
# CronParser
# ---------------------------------------------------------------------------


class TestCronParser:
    def test_normalize_daily(self):
        assert CronParser.normalize("daily") == "0 0 * * *"

    def test_normalize_weekly(self):
        assert CronParser.normalize("weekly") == "0 0 * * 1"

    def test_normalize_monthly(self):
        assert CronParser.normalize("monthly") == "0 0 1 * *"

    def test_normalize_hourly(self):
        assert CronParser.normalize("hourly") == "0 * * * *"

    def test_normalize_passthrough(self):
        assert CronParser.normalize("30 2 * * *") == "30 2 * * *"

    def test_normalize_case_insensitive(self):
        assert CronParser.normalize("DAILY") == "0 0 * * *"

    def test_validate_valid(self):
        assert CronParser.validate("0 0 * * *") == []

    def test_validate_preset(self):
        assert CronParser.validate("daily") == []

    def test_validate_wrong_field_count(self):
        errors = CronParser.validate("0 0 *")
        assert len(errors) == 1
        assert "5 fields" in errors[0]

    def test_validate_out_of_range(self):
        errors = CronParser.validate("60 0 * * *")
        assert any("out of range" in e for e in errors)

    def test_validate_invalid_value(self):
        errors = CronParser.validate("abc 0 * * *")
        assert any("not a valid" in e for e in errors)

    def test_is_preset_true(self):
        assert CronParser.is_preset("daily") is True

    def test_is_preset_false(self):
        assert CronParser.is_preset("0 0 * * *") is False

    def test_get_interval_hourly(self):
        iv = CronParser.get_interval("hourly")
        assert iv == timedelta(hours=1)

    def test_get_interval_daily(self):
        iv = CronParser.get_interval("daily")
        assert iv == timedelta(days=1)

    def test_get_interval_unknown(self):
        assert CronParser.get_interval("30 2 * * *") is None

    def test_next_run_hourly(self):
        after = datetime(2025, 1, 1, 10, 30, tzinfo=timezone.utc)
        result = CronParser.next_run("hourly", after)
        assert result is not None
        assert result.hour == 11
        assert result.minute == 0

    def test_next_run_daily(self):
        after = datetime(2025, 1, 1, 10, 0, tzinfo=timezone.utc)
        result = CronParser.next_run("daily", after)
        assert result is not None
        assert result.day == 2
        assert result.hour == 0

    def test_next_run_weekly(self):
        # 2025-01-01 is Wednesday (weekday=2)
        after = datetime(2025, 1, 1, 10, 0, tzinfo=timezone.utc)
        result = CronParser.next_run("weekly", after)
        assert result is not None
        assert result.weekday() == 0  # Monday

    def test_next_run_monthly(self):
        after = datetime(2025, 6, 15, 10, 0, tzinfo=timezone.utc)
        result = CronParser.next_run("monthly", after)
        assert result is not None
        assert result.month == 7
        assert result.day == 1

    def test_next_run_monthly_december(self):
        after = datetime(2025, 12, 15, 10, 0, tzinfo=timezone.utc)
        result = CronParser.next_run("monthly", after)
        assert result is not None
        assert result.year == 2026
        assert result.month == 1

    def test_next_run_unknown(self):
        after = datetime(2025, 1, 1, tzinfo=timezone.utc)
        assert CronParser.next_run("30 2 * * *", after) is None


# ---------------------------------------------------------------------------
# ScheduleManager
# ---------------------------------------------------------------------------


class TestScheduleManager:
    def test_add_valid(self):
        mgr = ScheduleManager()
        s = ScanSchedule(schedule_id="s1", cron_expr="daily")
        errors = mgr.add(s)
        assert errors == []
        assert mgr.get("s1") is not None

    def test_add_sets_created_at(self):
        mgr = ScheduleManager()
        s = ScanSchedule(schedule_id="s1", cron_expr="daily")
        mgr.add(s)
        assert s.created_at != ""

    def test_add_missing_id(self):
        mgr = ScheduleManager()
        s = ScanSchedule(schedule_id="", cron_expr="daily")
        errors = mgr.add(s)
        assert any("schedule_id" in e for e in errors)

    def test_add_missing_cron(self):
        mgr = ScheduleManager()
        s = ScanSchedule(schedule_id="s1", cron_expr="")
        errors = mgr.add(s)
        assert any("cron_expr" in e for e in errors)

    def test_add_invalid_profile(self):
        mgr = ScheduleManager()
        s = ScanSchedule(
            schedule_id="s1", cron_expr="daily",
            profile="unknown",
        )
        errors = mgr.add(s)
        assert any("Invalid profile" in e for e in errors)

    def test_remove(self):
        mgr = ScheduleManager()
        s = ScanSchedule(schedule_id="s1", cron_expr="daily")
        mgr.add(s)
        assert mgr.remove("s1") is True
        assert mgr.get("s1") is None

    def test_remove_nonexistent(self):
        mgr = ScheduleManager()
        assert mgr.remove("nope") is False

    def test_list_schedules(self):
        mgr = ScheduleManager()
        mgr.add(ScanSchedule(schedule_id="a", cron_expr="daily"))
        mgr.add(ScanSchedule(
            schedule_id="b", cron_expr="weekly", enabled=False,
        ))
        assert len(mgr.list_schedules()) == 2
        assert len(mgr.list_schedules(enabled_only=True)) == 1

    def test_get_due_scans_no_history(self):
        mgr = ScheduleManager()
        mgr.add(ScanSchedule(schedule_id="s1", cron_expr="daily"))
        now = datetime(2025, 1, 1, tzinfo=timezone.utc)
        due = mgr.get_due_scans(now)
        assert len(due) == 1

    def test_get_due_scans_disabled(self):
        mgr = ScheduleManager()
        mgr.add(ScanSchedule(
            schedule_id="s1", cron_expr="daily", enabled=False,
        ))
        now = datetime(2025, 1, 1, tzinfo=timezone.utc)
        due = mgr.get_due_scans(now)
        assert len(due) == 0

    def test_get_due_scans_with_history(self):
        mgr = ScheduleManager()
        mgr.add(ScanSchedule(schedule_id="s1", cron_expr="daily"))
        history = ScanHistory()
        history.add(ScanHistoryEntry(
            schedule_id="s1",
            run_at="2025-01-01T00:00:00Z",
        ))
        # 2 hours after last run: daily not due yet
        now = datetime(2025, 1, 1, 2, 0, tzinfo=timezone.utc)
        due = mgr.get_due_scans(now, history)
        assert len(due) == 0

    def test_get_due_scans_past_due(self):
        mgr = ScheduleManager()
        mgr.add(ScanSchedule(schedule_id="s1", cron_expr="daily"))
        history = ScanHistory()
        history.add(ScanHistoryEntry(
            schedule_id="s1",
            run_at="2025-01-01T00:00:00Z",
        ))
        # 2 days after last run: daily is due
        now = datetime(2025, 1, 3, 0, 0, tzinfo=timezone.utc)
        due = mgr.get_due_scans(now, history)
        assert len(due) == 1

    def test_save_and_load(self, tmp_path):
        store = tmp_path / "schedules.json"
        mgr = ScheduleManager(store_path=store)
        mgr.add(ScanSchedule(
            schedule_id="s1", cron_expr="daily",
            target_paths=["/app"],
        ))
        mgr.save()

        mgr2 = ScheduleManager(store_path=store)
        errors = mgr2.load()
        assert errors == []
        assert mgr2.get("s1") is not None
        assert mgr2.get("s1").target_paths == ["/app"]

    def test_save_no_path(self):
        mgr = ScheduleManager()
        with pytest.raises(ValueError):
            mgr.save()

    def test_load_no_path(self):
        mgr = ScheduleManager()
        errors = mgr.load()
        assert any("No store path" in e for e in errors)

    def test_load_missing_file(self, tmp_path):
        mgr = ScheduleManager(store_path=tmp_path / "missing.json")
        errors = mgr.load()
        assert any("not found" in e for e in errors)

    def test_schedules_property(self):
        mgr = ScheduleManager()
        mgr.add(ScanSchedule(schedule_id="s1", cron_expr="daily"))
        schedules = mgr.schedules
        assert "s1" in schedules


# ---------------------------------------------------------------------------
# ScanHistory
# ---------------------------------------------------------------------------


class TestScanHistory:
    def test_add_and_entries(self):
        h = ScanHistory()
        h.add(ScanHistoryEntry(schedule_id="s1", run_at="2025-01-01"))
        assert len(h.entries) == 1

    def test_last_run(self):
        h = ScanHistory()
        h.add(ScanHistoryEntry(schedule_id="s1", run_at="2025-01-01"))
        h.add(ScanHistoryEntry(schedule_id="s1", run_at="2025-01-02"))
        last = h.last_run("s1")
        assert last is not None
        assert last.run_at == "2025-01-02"

    def test_last_run_none(self):
        h = ScanHistory()
        assert h.last_run("nope") is None

    def test_runs_for(self):
        h = ScanHistory()
        h.add(ScanHistoryEntry(schedule_id="s1", run_at="2025-01-01"))
        h.add(ScanHistoryEntry(schedule_id="s2", run_at="2025-01-02"))
        h.add(ScanHistoryEntry(schedule_id="s1", run_at="2025-01-03"))
        assert len(h.runs_for("s1")) == 2
        assert len(h.runs_for("s2")) == 1

    def test_total_runs(self):
        h = ScanHistory()
        assert h.total_runs() == 0
        h.add(ScanHistoryEntry(schedule_id="s1", run_at="2025-01-01"))
        assert h.total_runs() == 1

    def test_clear(self):
        h = ScanHistory()
        h.add(ScanHistoryEntry(schedule_id="s1", run_at="2025-01-01"))
        h.clear()
        assert h.total_runs() == 0

    def test_to_dict(self):
        h = ScanHistory()
        h.add(ScanHistoryEntry(schedule_id="s1", run_at="2025-01-01"))
        d = h.to_dict()
        assert d["total_runs"] == 1
        assert len(d["entries"]) == 1

    def test_load_from_dict(self):
        h = ScanHistory()
        h.load_from_dict({
            "entries": [
                {"schedule_id": "s1", "run_at": "2025-01-01"},
            ]
        })
        assert h.total_runs() == 1


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


class TestFormatSchedulesMarkdown:
    def test_empty(self):
        result = format_schedules_markdown([])
        assert "No schedules" in result

    def test_table_format(self):
        schedules = [
            ScanSchedule(
                schedule_id="s1", cron_expr="daily",
                profile="deep", target_paths=["/src"],
            ),
        ]
        result = format_schedules_markdown(schedules)
        assert "| s1 |" in result
        assert "deep" in result
        assert "/src" in result

    def test_disabled_schedule(self):
        schedules = [
            ScanSchedule(
                schedule_id="s1", cron_expr="daily",
                enabled=False,
            ),
        ]
        result = format_schedules_markdown(schedules)
        assert "No" in result


class TestFormatHistoryText:
    def test_empty(self):
        h = ScanHistory()
        result = format_history_text(h)
        assert "No scan history" in result

    def test_with_entries(self):
        h = ScanHistory()
        h.add(ScanHistoryEntry(
            schedule_id="s1", run_at="2025-01-01",
            findings_count=3, grade="B",
        ))
        result = format_history_text(h)
        assert "s1" in result
        assert "3 findings" in result
        assert "grade B" in result

    def test_failed_status(self):
        h = ScanHistory()
        h.add(ScanHistoryEntry(
            schedule_id="s1", run_at="2025-01-01",
            status="failed",
        ))
        result = format_history_text(h)
        assert "FAIL" in result
