"""Tests for Phase 9: compliance exporter, mutation audit, HMAC integrity, X.509."""

import json
import os
import tempfile
from datetime import UTC, datetime, timedelta

import pytest

from pyauthz import (
    AuditLogger,
    Delegation,
    DelegationStore,
    JsonExporter,
    PyAuthz,
    Resource,
    Role,
    Subject,
    TemporalRole,
    TemporalRoleStore,
)
from pyauthz.audit.compliance import (
    ComplianceExporter,
    get_decision_controls,
)
from pyauthz.audit.integrity import HmacExporter
from pyauthz.audit.models import AuditEvent, MutationAction, MutationEvent
from pyauthz.contrib.x509 import (
    extract_dod_id,
    extract_email,
    parse_dn,
    subject_from_dn,
)

# --- AuditEvent model expansion ---


class TestAuditEventExpansion:
    def test_optional_fields_default_to_none(self):
        event = AuditEvent(
            timestamp=datetime.now(UTC),
            decision="ALLOW",
            subject_id="alice",
            subject_roles=["admin"],
            action="documents:read",
            resource_type="documents",
            resource_id="doc-1",
            matched_policies=["admin-access"],
            reasoning=["allowed"],
            duration_us=100,
        )
        assert event.source_ip is None
        assert event.request_id is None
        assert event.service_id is None

    def test_optional_fields_can_be_set(self):
        event = AuditEvent(
            timestamp=datetime.now(UTC),
            decision="DENY",
            subject_id="bob",
            subject_roles=["viewer"],
            action="documents:write",
            resource_type="documents",
            resource_id="doc-1",
            matched_policies=[],
            reasoning=["no match"],
            duration_us=50,
            source_ip="10.0.0.1",
            request_id="req-abc-123",
            service_id="api-gateway",
        )
        assert event.source_ip == "10.0.0.1"
        assert event.request_id == "req-abc-123"
        assert event.service_id == "api-gateway"

    def test_backward_compatible_serialization(self):
        """Existing code that doesn't set new fields should still work."""
        event = AuditEvent(
            timestamp=datetime.now(UTC),
            decision="ALLOW",
            subject_id="alice",
            subject_roles=[],
            action="read",
            resource_type="docs",
            resource_id="1",
            matched_policies=[],
            reasoning=[],
            duration_us=0,
        )
        data = json.loads(event.model_dump_json())
        assert "subject_id" in data
        assert data["source_ip"] is None


# --- MutationEvent model ---


class TestMutationEvent:
    def test_mutation_event_creation(self):
        event = MutationEvent(
            timestamp=datetime.now(UTC),
            mutation_action=MutationAction.ROLE_ADDED,
            target_name="admin",
            details={"permissions": ["*:*"]},
        )
        assert event.mutation_action == MutationAction.ROLE_ADDED
        assert event.target_name == "admin"

    def test_mutation_event_serialization(self):
        event = MutationEvent(
            timestamp=datetime.now(UTC),
            mutation_action=MutationAction.POLICY_REMOVED,
            target_name="old-policy",
        )
        data = json.loads(event.model_dump_json())
        assert data["mutation_action"] == "policy_removed"
        assert data["target_name"] == "old-policy"


# --- Engine mutation audit hooks ---


class TestEngineMutationAudit:
    def _setup_audited_engine(self):
        fd, path = tempfile.mkstemp(suffix=".jsonl")
        os.close(fd)
        rp = PyAuthz()
        audit = AuditLogger(exporters=[JsonExporter(path=path)])
        rp.set_audit_logger(audit)
        return rp, path

    def test_add_role_emits_mutation_event(self):
        rp, path = self._setup_audited_engine()
        try:
            rp.add_role(Role(name="admin", permissions=["*:*"]))
            with open(path) as f:
                lines = f.readlines()
            assert len(lines) == 1
            event = json.loads(lines[0])
            assert event["mutation_action"] == "role_added"
            assert event["target_name"] == "admin"
        finally:
            os.unlink(path)

    def test_remove_role_emits_mutation_event(self):
        rp, path = self._setup_audited_engine()
        try:
            rp.add_role(Role(name="viewer", permissions=["docs:read"]))
            rp.remove_role("viewer")
            with open(path) as f:
                lines = f.readlines()
            assert len(lines) == 2
            event = json.loads(lines[1])
            assert event["mutation_action"] == "role_removed"
            assert event["target_name"] == "viewer"
        finally:
            os.unlink(path)

    def test_add_policy_emits_mutation_event(self):
        rp, path = self._setup_audited_engine()
        try:
            from pyauthz import Effect, Policy

            rp.add_policy(Policy(name="deny-pii", effect=Effect.DENY, permissions=["pii:*"]))
            with open(path) as f:
                lines = f.readlines()
            assert len(lines) == 1
            event = json.loads(lines[0])
            assert event["mutation_action"] == "policy_added"
            assert event["target_name"] == "deny-pii"
        finally:
            os.unlink(path)

    def test_replace_roles_emits_mutation_event(self):
        rp, path = self._setup_audited_engine()
        try:
            rp.replace_roles(
                [
                    Role(name="a", permissions=["x:read"]),
                    Role(name="b", permissions=["y:read"]),
                ]
            )
            with open(path) as f:
                lines = f.readlines()
            assert len(lines) == 1
            event = json.loads(lines[0])
            assert event["mutation_action"] == "roles_replaced"
            assert "a" in event["details"]["role_names"]
        finally:
            os.unlink(path)

    def test_no_mutation_events_without_audit_logger(self):
        """Engine without audit logger should not crash on mutations."""
        rp = PyAuthz()
        rp.add_role(Role(name="admin", permissions=["*:*"]))
        rp.remove_role("admin")
        # No exception = pass


