"""Authorization contracts (ports) for the domain layer.

These protocols define the interfaces for authorization components.
Infrastructure layer provides concrete implementations.
"""

from abc import abstractmethod
from dataclasses import dataclass, field
from typing import Any, Protocol, runtime_checkable

from ..value_objects import Permission, Principal, Role
from ..value_objects.tool_access_policy import ToolAccessPolicy


@dataclass
class AuthorizationRequest:
    """Request to check authorization.

    Contains all information needed to make an authorization decision.

    Attributes:
        principal: The authenticated principal requesting access.
        action: The action being requested (create, read, update, delete, invoke, etc.).
        resource_type: Type of resource (mcp_server, tool, config, audit, metrics).
        resource_id: Specific resource identifier or '*' for any.
        context: Additional context for policy evaluation (rate limits, time, etc.).
    """

    principal: Principal
    action: str
    resource_type: str
    resource_id: str
    context: dict[str, Any] = field(default_factory=dict)

    def __post_init__(self) -> None:
        if not self.action:
            raise ValueError("AuthorizationRequest action cannot be empty")
        if not self.resource_type:
            raise ValueError("AuthorizationRequest resource_type cannot be empty")


@dataclass
class AuthorizationResult:
    """Result of authorization check.

    Attributes:
        allowed: Whether the action is permitted.
        reason: Human-readable reason for the decision.
        matched_permission: The permission that granted access (if allowed).
        matched_role: The role that provided the permission (if allowed).
        grant_scope: The scope of the role binding that granted access --
            ``"global"`` or ``"tenant:<id>"`` -- from an authorizer that binds
            roles to scopes. ``None`` when the deciding authorizer has no notion
            of a scoped grant. :class:`GrantScope` says how each is honoured.
    """

    allowed: bool
    reason: str = ""
    matched_permission: Permission | None = None
    matched_role: str | None = None
    grant_scope: str | None = None

    @classmethod
    def allow(
        cls,
        reason: str = "",
        permission: Permission | None = None,
        role: str | None = None,
        scope: str | None = None,
    ) -> "AuthorizationResult":
        """Create an allow result."""
        return cls(
            allowed=True,
            reason=reason,
            matched_permission=permission,
            matched_role=role,
            grant_scope=scope,
        )

    @classmethod
    def deny(cls, reason: str = "") -> "AuthorizationResult":
        """Create a deny result."""
        return cls(allowed=False, reason=reason)


@dataclass(frozen=True)
class GrantScope:
    """How far an allow decision reaches: the whole fleet, or one tenant.

    A role bound at ``tenant:<id>`` is a grant *within that tenant*. The control
    plane checks its permissions against ``resource_id="*"``, so "allowed" on
    its own cannot tell a tenant's viewer from a fleet-wide one. A caller that
    serves one tenant's data -- or none of it -- has to ask how far the grant
    reaches, and this is the answer.

    Attributes:
        confined: False when the grant reaches the whole fleet.
        tenant_id: The tenant a confined grant is limited to. ``None`` while
            ``confined`` is a scope this code does not recognise. That is
            honoured nowhere: not knowing how far a grant reaches is no reason
            to assume it reaches everywhere.
    """

    confined: bool = False
    tenant_id: str | None = None

    @classmethod
    def of(cls, result: object) -> "GrantScope":
        """Read the reach of *result*, an allow decision.

        * ``"global"`` -- fleet-wide.
        * ``"tenant:<id>"`` -- confined to ``<id>``.
        * ``None`` -- fleet-wide. The authorizer binds no grants to scopes (a
          policy engine, or the null authorizer that answers with auth off), so
          nothing narrows its decision; that is how every decision read before
          scopes were reported. An object that is not an
          :class:`AuthorizationResult` carries no scope and reads the same way.
        * anything else -- confined to no tenant, so refused wherever asked.
        """
        scope = result.grant_scope if isinstance(result, AuthorizationResult) else None
        if scope is None or scope == GLOBAL_SCOPE:
            return cls()
        if scope.startswith(TENANT_SCOPE_PREFIX) and len(scope) > len(TENANT_SCOPE_PREFIX):
            return cls(confined=True, tenant_id=scope[len(TENANT_SCOPE_PREFIX) :])
        return cls(confined=True, tenant_id=None)


