"""Tests for the core authorization engine."""

import pytest

from pyauthz import (
    Decision,
    Denied,
    Effect,
    Policy,
    PyAuthz,
    Resource,
    Role,
    Subject,
)
from pyauthz.core.errors import ConfigError

# --- Fixtures ---


@pytest.fixture
def rp():
    """A fresh PyAuthz engine."""
    return PyAuthz()


@pytest.fixture
def admin_role():
    return Role(name="admin", permissions=["*:*"])


@pytest.fixture
def editor_role():
    return Role(name="editor", permissions=["documents:write", "documents:read"])


@pytest.fixture
def viewer_role():
    return Role(name="viewer", permissions=["documents:read"])


@pytest.fixture
def alice_admin():
    return Subject(id="alice", roles=["admin"])


@pytest.fixture
def bob_editor():
    return Subject(id="bob", roles=["editor"])


@pytest.fixture
def carol_viewer():
    return Subject(id="carol", roles=["viewer"])


@pytest.fixture
def no_role_user():
    return Subject(id="nobody", roles=[])


@pytest.fixture
def doc():
    return Resource(id="doc-1", type="documents")


@pytest.fixture
def pii_record():
    return Resource(id="ssn-1", type="pii")


# --- Auto-default behavior (no explicit policies) ---


class TestAutoDefault:
    def test_admin_wildcard_allows(self, rp, admin_role, alice_admin, doc):
        rp.add_role(admin_role)
        assert rp.is_allowed(alice_admin, "read", doc) is True

    def test_editor_can_read_documents(self, rp, editor_role, bob_editor, doc):
        rp.add_role(editor_role)
        assert rp.is_allowed(bob_editor, "read", doc) is True

    def test_editor_can_write_documents(self, rp, editor_role, bob_editor, doc):
        rp.add_role(editor_role)
        assert rp.is_allowed(bob_editor, "write", doc) is True

    def test_viewer_cannot_write(self, rp, viewer_role, carol_viewer, doc):
        rp.add_role(viewer_role)
        assert rp.is_allowed(carol_viewer, "write", doc) is False

    def test_viewer_can_read(self, rp, viewer_role, carol_viewer, doc):
        rp.add_role(viewer_role)
        assert rp.is_allowed(carol_viewer, "read", doc) is True

    def test_no_role_denied(self, rp, admin_role, no_role_user, doc):
        rp.add_role(admin_role)
        assert rp.is_allowed(no_role_user, "read", doc) is False

    def test_action_shorthand_expands(self, rp, editor_role, bob_editor, doc):
        """'write' should auto-expand to 'documents:write' via resource.type."""
        rp.add_role(editor_role)
        assert rp.is_allowed(bob_editor, "write", doc) is True

    def test_action_full_form_works(self, rp, editor_role, bob_editor, doc):
        rp.add_role(editor_role)
        assert rp.is_allowed(bob_editor, "documents:write", doc) is True

    def test_unknown_role_denied(self, rp, doc):
        user = Subject(id="x", roles=["nonexistent"])
        assert rp.is_allowed(user, "read", doc) is False


# --- Explicit policies ---