# --- Temporal role audit events ---


class TestTemporalRoleAudit:
    def test_assign_emits_audit_event(self):
        fd, path = tempfile.mkstemp(suffix=".jsonl")
        os.close(fd)
        try:
            audit = AuditLogger(exporters=[JsonExporter(path=path)])
            store = TemporalRoleStore(audit_logger=audit)
            store.assign(
                TemporalRole(
                    role_name="admin",
                    subject_id="alice",
                    expires_at=datetime.now(UTC) + timedelta(hours=1),
                )
            )
            with open(path) as f:
                event = json.loads(f.readline())
            assert event["mutation_action"] == "temporal_role_assigned"
            assert event["target_name"] == "admin"
            assert event["details"]["subject_id"] == "alice"
        finally:
            os.unlink(path)

    def test_revoke_emits_audit_event(self):
        fd, path = tempfile.mkstemp(suffix=".jsonl")
        os.close(fd)
        try:
            audit = AuditLogger(exporters=[JsonExporter(path=path)])
            store = TemporalRoleStore(audit_logger=audit)
            store.assign(TemporalRole(role_name="admin", subject_id="alice"))
            store.revoke("admin", "alice")
            with open(path) as f:
                lines = f.readlines()
            assert len(lines) == 2
            event = json.loads(lines[1])
            assert event["mutation_action"] == "temporal_role_revoked"
        finally:
            os.unlink(path)

    def test_no_audit_without_logger(self):
        """Store without audit logger should work normally."""
        store = TemporalRoleStore()
        store.assign(TemporalRole(role_name="admin", subject_id="alice"))
        store.revoke("admin", "alice")


# --- Delegation audit events ---


class TestDelegationAudit:
    def test_create_emits_audit_event(self):
        fd, path = tempfile.mkstemp(suffix=".jsonl")
        os.close(fd)
        try:
            audit = AuditLogger(exporters=[JsonExporter(path=path)])
            store = DelegationStore(audit_logger=audit)
            store.create(
                Delegation(
                    grantor_id="alice",
                    grantee_id="bob",
                    permissions=["docs:write"],
                    reason="covering PTO",
                )
            )
            with open(path) as f:
                event = json.loads(f.readline())
            assert event["mutation_action"] == "delegation_created"
            assert "alice" in event["target_name"]
            assert "bob" in event["target_name"]
        finally:
            os.unlink(path)

    def test_revoke_emits_audit_event(self):
        fd, path = tempfile.mkstemp(suffix=".jsonl")
        os.close(fd)
        try:
            audit = AuditLogger(exporters=[JsonExporter(path=path)])
            store = DelegationStore(audit_logger=audit)
            store.create(
                Delegation(
                    grantor_id="alice",
                    grantee_id="bob",
                    permissions=["docs:write"],
                )
            )
            store.revoke("alice", "bob")
            with open(path) as f:
                lines = f.readlines()
            assert len(lines) == 2
            event = json.loads(lines[1])
            assert event["mutation_action"] == "delegation_revoked"
        finally:
            os.unlink(path)


# --- ComplianceExporter ---