@runtime_checkable
class IAuthorizer(Protocol):
    """Checks if a principal is authorized for an action.

    Authorizers make access control decisions based on:
    - Principal identity and attributes
    - Requested action
    - Target resource
    - Optional context (rate limits, time-based rules, etc.)
    """

    @abstractmethod
    def authorize(self, request: AuthorizationRequest) -> AuthorizationResult:
        """Check if the principal is authorized.

        Args:
            request: The authorization request with principal, action, and resource.

        Returns:
            AuthorizationResult with allowed status and reason.

        Note:
            This method should never raise exceptions for authorization failures.
            Authorization denial is represented in the result, not via exceptions.
        """
        ...


@runtime_checkable
class IRoleStore(Protocol):
    """Storage for roles and role assignments.

    Handles:
    - Role definitions (name -> permissions)
    - Role assignments (principal -> roles, optionally scoped)

    Roles can be assigned globally or scoped to a tenant/namespace.
    """

    @abstractmethod
    def add_role(self, role: Role) -> None:
        """Add a new role to the store.

        Args:
            role: The role to add.
        """
        ...

    @abstractmethod
    def get_role(self, role_name: str) -> Role | None:
        """Get role by name.

        Args:
            role_name: Name of the role to retrieve.

        Returns:
            Role if found, None otherwise.
        """
        ...

    @abstractmethod
    def get_roles_for_principal(
        self,
        principal_id: str,
        scope: str = "*",
    ) -> list[Role]:
        """Get all roles assigned to a principal.

        Args:
            principal_id: ID of the principal.
            scope: Filter by scope ('*' for all, 'global', 'tenant:X', etc.).

        Returns:
            List of roles assigned to the principal.
        """
        ...

    @abstractmethod
    def assign_role(
        self,
        principal_id: str,
        role_name: str,
        scope: str = "global",
        assigned_by: str | None = None,
    ) -> None:
        """Assign a role to a principal.

        Args:
            principal_id: ID of the principal receiving the role.
            role_name: Name of the role to assign.
            scope: Scope of the assignment (global, tenant:X, namespace:Y).

        Raises:
            ValueError: If role_name doesn't exist.
        """
        ...

    @abstractmethod
    def revoke_role(
        self,
        principal_id: str,
        role_name: str,
        scope: str = "global",
        revoked_by: str | None = None,
    ) -> None:
        """Revoke a role from a principal.

        Args:
            principal_id: ID of the principal losing the role.
            role_name: Name of the role to revoke.
            scope: Scope from which to revoke (global, tenant:X, namespace:Y).
        """
        ...

    @abstractmethod
    def list_all_roles(self) -> list[Role]:
        """List all custom (non-builtin) roles.

        Returns:
            List of all custom roles in the store.
        """
        ...

    @abstractmethod
    def delete_role(self, role_name: str) -> None:
        """Delete a custom role and remove all its assignments.

        Args:
            role_name: Name of the role to delete.

        Raises:
            RoleNotFoundError: If the role does not exist.
            CannotModifyBuiltinRoleError: If the role is a built-in role.
        """
        ...

    @abstractmethod
    def update_role(
        self,
        role_name: str,
        permissions: list["Permission"],
        description: str | None,
    ) -> Role:
        """Update a custom role's permissions and description.

        Args:
            role_name: Name of the role to update.
            permissions: New list of permissions.
            description: New description (None to clear).

        Returns:
            Updated Role value object.

        Raises:
            RoleNotFoundError: If the role does not exist.
            CannotModifyBuiltinRoleError: If the role is a built-in role.
        """
        ...


@runtime_checkable
class IPolicyEngine(Protocol):
    """External policy engine (e.g., OPA) for complex authorization.

    Used when built-in RBAC is insufficient and complex policies
    are needed (multi-tenant isolation, time-based access, etc.).
    """

    @abstractmethod
    def evaluate(self, input_data: dict[str, Any]) -> AuthorizationResult:
        """Evaluate policy with given input.

        Args:
            input_data: Policy input including principal, action, resource, context.

        Returns:
            AuthorizationResult from policy evaluation.

        Note:
            Should fail closed (deny) on errors. Never raise exceptions
            that would bypass authorization.
        """
        ...

    @staticmethod
    def build_input(request: AuthorizationRequest) -> dict[str, Any]:
        """Build policy engine input from authorization request.

        Args:
            request: The authorization request.

        Returns:
            Dictionary formatted for policy engine input.
        """
        return {
            "principal": {
                "id": request.principal.id.value,
                "type": request.principal.type.value,
                "tenant_id": request.principal.tenant_id,
                "groups": list(request.principal.groups),
            },
            "action": request.action,
            "resource": {
                "type": request.resource_type,
                "id": request.resource_id,
            },
            "context": request.context,
        }