class TestExplicitPolicies:
    def test_allow_policy(self, rp, admin_role, alice_admin, doc):
        rp.add_role(admin_role)
        rp.add_policy(
            Policy(
                name="admin-all",
                effect=Effect.ALLOW,
                roles=["admin"],
                permissions=["*:*"],
            )
        )
        assert rp.is_allowed(alice_admin, "read", doc) is True

    def test_deny_policy(self, rp, editor_role, bob_editor, pii_record):
        rp.add_role(editor_role)
        rp.add_policy(
            Policy(
                name="deny-pii",
                effect=Effect.DENY,
                roles=["editor"],
                permissions=["pii:*"],
            )
        )
        assert rp.is_allowed(bob_editor, "read", pii_record) is False

    def test_deny_override(self, rp, admin_role, alice_admin, doc):
        """In deny-override mode, a DENY policy beats an ALLOW policy."""
        rp.add_role(admin_role)
        rp.add_policy(
            Policy(
                name="allow-all",
                effect=Effect.ALLOW,
                roles=["admin"],
                permissions=["*:*"],
            )
        )
        rp.add_policy(
            Policy(
                name="deny-docs",
                effect=Effect.DENY,
                roles=["admin"],
                permissions=["documents:*"],
            )
        )
        assert rp.is_allowed(alice_admin, "read", doc) is False

    def test_no_matching_policy_uses_default(self, rp, admin_role, alice_admin, doc):
        rp.add_role(admin_role)
        # Add a policy that doesn't match the request
        rp.add_policy(
            Policy(
                name="unrelated",
                effect=Effect.ALLOW,
                roles=["other-role"],
                permissions=["users:*"],
            )
        )
        assert rp.is_allowed(alice_admin, "read", doc) is False

    def test_policy_no_roles_applies_to_all(self, rp, doc):
        rp.add_policy(
            Policy(
                name="allow-all-reads",
                effect=Effect.ALLOW,
                roles=[],
                permissions=["documents:read"],
            )
        )
        anyone = Subject(id="anyone")
        assert rp.is_allowed(anyone, "read", doc) is True

    def test_policy_no_permissions_matches_all_actions(self, rp, doc):
        rp.add_policy(
            Policy(
                name="allow-admin-everything",
                effect=Effect.ALLOW,
                roles=["admin"],
                permissions=[],
            )
        )
        admin = Subject(id="admin", roles=["admin"])
        assert rp.is_allowed(admin, "read", doc) is True
        assert rp.is_allowed(admin, "write", doc) is True

    def test_disabled_policy_skipped(self, rp, admin_role, alice_admin, doc):
        rp.add_role(admin_role)
        rp.add_policy(
            Policy(
                name="allow-admin",
                effect=Effect.ALLOW,
                roles=["admin"],
                permissions=["*:*"],
                enabled=False,
            )
        )
        # No enabled policies match, so default effect (DENY) applies
        assert rp.is_allowed(alice_admin, "read", doc) is False


# --- authorize() and Denied exception ---


class TestAuthorize:
    def test_authorize_returns_decision(self, rp, admin_role, alice_admin, doc):
        rp.add_role(admin_role)
        decision = rp.authorize(alice_admin, "read", doc)
        assert isinstance(decision, Decision)
        assert decision.allowed is True

    def test_authorize_raises_denied(self, rp, viewer_role, carol_viewer, doc):
        rp.add_role(viewer_role)
        with pytest.raises(Denied) as exc_info:
            rp.authorize(carol_viewer, "write", doc)
        assert exc_info.value.decision.allowed is False
        assert isinstance(exc_info.value.decision, Decision)

    def test_denied_has_explanation(self, rp, viewer_role, carol_viewer, doc):
        rp.add_role(viewer_role)
        with pytest.raises(Denied) as exc_info:
            rp.authorize(carol_viewer, "write", doc)
        explanation = exc_info.value.decision.explain()
        assert "DENIED" in explanation
        assert "carol" in explanation

    def test_decision_has_duration(self, rp, admin_role, alice_admin, doc):
        rp.add_role(admin_role)
        decision = rp.authorize(alice_admin, "read", doc)
        assert decision.duration_us >= 0


# --- Bulk operations ---


class TestBulk:
    def test_authorize_bulk(self, rp, admin_role, viewer_role, alice_admin, carol_viewer, doc):
        rp.add_role(admin_role)
        rp.add_role(viewer_role)
        decisions = rp.authorize_bulk(
            [
                (alice_admin, "read", doc),
                (carol_viewer, "write", doc),
                (alice_admin, "write", doc),
            ]
        )
        assert len(decisions) == 3
        assert decisions[0].allowed is True
        assert decisions[1].allowed is False
        assert decisions[2].allowed is True


# --- Async ---


class TestAsync:
    @pytest.mark.asyncio
    async def test_aauthorize(self, rp, admin_role, alice_admin, doc):
        rp.add_role(admin_role)
        decision = await rp.aauthorize(alice_admin, "read", doc)
        assert decision.allowed is True

    @pytest.mark.asyncio
    async def test_ais_allowed(self, rp, admin_role, alice_admin, doc):
        rp.add_role(admin_role)
        assert await rp.ais_allowed(alice_admin, "read", doc) is True

    @pytest.mark.asyncio
    async def test_aauthorize_denied(self, rp, viewer_role, carol_viewer, doc):
        rp.add_role(viewer_role)
        with pytest.raises(Denied):
            await rp.aauthorize(carol_viewer, "write", doc)

    @pytest.mark.asyncio
    async def test_aauthorize_bulk(self, rp, admin_role, alice_admin, doc):
        rp.add_role(admin_role)
        decisions = await rp.aauthorize_bulk(
            [
                (alice_admin, "read", doc),
                (alice_admin, "write", doc),
            ]
        )
        assert all(d.allowed for d in decisions)