class TestComplianceExporter:
    def test_decision_event_has_nist_controls(self):
        fd, path = tempfile.mkstemp(suffix=".jsonl")
        os.close(fd)
        try:
            rp = PyAuthz()
            rp.add_role(Role(name="admin", permissions=["*:*"]))
            exporter = ComplianceExporter(path=path)
            audit = AuditLogger(exporters=[exporter])
            rp.set_audit_logger(audit)

            user = Subject(id="alice", roles=["admin"])
            doc = Resource(id="doc-1", type="documents")
            rp.is_allowed(user, "read", doc)

            with open(path) as f:
                record = json.loads(f.readline())
            assert record["event_category"] == "authorization"
            assert "AC-3" in record["nist_controls"]
        finally:
            os.unlink(path)

    def test_mutation_event_has_nist_controls(self):
        fd, path = tempfile.mkstemp(suffix=".jsonl")
        os.close(fd)
        try:
            exporter = ComplianceExporter(path=path)
            audit = AuditLogger(exporters=[exporter])
            rp = PyAuthz()
            rp.set_audit_logger(audit)
            rp.add_role(Role(name="admin", permissions=["*:*"]))

            with open(path) as f:
                record = json.loads(f.readline())
            assert record["event_category"] == "configuration_change"
            assert "AC-2" in record["nist_controls"]
            assert "CM-3" in record["nist_controls"]
        finally:
            os.unlink(path)

    def test_service_id_attached(self):
        fd, path = tempfile.mkstemp(suffix=".jsonl")
        os.close(fd)
        try:
            exporter = ComplianceExporter(path=path, service_id="my-api")
            audit = AuditLogger(exporters=[exporter])
            rp = PyAuthz()
            rp.set_audit_logger(audit)
            rp.add_role(Role(name="x", permissions=["a:b"]))

            with open(path) as f:
                record = json.loads(f.readline())
            assert record["service_id"] == "my-api"
        finally:
            os.unlink(path)

    def test_deny_controls_include_ac6(self):
        controls = get_decision_controls("DENY")
        assert "AC-6" in controls

    def test_allow_controls(self):
        controls = get_decision_controls("ALLOW")
        assert "AC-3" in controls
        assert "AC-24" in controls


# --- HMAC Integrity ---


class TestHmacExporter:
    def test_signed_event_can_be_verified(self):
        fd, path = tempfile.mkstemp(suffix=".jsonl")
        os.close(fd)
        key = b"test-secret-key"
        try:
            exporter = HmacExporter(path=path, key=key)
            event = AuditEvent(
                timestamp=datetime.now(UTC),
                decision="ALLOW",
                subject_id="alice",
                subject_roles=["admin"],
                action="documents:read",
                resource_type="documents",
                resource_id="doc-1",
                matched_policies=["admin-access"],
                reasoning=["allowed"],
                duration_us=100,
            )
            exporter.export(event)

            with open(path) as f:
                line = f.readline()
            assert HmacExporter.verify(line, key)
        finally:
            os.unlink(path)

    def test_tampered_event_fails_verification(self):
        fd, path = tempfile.mkstemp(suffix=".jsonl")
        os.close(fd)
        key = b"test-secret-key"
        try:
            exporter = HmacExporter(path=path, key=key)
            event = AuditEvent(
                timestamp=datetime.now(UTC),
                decision="ALLOW",
                subject_id="alice",
                subject_roles=["admin"],
                action="documents:read",
                resource_type="documents",
                resource_id="doc-1",
                matched_policies=[],
                reasoning=[],
                duration_us=100,
            )
            exporter.export(event)

            with open(path) as f:
                line = f.readline()

            # Tamper with the data
            record = json.loads(line)
            record["subject_id"] = "eve"
            tampered_line = json.dumps(record)
            assert not HmacExporter.verify(tampered_line, key)
        finally:
            os.unlink(path)

    def test_wrong_key_fails_verification(self):
        fd, path = tempfile.mkstemp(suffix=".jsonl")
        os.close(fd)
        try:
            exporter = HmacExporter(path=path, key=b"correct-key")
            event = AuditEvent(
                timestamp=datetime.now(UTC),
                decision="DENY",
                subject_id="bob",
                subject_roles=[],
                action="pii:read",
                resource_type="pii",
                resource_id="record-1",
                matched_policies=[],
                reasoning=[],
                duration_us=50,
            )
            exporter.export(event)

            with open(path) as f:
                line = f.readline()
            assert not HmacExporter.verify(line, b"wrong-key")
        finally:
            os.unlink(path)

    def test_empty_key_raises(self):
        with pytest.raises(ValueError, match="not be empty"):
            HmacExporter(path="/tmp/test.jsonl", key=b"")

    def test_mutation_event_signed(self):
        fd, path = tempfile.mkstemp(suffix=".jsonl")
        os.close(fd)
        key = b"secret"
        try:
            exporter = HmacExporter(path=path, key=key)
            event = MutationEvent(
                timestamp=datetime.now(UTC),
                mutation_action=MutationAction.ROLE_ADDED,
                target_name="admin",
                details={"permissions": ["*:*"]},
            )
            exporter.export(event)

            with open(path) as f:
                line = f.readline()
            assert HmacExporter.verify(line, key)
        finally:
            os.unlink(path)