@runtime_checkable
class IToolAccessPolicyStore(Protocol):
    """Persistent storage for tool access policies.

    Stores per-scope tool access policies that survive server restarts.
    Scope values: "mcp_server", "group", "member".
    """

    @abstractmethod
    def set_policy(self, scope: str, target_id: str, policy: ToolAccessPolicy) -> None:
        """Persist a tool access policy for a scope/target combination.

        Takes the whole policy rather than the two lists one command happens to
        carry. The narrower signature is how a consent gate went missing: a
        store that only knows about ``allow_list`` and ``deny_list`` cannot
        round-trip ``approval_list``, so a restart replayed a policy with the
        gate silently removed (#915).

        Args:
            scope: "mcp_server", "group", or "member".
            target_id: Identifier of the mcp_server, group, or member.
            policy: The policy to persist, in full.
        """
        ...

    @abstractmethod
    def get_policy(self, scope: str, target_id: str) -> ToolAccessPolicy | None:
        """Retrieve a stored policy.

        Args:
            scope: Scope string.
            target_id: Target identifier.

        Returns:
            ToolAccessPolicy if found, None otherwise.
        """
        ...

    @abstractmethod
    def clear_policy(self, scope: str, target_id: str) -> None:
        """Remove a stored policy.

        Args:
            scope: Scope string.
            target_id: Target identifier.
        """
        ...

    @abstractmethod
    def list_all_policies(self) -> list[tuple[str, str, ToolAccessPolicy]]:
        """List all stored policies for startup replay.

        Returns whole policies, so a caller cannot reconstruct one from a
        subset of its fields and drop the rest -- which is what the previous
        ``(scope, target_id, allow_list, deny_list)`` shape invited (#915).

        Returns:
            List of (scope, target_id, policy) tuples.
        """
        ...


@dataclass
class PolicyEvaluationResult:
    """Result of a tool access policy evaluation.

    Carried a ``policy_id`` field documented as "the policy that made the
    decision (for audit)" from the day it was written. Nothing ever set it and
    nothing ever read it: the only implementation of the enforcer protocol in
    this codebase is :class:`NullToolAccessPolicyEnforcer`, which allows
    everything and names no policy. A documented-but-always-empty audit field is
    worse than an absent one, because a reader reasonably assumes it works, so
    it is gone (#1129) rather than left as evidence for a capability that does
    not exist.

    Policy identity on the path that does produce verdicts lives on
    ``L7Policy.policy_id``: a content hash of the compiled rules, carried by
    ``Decision``, the ``EgressPolicy*`` events and the refusals. Bring the field
    back here when an enforcer exists that can fill it.

    Attributes:
        allowed: Whether the tool invocation is permitted.
        reason: Human-readable explanation of the decision.
    """

    allowed: bool
    reason: str = ""

    @classmethod
    def allow(cls, reason: str = "") -> "PolicyEvaluationResult":
        """Create an allow result."""
        return cls(allowed=True, reason=reason)

    @classmethod
    def deny(cls, reason: str = "") -> "PolicyEvaluationResult":
        """Create a deny result."""
        return cls(allowed=False, reason=reason)


@runtime_checkable
class IToolAccessPolicyEnforcer(Protocol):
    """Runtime enforcement of tool access policies.

    Evaluates whether a principal can invoke a specific tool on a mcp_server,
    considering all applicable policies (mcp_server-level, group-level, member-level).

    This is the enforcement contract -- distinct from IToolAccessPolicyStore which
    handles policy storage/retrieval. The RBAC module implements this with
    identity-aware policy resolution. Core provides a config-driven implementation
    using ToolAccessPolicy value objects.
    """

    @abstractmethod
    def evaluate(
        self,
        principal: Principal,
        mcp_server_id: str,
        tool_name: str,
        context: dict[str, Any] | None = None,
    ) -> PolicyEvaluationResult:
        """Evaluate whether a tool invocation is allowed.

        Args:
            principal: The authenticated principal requesting access.
            mcp_server_id: ID of the mcp_server owning the tool.
            tool_name: Name of the tool being invoked.
            context: Optional additional context (group membership, etc.).

        Returns:
            PolicyEvaluationResult with decision and reason.
        """
        ...