# --- Role and policy management ---


class TestManagement:
    def test_add_duplicate_role_raises(self, rp, admin_role):
        rp.add_role(admin_role)
        with pytest.raises(ConfigError, match="already exists"):
            rp.add_role(admin_role)

    def test_add_duplicate_policy_raises(self, rp):
        policy = Policy(name="test", effect=Effect.ALLOW)
        rp.add_policy(policy)
        with pytest.raises(ConfigError, match="already exists"):
            rp.add_policy(policy)

    def test_remove_role(self, rp, admin_role):
        rp.add_role(admin_role)
        rp.remove_role("admin")
        # Should be able to add again
        rp.add_role(admin_role)

    def test_remove_nonexistent_role_raises(self, rp):
        with pytest.raises(ConfigError, match="not found"):
            rp.remove_role("nope")

    def test_remove_policy(self, rp):
        rp.add_policy(Policy(name="test", effect=Effect.ALLOW))
        rp.remove_policy("test")
        rp.add_policy(Policy(name="test", effect=Effect.ALLOW))

    def test_remove_nonexistent_policy_raises(self, rp):
        with pytest.raises(ConfigError, match="not found"):
            rp.remove_policy("nope")

    def test_replace_policies(self, rp, admin_role, alice_admin, doc):
        rp.add_role(admin_role)
        rp.add_policy(Policy(name="deny-all", effect=Effect.DENY, permissions=["*:*"]))
        assert rp.is_allowed(alice_admin, "read", doc) is False

        rp.replace_policies(
            [Policy(name="allow-all", effect=Effect.ALLOW, roles=["admin"], permissions=["*:*"])]
        )
        assert rp.is_allowed(alice_admin, "read", doc) is True

    def test_replace_roles(self, rp, alice_admin, doc):
        rp.add_role(Role(name="admin", permissions=["users:*"]))
        assert rp.is_allowed(alice_admin, "read", doc) is False

        rp.replace_roles([Role(name="admin", permissions=["*:*"])])
        assert rp.is_allowed(alice_admin, "read", doc) is True


# --- First-match combiner ---


class TestFirstMatch:
    def test_first_match_allow_first(self, rp):
        rp_fm = PyAuthz(combiner="first-match")
        rp_fm.add_policy(Policy(name="allow-first", effect=Effect.ALLOW, permissions=["*:*"]))
        rp_fm.add_policy(Policy(name="deny-second", effect=Effect.DENY, permissions=["*:*"]))
        user = Subject(id="x")
        doc = Resource(id="d", type="documents")
        assert rp_fm.is_allowed(user, "read", doc) is True

    def test_first_match_deny_first(self):
        rp_fm = PyAuthz(combiner="first-match")
        rp_fm.add_policy(Policy(name="deny-first", effect=Effect.DENY, permissions=["*:*"]))
        rp_fm.add_policy(Policy(name="allow-second", effect=Effect.ALLOW, permissions=["*:*"]))
        user = Subject(id="x")
        doc = Resource(id="d", type="documents")
        assert rp_fm.is_allowed(user, "read", doc) is False


# --- Environment context ---


class TestEnvironment:
    def test_environment_in_decision(self, rp, admin_role, alice_admin, doc):
        rp.add_role(admin_role)
        decision = rp.authorize(alice_admin, "read", doc, hour=14, ip="10.0.0.1")
        assert decision.request.environment["hour"] == 14
        assert decision.request.environment["ip"] == "10.0.0.1"

    def test_set_environment_defaults(self, rp, admin_role, alice_admin, doc):
        rp.add_role(admin_role)
        rp.set_environment(region="us-east-1")
        decision = rp.authorize(alice_admin, "read", doc)
        assert decision.request.environment["region"] == "us-east-1"

    def test_per_request_overrides_default(self, rp, admin_role, alice_admin, doc):
        rp.add_role(admin_role)
        rp.set_environment(region="us-east-1")
        decision = rp.authorize(alice_admin, "read", doc, region="eu-west-1")
        assert decision.request.environment["region"] == "eu-west-1"


# --- Invalid combiner ---


class TestInvalidConfig:
    def test_unknown_combiner_raises(self):
        with pytest.raises(ConfigError, match="Unknown combiner"):
            PyAuthz(combiner="nonexistent")