# --- X.509 DN Parsing ---


class TestX509DnParsing:
    def test_parse_comma_separated_dn(self):
        dn = "CN=DOE.JANE.M.1234567890,OU=USN,OU=PKI,OU=DoD,O=U.S. Government,C=US"
        fields = parse_dn(dn)
        assert fields["CN"] == "DOE.JANE.M.1234567890"
        assert fields["OU"] == ["USN", "PKI", "DoD"]
        assert fields["O"] == "U.S. Government"
        assert fields["C"] == "US"

    def test_parse_slash_separated_dn(self):
        dn = "/C=US/O=U.S. Government/OU=DoD/OU=PKI/CN=DOE.JOHN.A.9876543210"
        fields = parse_dn(dn)
        assert fields["CN"] == "DOE.JOHN.A.9876543210"
        assert fields["C"] == "US"

    def test_parse_single_ou(self):
        dn = "CN=test,OU=single,O=Org,C=US"
        fields = parse_dn(dn)
        assert fields["OU"] == "single"

    def test_extract_dod_id(self):
        assert extract_dod_id("DOE.JANE.M.1234567890") == "1234567890"

    def test_extract_dod_id_no_match(self):
        assert extract_dod_id("some.user") is None

    def test_extract_dod_id_short_number(self):
        assert extract_dod_id("DOE.JANE.123") is None

    def test_extract_email(self):
        fields = {"CN": "test", "E": "jane@example.com"}
        assert extract_email(fields) == "jane@example.com"

    def test_extract_email_emailaddress(self):
        fields = {"CN": "test", "EMAILADDRESS": "bob@mil.gov"}
        assert extract_email(fields) == "bob@mil.gov"

    def test_extract_email_none(self):
        fields = {"CN": "test"}
        assert extract_email(fields) is None

    def test_subject_from_dn_dod(self):
        dn = "CN=DOE.JANE.M.1234567890,OU=USN,OU=PKI,O=U.S. Government,C=US"
        subject = subject_from_dn(dn, roles=["analyst"])
        assert subject.id == "1234567890"
        assert subject.roles == ["analyst"]
        assert subject.attributes["cn"] == "DOE.JANE.M.1234567890"
        assert subject.attributes["org"] == "U.S. Government"
        assert subject.attributes["dod_id"] == "1234567890"
        assert subject.attributes["dn"] == dn

    def test_subject_from_dn_no_dod_id(self):
        dn = "CN=Some User,O=ACME Corp,C=US"
        subject = subject_from_dn(dn)
        assert subject.id == "Some User"
        assert "dod_id" not in subject.attributes

    def test_subject_from_dn_with_email(self):
        dn = "CN=DOE.JANE.M.1234567890,E=jane@navy.mil,O=USN,C=US"
        subject = subject_from_dn(dn)
        assert subject.attributes["email"] == "jane@navy.mil"

    def test_subject_from_dn_extra_attributes(self):
        dn = "CN=DOE.JANE.M.1234567890,O=USN,C=US"
        subject = subject_from_dn(
            dn,
            extra_attributes={"clearance": "SECRET"},
        )
        assert subject.attributes["clearance"] == "SECRET"

    def test_empty_dn(self):
        fields = parse_dn("")
        assert fields == {}

    def test_subject_from_empty_dn(self):
        subject = subject_from_dn("")
        assert subject.id == "unknown"


# --- Denied exception suggestions ---


class TestDeniedSuggestions:
    def test_denied_carries_suggestions(self):
        from pyauthz.core.errors import Denied

        rp = PyAuthz()
        rp.add_role(Role(name="admin", permissions=["*:*"]))
        user = Subject(id="bob", roles=[])
        doc = Resource(id="doc-1", type="documents")

        try:
            rp.authorize(user, "documents:read", doc)
            pytest.fail("Should have raised Denied")
        except Denied as e:
            assert len(e.suggestions) > 0
            assert any("admin" in s for s in e.suggestions)

    def test_denied_suggestions_empty_when_no_matching_role(self):
        from pyauthz.core.errors import Denied

        rp = PyAuthz()
        user = Subject(id="bob", roles=[])
        doc = Resource(id="doc-1", type="documents")

        try:
            rp.authorize(user, "documents:read", doc)
            pytest.fail("Should have raised Denied")
        except Denied as e:
            assert len(e.suggestions) > 0