class NullAuthorizer:
    """No-op authorizer. Allows all requests.

    Used when the RBAC module is not installed or during testing.
    """

    def authorize(self, request: AuthorizationRequest) -> AuthorizationResult:
        """Allow all requests when no RBAC is configured."""
        return AuthorizationResult.allow(reason="No RBAC configured (null authorizer)")


class NullRoleStore:
    """No-op role store. Returns empty results for all queries.

    Used when the RBAC module is not installed or during testing.
    """

    def add_role(self, role: Role) -> None:
        """No-op: role creation requires the RBAC module."""

    def get_role(self, role_name: str) -> Role | None:
        """No roles defined."""
        return None

    def get_roles_for_principal(
        self,
        principal_id: str,
        scope: str = "*",
    ) -> list[Role]:
        """No roles assigned."""
        return []

    def assign_role(
        self,
        principal_id: str,
        role_name: str,
        scope: str = "global",
        assigned_by: str | None = None,
    ) -> None:
        """No-op: role assignment requires the RBAC module."""

    def revoke_role(
        self,
        principal_id: str,
        role_name: str,
        scope: str = "global",
        revoked_by: str | None = None,
    ) -> None:
        """No-op: role revocation requires the RBAC module."""

    def list_all_roles(self) -> list[Role]:
        """No custom roles defined."""
        return []

    def delete_role(self, role_name: str) -> None:
        """No-op: role deletion requires the RBAC module."""

    def update_role(
        self,
        role_name: str,
        permissions: list[Permission],
        description: str | None = None,
    ) -> Role:
        """No-op: raise NotImplementedError (no role management without the RBAC module)."""
        raise NotImplementedError("Role management requires the RBAC module")


class NullToolAccessPolicyStore:
    """No-op tool access policy store. Returns None for all lookups.

    Used when the policy storage module is not installed or during testing.
    """

    def set_policy(self, scope: str, target_id: str, policy: ToolAccessPolicy) -> None:
        """No-op: policy storage requires the auth module."""

    def get_policy(self, scope: str, target_id: str) -> ToolAccessPolicy | None:
        """No policies stored."""
        return None

    def clear_policy(self, scope: str, target_id: str) -> None:
        """No-op."""

    def list_all_policies(self) -> list[tuple[str, str, ToolAccessPolicy]]:
        """No policies stored."""
        return []


class NullToolAccessPolicyEnforcer:
    """No-op policy enforcer. Allows all tool invocations.

    Used when the policy enforcement module is not installed or during testing.
    """

    def evaluate(
        self,
        principal: Principal,
        tool_name: str,
        mcp_server_id: str | None = None,
        context: dict[str, Any] | None = None,
        **kwargs: Any,
    ) -> PolicyEvaluationResult:
        """Allow all tool invocations when no policy enforcement is configured."""
        return PolicyEvaluationResult.allow(reason="No policy enforcement configured (null enforcer)")


#: The scopes `RBACAuthorizer._collect_roles` actually queries. Anything else is
#: stored and then never read, so the grant silently does nothing.
TENANT_SCOPE_PREFIX = "tenant:"
SUPPORTED_SCOPE_PREFIXES = (TENANT_SCOPE_PREFIX,)
GLOBAL_SCOPE = "global"


def validate_role_scope(scope: str) -> None:
    """Reject a scope no authorizer will ever look up.

    Role collection asks the store for exactly two things: `global`, and
    `tenant:{id}` when the principal carries a tenant. A grant written with any
    other scope -- `*` being the tempting one -- is accepted, persisted, and
    then never matched. That fails closed, so it is not an escalation; it is
    worse in a quieter way. An administrator who grants `*` believes a
    permission exists, sees it in the audit trail, and has in fact granted
    nothing. The next step is usually to reach for something less auditable.

    Raises:
        ValueError: If the scope would never be collected.
    """
    if scope == GLOBAL_SCOPE or scope.startswith(SUPPORTED_SCOPE_PREFIXES):
        return
    raise ValueError(
        f"unsupported role scope {scope!r}: use {GLOBAL_SCOPE!r} or 'tenant:<id>'. "
        "Other scopes are never collected during authorization, so the grant "
        "would be stored and never take effect."
    )
