"""Tests for Exemption Manager."""

from __future__ import annotations

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

import pytest

from mcpaudit.exemptions import (
    AuditEvent,
    DEFAULT_EXEMPTIONS_FILE,
    Exemption,
    ExemptionStore,
    FilteredFindings,
    MAX_EXEMPTION_DAYS,
    VALID_STATUSES,
    apply_exemptions,
    create_exemption,
    expire_exemptions,
    export_exemptions,
    format_audit_trail_text,
    format_exemptions_json,
    format_exemptions_text,
    format_filtered_text,
    get_audit_trail,
    import_exemptions,
    load_exemptions,
    revoke_exemption,
    save_exemptions,
    update_exemption,
    _generate_id,
    _is_expired,
    _matches_scope,
    _parse_iso,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _future_date(days: int = 30) -> str:
    dt = datetime.now(timezone.utc) + timedelta(days=days)
    return dt.strftime("%Y-%m-%dT%H:%M:%SZ")


def _past_date(days: int = 30) -> str:
    dt = datetime.now(timezone.utc) - timedelta(days=days)
    return dt.strftime("%Y-%m-%dT%H:%M:%SZ")


def _sample_findings() -> list[dict[str, Any]]:
    return [
        {"rule_id": "CMD-001", "file": "server.py", "line": 10,
         "severity": "HIGH", "title": "Shell injection"},
        {"rule_id": "AUTH-001", "file": "server.py", "line": 20,
         "severity": "MEDIUM", "title": "Missing auth"},
        {"rule_id": "CMD-001", "file": "api.py", "line": 5,
         "severity": "HIGH", "title": "Shell injection in API"},
        {"rule_id": "RATE-001", "file": "api.py", "line": 30,
         "severity": "LOW", "title": "No rate limit"},
    ]


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

class TestConstants:
    def test_valid_statuses(self):
        expected = {"active", "expired", "revoked", "pending"}
        assert VALID_STATUSES == expected

    def test_default_file(self):
        assert ".mcpaudit-exemptions.json" in DEFAULT_EXEMPTIONS_FILE

    def test_max_days(self):
        assert MAX_EXEMPTION_DAYS == 365


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

class TestParseIso:
    def test_basic(self):
        dt = _parse_iso("2024-01-15T10:00:00Z")
        assert dt.year == 2024
        assert dt.month == 1

    def test_with_offset(self):
        dt = _parse_iso("2024-01-15T10:00:00+00:00")
        assert dt.year == 2024


class TestIsExpired:
    def test_future(self):
        assert not _is_expired(_future_date(30))

    def test_past(self):
        assert _is_expired(_past_date(30))

    def test_invalid(self):
        assert not _is_expired("not-a-date")


class TestGenerateId:
    def test_deterministic(self):
        id1 = _generate_id("CMD-001", "2024-01-01T00:00:00Z")
        id2 = _generate_id("CMD-001", "2024-01-01T00:00:00Z")
        assert id1 == id2

    def test_starts_with_ex(self):
        eid = _generate_id("CMD-001", "2024-01-01T00:00:00Z")
        assert eid.startswith("EX-")

    def test_different_inputs(self):
        id1 = _generate_id("CMD-001", "2024-01-01T00:00:00Z")
        id2 = _generate_id("CMD-002", "2024-01-01T00:00:00Z")
        assert id1 != id2


class TestMatchesScope:
    def test_no_scope(self):
        assert _matches_scope("", "anything.py") is True

    def test_exact_match(self):
        assert _matches_scope("server.py", "server.py") is True

    def test_glob(self):
        assert _matches_scope("*.py", "server.py") is True

    def test_no_match(self):
        assert _matches_scope("*.ts", "server.py") is False

    def test_basename_match(self):
        assert _matches_scope("server.py", "/path/to/server.py") is True


# ---------------------------------------------------------------------------
# Exemption
# ---------------------------------------------------------------------------

class TestExemption:
    def test_basic(self):
        ex = Exemption(
            finding_rule="CMD-001",
            reason="Mitigated by WAF",
            approved_by="security-team",
        )
        assert ex.finding_rule == "CMD-001"
        assert ex.reason == "Mitigated by WAF"
        assert ex.approved_by == "security-team"
        assert ex.status == "active"
        assert ex.exemption_id.startswith("EX-")
        assert ex.created_at

    def test_is_active(self):
        ex = Exemption(
            finding_rule="CMD-001",
            reason="Test",
            approved_by="admin",
            status="active",
        )
        assert ex.is_active is True

    def test_not_active_revoked(self):
        ex = Exemption(
            finding_rule="CMD-001",
            reason="Test",
            approved_by="admin",
            status="revoked",
        )
        assert ex.is_active is False

    def test_not_active_expired_status(self):
        ex = Exemption(
            finding_rule="CMD-001",
            reason="Test",
            approved_by="admin",
            status="expired",
        )
        assert ex.is_active is False

    def test_not_active_pending(self):
        ex = Exemption(
            finding_rule="CMD-001",
            reason="Test",
            approved_by="admin",
            status="pending",
        )
        assert ex.is_active is False

    def test_active_with_future_expiry(self):
        ex = Exemption(
            finding_rule="CMD-001",
            reason="Test",
            approved_by="admin",
            expires_at=_future_date(30),
        )
        assert ex.is_active is True

    def test_not_active_past_expiry(self):
        ex = Exemption(
            finding_rule="CMD-001",
            reason="Test",
            approved_by="admin",
            expires_at=_past_date(30),
        )
        assert ex.is_active is False

    def test_validate_valid(self):
        ex = Exemption(
            finding_rule="CMD-001",
            reason="Mitigated",
            approved_by="admin",
        )
        assert ex.validate() == []

    def test_validate_missing_rule(self):
        ex = Exemption(reason="R", approved_by="A")
        errors = ex.validate()
        assert any("finding_rule" in e for e in errors)

    def test_validate_missing_reason(self):
        ex = Exemption(finding_rule="CMD-001", approved_by="A")
        errors = ex.validate()
        assert any("reason" in e for e in errors)

    def test_validate_missing_approved_by(self):
        ex = Exemption(finding_rule="CMD-001", reason="R")
        errors = ex.validate()
        assert any("approved_by" in e for e in errors)

    def test_validate_invalid_status(self):
        ex = Exemption(
            finding_rule="CMD-001",
            reason="R",
            approved_by="A",
            status="invalid",
        )
        errors = ex.validate()
        assert any("Invalid status" in e for e in errors)

    def test_validate_invalid_expires(self):
        ex = Exemption(
            finding_rule="CMD-001",
            reason="R",
            approved_by="A",
            expires_at="not-a-date",
        )
        errors = ex.validate()
        assert any("expires_at" in e for e in errors)

    def test_to_dict(self):
        ex = Exemption(
            finding_rule="CMD-001",
            reason="Test",
            approved_by="admin",
            scope="*.py",
        )
        d = ex.to_dict()
        assert d["finding_rule"] == "CMD-001"
        assert d["scope"] == "*.py"

    def test_from_dict(self):
        data = {
            "exemption_id": "EX-123",
            "finding_rule": "CMD-001",
            "reason": "Test",
            "approved_by": "admin",
            "status": "active",
            "conditions": ["reviewed"],
        }
        ex = Exemption.from_dict(data)
        assert ex.exemption_id == "EX-123"
        assert ex.conditions == ["reviewed"]

    def test_roundtrip(self):
        ex = Exemption(
            finding_rule="CMD-001",
            reason="Test",
            approved_by="admin",
            conditions=["monthly-review"],
            scope="server.py",
        )
        d = ex.to_dict()
        ex2 = Exemption.from_dict(d)
        assert ex2.finding_rule == ex.finding_rule
        assert ex2.conditions == ex.conditions
        assert ex2.scope == ex.scope


# ---------------------------------------------------------------------------
# AuditEvent
# ---------------------------------------------------------------------------

class TestAuditEvent:
    def test_basic(self):
        e = AuditEvent(action="created", exemption_id="EX-1")
        assert e.action == "created"
        assert e.timestamp

    def test_to_dict(self):
        e = AuditEvent(
            action="revoked",
            exemption_id="EX-1",
            actor="admin",
        )
        d = e.to_dict()
        assert d["action"] == "revoked"
        assert d["actor"] == "admin"

    def test_from_dict(self):
        data = {"action": "expired", "exemption_id": "EX-1", "actor": "system"}
        e = AuditEvent.from_dict(data)
        assert e.action == "expired"
        assert e.actor == "system"


# ---------------------------------------------------------------------------
# ExemptionStore
# ---------------------------------------------------------------------------

class TestExemptionStore:
    def test_empty(self):
        store = ExemptionStore()
        assert store.count == 0
        assert store.active_count == 0

    def test_add_exemption(self):
        store = ExemptionStore()
        ex = Exemption(
            finding_rule="CMD-001",
            reason="Test",
            approved_by="admin",
        )
        store.exemptions[ex.exemption_id] = ex
        assert store.count == 1
        assert store.active_count == 1

    def test_get(self):
        store = ExemptionStore()
        ex = Exemption(finding_rule="CMD-001", reason="T", approved_by="A")
        store.exemptions[ex.exemption_id] = ex
        assert store.get(ex.exemption_id) is not None
        assert store.get("nonexistent") is None

    def test_get_by_rule(self):
        store = ExemptionStore()
        ex = Exemption(finding_rule="CMD-001", reason="T", approved_by="A")
        store.exemptions[ex.exemption_id] = ex
        results = store.get_by_rule("CMD-001")
        assert len(results) == 1

    def test_get_active(self):
        store = ExemptionStore()
        active = Exemption(finding_rule="CMD-001", reason="T", approved_by="A")
        revoked = Exemption(
            finding_rule="AUTH-001", reason="T", approved_by="A",
            status="revoked",
        )
        store.exemptions[active.exemption_id] = active
        store.exemptions[revoked.exemption_id] = revoked
        assert len(store.get_active()) == 1

    def test_get_expired(self):
        store = ExemptionStore()
        ex = Exemption(
            finding_rule="CMD-001", reason="T", approved_by="A",
            expires_at=_past_date(1),
        )
        store.exemptions[ex.exemption_id] = ex
        assert len(store.get_expired()) == 1

    def test_list_all(self):
        store = ExemptionStore()
        for i in range(3):
            ex = Exemption(finding_rule=f"R-{i}", reason="T", approved_by="A")
            store.exemptions[ex.exemption_id] = ex
        assert len(store.list_all()) == 3

    def test_stats(self):
        store = ExemptionStore()
        active = Exemption(finding_rule="CMD-001", reason="T", approved_by="A")
        revoked = Exemption(
            finding_rule="AUTH-001", reason="T", approved_by="A",
            status="revoked",
        )
        store.exemptions[active.exemption_id] = active
        store.exemptions[revoked.exemption_id] = revoked
        stats = store.stats()
        assert stats.get("active", 0) == 1
        assert stats.get("revoked", 0) == 1

    def test_to_dict(self):
        store = ExemptionStore()
        ex = Exemption(finding_rule="CMD-001", reason="T", approved_by="A")
        store.exemptions[ex.exemption_id] = ex
        store.audit_trail.append(AuditEvent(action="created"))
        d = store.to_dict()
        assert "exemptions" in d
        assert "audit_trail" in d
        assert len(d["audit_trail"]) == 1

    def test_from_dict(self):
        data = {
            "exemptions": {
                "EX-1": {
                    "exemption_id": "EX-1",
                    "finding_rule": "CMD-001",
                    "reason": "T",
                    "approved_by": "A",
                },
            },
            "audit_trail": [
                {"action": "created", "exemption_id": "EX-1"},
            ],
        }
        store = ExemptionStore.from_dict(data)
        assert store.count == 1
        assert len(store.audit_trail) == 1


# ---------------------------------------------------------------------------
# FilteredFindings
# ---------------------------------------------------------------------------

class TestFilteredFindings:
    def test_basic(self):
        ff = FilteredFindings(
            included=[{"rule_id": "A"}],
            exempted=[{"rule_id": "B"}],
        )
        assert ff.total_original == 2
        assert ff.total_exempted == 1
        assert ff.total_remaining == 1

    def test_empty(self):
        ff = FilteredFindings()
        assert ff.total_original == 0

    def test_to_dict(self):
        ff = FilteredFindings(
            included=[{"rule_id": "A"}],
            exempted=[{"rule_id": "B"}],
        )
        d = ff.to_dict()
        assert d["total_original"] == 2


# ---------------------------------------------------------------------------
# create_exemption
# ---------------------------------------------------------------------------

class TestCreateExemption:
    def test_basic(self):
        ex, errors = create_exemption("CMD-001", "Mitigated", "admin")
        assert errors == []
        assert ex.finding_rule == "CMD-001"
        assert ex.is_active is True

    def test_with_expiry(self):
        ex, errors = create_exemption(
            "CMD-001", "Temp", "admin",
            expires_at=_future_date(30),
        )
        assert errors == []
        assert ex.expires_at

    def test_with_conditions(self):
        ex, errors = create_exemption(
            "CMD-001", "Test", "admin",
            conditions=["monthly-review", "waf-active"],
        )
        assert errors == []
        assert len(ex.conditions) == 2

    def test_with_scope(self):
        ex, errors = create_exemption(
            "CMD-001", "Test", "admin", scope="server.py",
        )
        assert errors == []
        assert ex.scope == "server.py"

    def test_validation_errors(self):
        ex, errors = create_exemption("", "", "")
        assert len(errors) > 0

    def test_add_to_store(self):
        store = ExemptionStore()
        ex, errors = create_exemption(
            "CMD-001", "Test", "admin", store=store,
        )
        assert errors == []
        assert store.count == 1
        assert len(store.audit_trail) == 1


# ---------------------------------------------------------------------------
# revoke_exemption
# ---------------------------------------------------------------------------

class TestRevokeExemption:
    def test_basic(self):
        store = ExemptionStore()
        ex, _ = create_exemption("CMD-001", "Test", "admin", store=store)
        success, msg = revoke_exemption(ex.exemption_id, "security", store)
        assert success is True
        assert ex.status == "revoked"
        assert ex.revoked_by == "security"
        assert len(store.audit_trail) == 2  # created + revoked

    def test_not_found(self):
        store = ExemptionStore()
        success, msg = revoke_exemption("EX-missing", "admin", store)
        assert success is False
        assert "not found" in msg

    def test_already_revoked(self):
        store = ExemptionStore()
        ex, _ = create_exemption("CMD-001", "Test", "admin", store=store)
        revoke_exemption(ex.exemption_id, "admin", store)
        success, msg = revoke_exemption(ex.exemption_id, "admin", store)
        assert success is False
        assert "already revoked" in msg


# ---------------------------------------------------------------------------
# expire_exemptions
# ---------------------------------------------------------------------------

class TestExpireExemptions:
    def test_expires_past(self):
        store = ExemptionStore()
        ex, _ = create_exemption(
            "CMD-001", "Test", "admin",
            expires_at=_past_date(1),
            store=store,
        )
        expired = expire_exemptions(store)
        assert ex.exemption_id in expired
        assert ex.status == "expired"

    def test_does_not_expire_future(self):
        store = ExemptionStore()
        ex, _ = create_exemption(
            "CMD-001", "Test", "admin",
            expires_at=_future_date(30),
            store=store,
        )
        expired = expire_exemptions(store)
        assert expired == []
        assert ex.status == "active"

    def test_does_not_expire_revoked(self):
        store = ExemptionStore()
        ex, _ = create_exemption(
            "CMD-001", "Test", "admin",
            expires_at=_past_date(1),
            store=store,
        )
        ex.status = "revoked"
        expired = expire_exemptions(store)
        assert expired == []


# ---------------------------------------------------------------------------
# apply_exemptions
# ---------------------------------------------------------------------------

class TestApplyExemptions:
    def test_basic(self):
        findings = _sample_findings()
        ex, _ = create_exemption("CMD-001", "Mitigated", "admin")
        result = apply_exemptions(findings, [ex])
        assert result.total_exempted == 2  # Two CMD-001 findings
        assert result.total_remaining == 2

    def test_no_exemptions(self):
        findings = _sample_findings()
        result = apply_exemptions(findings, [])
        assert result.total_exempted == 0
        assert result.total_remaining == 4

    def test_with_store(self):
        store = ExemptionStore()
        create_exemption("CMD-001", "Test", "admin", store=store)
        result = apply_exemptions(_sample_findings(), store)
        assert result.total_exempted == 2

    def test_expired_not_applied(self):
        findings = _sample_findings()
        ex, _ = create_exemption(
            "CMD-001", "Test", "admin",
            expires_at=_past_date(1),
        )
        result = apply_exemptions(findings, [ex])
        assert result.total_exempted == 0

    def test_revoked_not_applied(self):
        findings = _sample_findings()
        ex, _ = create_exemption("CMD-001", "Test", "admin")
        ex.status = "revoked"
        result = apply_exemptions(findings, [ex])
        assert result.total_exempted == 0

    def test_scope_filter(self):
        findings = _sample_findings()
        ex, _ = create_exemption(
            "CMD-001", "Test", "admin", scope="server.py",
        )
        result = apply_exemptions(findings, [ex])
        assert result.total_exempted == 1  # Only CMD-001 in server.py

    def test_exemption_map(self):
        findings = _sample_findings()
        ex, _ = create_exemption("CMD-001", "Test", "admin")
        result = apply_exemptions(findings, [ex])
        assert len(result.exemption_map) == 2


# ---------------------------------------------------------------------------
# update_exemption
# ---------------------------------------------------------------------------

class TestUpdateExemption:
    def test_update_reason(self):
        store = ExemptionStore()
        ex, _ = create_exemption("CMD-001", "Old", "admin", store=store)
        success, msg = update_exemption(
            ex.exemption_id, store, reason="New reason",
        )
        assert success is True
        assert ex.reason == "New reason"

    def test_update_expiry(self):
        store = ExemptionStore()
        ex, _ = create_exemption("CMD-001", "Test", "admin", store=store)
        new_date = _future_date(60)
        update_exemption(
            ex.exemption_id, store, expires_at=new_date,
        )
        assert ex.expires_at == new_date

    def test_not_found(self):
        store = ExemptionStore()
        success, msg = update_exemption("EX-missing", store, reason="R")
        assert success is False

    def test_audit_trail(self):
        store = ExemptionStore()
        ex, _ = create_exemption("CMD-001", "Test", "admin", store=store)
        update_exemption(
            ex.exemption_id, store, reason="Updated",
            updated_by="reviewer",
        )
        assert len(store.audit_trail) >= 2
        last = store.audit_trail[-1]
        assert last.action == "updated"
        assert last.actor == "reviewer"


# ---------------------------------------------------------------------------
# Persistence
# ---------------------------------------------------------------------------

class TestLoadSaveExemptions:
    def test_save_and_load(self, tmp_path: Path):
        store = ExemptionStore()
        create_exemption("CMD-001", "Test", "admin", store=store)
        p = tmp_path / "exemptions.json"
        save_exemptions(store, p)
        loaded = load_exemptions(p)
        assert loaded.count == 1

    def test_load_missing(self, tmp_path: Path):
        store = load_exemptions(tmp_path / "missing.json")
        assert store.count == 0

    def test_load_invalid_json(self, tmp_path: Path):
        p = tmp_path / "bad.json"
        p.write_text("not json")
        store = load_exemptions(p)
        assert store.count == 0

    def test_load_non_dict(self, tmp_path: Path):
        p = tmp_path / "bad.json"
        p.write_text("[]")
        store = load_exemptions(p)
        assert store.count == 0


# ---------------------------------------------------------------------------
# Export / Import
# ---------------------------------------------------------------------------

class TestExportImport:
    def test_export(self):
        store = ExemptionStore()
        create_exemption("CMD-001", "Test", "admin", store=store)
        text = export_exemptions(store)
        data = json.loads(text)
        assert "exemptions" in data

    def test_import_new(self):
        store = ExemptionStore()
        create_exemption("CMD-001", "Test", "admin", store=store)
        exported = export_exemptions(store)

        imported, count = import_exemptions(exported)
        assert imported.count == 1
        assert count == 1

    def test_import_merge(self):
        store1 = ExemptionStore()
        create_exemption("CMD-001", "Test1", "admin", store=store1)

        store2 = ExemptionStore()
        create_exemption("AUTH-001", "Test2", "admin", store=store2)

        exported = export_exemptions(store1)
        result, count = import_exemptions(exported, target_store=store2)
        assert result.count == 2
        assert count == 1  # Only 1 new

    def test_import_no_duplicates(self):
        store = ExemptionStore()
        ex, _ = create_exemption("CMD-001", "Test", "admin", store=store)
        exported = export_exemptions(store)
        result, count = import_exemptions(exported, target_store=store)
        assert count == 0  # Already exists

    def test_import_invalid_json(self):
        with pytest.raises(ValueError, match="Invalid JSON"):
            import_exemptions("not json")

    def test_import_invalid_format(self):
        with pytest.raises(ValueError, match="Expected JSON object"):
            import_exemptions("[]")


# ---------------------------------------------------------------------------
# Audit trail
# ---------------------------------------------------------------------------

class TestGetAuditTrail:
    def test_basic(self):
        store = ExemptionStore()
        create_exemption("CMD-001", "Test", "admin", store=store)
        events = get_audit_trail(store)
        assert len(events) == 1
        assert events[0].action == "created"

    def test_filter_by_id(self):
        store = ExemptionStore()
        ex1, _ = create_exemption("CMD-001", "T1", "admin", store=store)
        ex2, _ = create_exemption("AUTH-001", "T2", "admin", store=store)
        events = get_audit_trail(store, exemption_id=ex1.exemption_id)
        assert all(e.exemption_id == ex1.exemption_id for e in events)

    def test_filter_by_action(self):
        store = ExemptionStore()
        ex, _ = create_exemption("CMD-001", "Test", "admin", store=store)
        revoke_exemption(ex.exemption_id, "admin", store)
        events = get_audit_trail(store, action="revoked")
        assert len(events) == 1

    def test_limit(self):
        store = ExemptionStore()
        for i in range(5):
            create_exemption(f"R-{i}", "T", "admin", store=store)
        events = get_audit_trail(store, limit=3)
        assert len(events) == 3

    def test_sorted_recent_first(self):
        store = ExemptionStore()
        create_exemption("CMD-001", "T1", "admin", store=store)
        create_exemption("AUTH-001", "T2", "admin", store=store)
        events = get_audit_trail(store)
        assert events[0].timestamp >= events[-1].timestamp


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

class TestFormatExemptionsText:
    def test_empty(self):
        store = ExemptionStore()
        text = format_exemptions_text(store)
        assert "No exemptions" in text

    def test_with_data(self):
        store = ExemptionStore()
        create_exemption("CMD-001", "Test", "admin", store=store)
        text = format_exemptions_text(store)
        assert "CMD-001" in text
        assert "admin" in text
        assert "1 total" in text


class TestFormatExemptionsJson:
    def test_valid_json(self):
        store = ExemptionStore()
        create_exemption("CMD-001", "Test", "admin", store=store)
        text = format_exemptions_json(store)
        data = json.loads(text)
        assert data["count"] == 1
        assert "exemptions" in data


class TestFormatAuditTrailText:
    def test_empty(self):
        text = format_audit_trail_text([])
        assert "No audit events" in text

    def test_with_events(self):
        events = [
            AuditEvent(
                action="created", exemption_id="EX-123",
                finding_rule="CMD-001", actor="admin",
            ),
        ]
        text = format_audit_trail_text(events)
        assert "CREATED" in text
        assert "CMD-001" in text


class TestFormatFilteredText:
    def test_basic(self):
        ff = FilteredFindings(
            included=[{"rule_id": "A"}],
            exempted=[{"rule_id": "B", "title": "Bad thing"}],
        )
        text = format_filtered_text(ff)
        assert "Original findings: 2" in text
        assert "Exempted: 1" in text
        assert "Remaining: 1" in text
