"""Canonical authorization decisions for project-scoped membership (design §3.1–3.6).

Pure functions over snapshots loaded from canonical Core state. Decisions use
permissions, the protected flag, and the explicit assignment tier of the role.
They never use role names, and they never accept a tier from a request.
"""

from __future__ import annotations

from collections.abc import Iterable
from dataclasses import dataclass
from enum import StrEnum
from uuid import UUID

from src.domain.project_auth import permissions as perms


class Action(StrEnum):
    grant = "grant"
    revoke = "revoke"
    suspend = "suspend"
    reinstate = "reinstate"


class AssignmentTier(StrEnum):
    admin = "admin"
    staff = "staff"
    member = "member"


class MembershipState(StrEnum):
    active = "active"
    suspended = "suspended"


DENY_BOUNDARY = "boundary_mismatch"
DENY_INACTIVE = "membership_inactive"
DENY_PROTECTED = "role_protected"
DENY_TIER_UNCLASSIFIED = "role_tier_unclassified"
DENY_NOT_GRANTABLE = "role_not_grantable"
DENY_PERMISSION = "permission_denied"
DENY_SELF_SUSPEND = "self_suspend_denied"
DENY_OWNER_INVARIANT = "owner_invariant"


@dataclass(frozen=True)
class Decision:
    allowed: bool
    code: str | None = None

    @classmethod
    def allow(cls) -> Decision:
        return cls(True, None)

    @classmethod
    def deny(cls, code: str) -> Decision:
        return cls(False, code)


@dataclass(frozen=True)
class Actor:
    project_id: UUID
    membership_id: UUID
    state: MembershipState
    permissions: frozenset[str]


@dataclass(frozen=True)
class Target:
    """holds_admin: the membership currently holds a role whose tier is admin."""

    project_id: UUID
    membership_id: UUID
    state: MembershipState
    is_owner: bool
    holds_admin: bool


@dataclass(frozen=True)
class RoleDescriptor:
    role_id: UUID
    assignment_tier: AssignmentTier | None
    protected: bool
    permissions: frozenset[str]


def permissions_from_roles(role_permission_lists: Iterable[Iterable[str]]) -> frozenset[str]:
    """Union of permissions across a membership's project-scope roles."""
    out: set[str] = set()
    for group in role_permission_lists:
        out.update(group)
    return frozenset(out)


_ROLE_CHANGE_PERMISSION: dict[tuple[Action, AssignmentTier], str] = {
    (Action.grant, AssignmentTier.admin): perms.ROLE_GRANT_ADMIN,
    (Action.revoke, AssignmentTier.admin): perms.ROLE_REVOKE_ADMIN,
    (Action.grant, AssignmentTier.staff): perms.ROLE_GRANT_STAFF,
    (Action.revoke, AssignmentTier.staff): perms.ROLE_REVOKE_STAFF,
    (Action.grant, AssignmentTier.member): perms.ROLE_GRANT_MEMBER,
    (Action.revoke, AssignmentTier.member): perms.ROLE_REVOKE_MEMBER,
}


def _boundary_and_active(actor: Actor, target: Target, project_id: UUID) -> Decision | None:
    if actor.project_id != project_id or target.project_id != project_id:
        return Decision.deny(DENY_BOUNDARY)
    if actor.state is not MembershipState.active:
        return Decision.deny(DENY_INACTIVE)
    return None


def can(actor: Actor, permission: str, project_id: UUID) -> Decision:
    """Project-scope permission check. Default deny."""
    if actor.project_id != project_id:
        return Decision.deny(DENY_BOUNDARY)
    if actor.state is not MembershipState.active:
        return Decision.deny(DENY_INACTIVE)
    if permission not in perms.VOCABULARY or permission not in actor.permissions:
        return Decision.deny(DENY_PERMISSION)
    return Decision.allow()


def authorize_role_change(
    actor: Actor,
    target: Target,
    role: RoleDescriptor,
    *,
    project_id: UUID,
    action: Action,
) -> Decision:
    """Grant or revoke a role on a target membership (design §3.4).

    Order: boundary, active actor, protected target or protected role, unclassified
    role, permission for the role's assignment tier.
    """
    early = _boundary_and_active(actor, target, project_id)
    if early:
        return early
    if target.is_owner or role.protected:
        return Decision.deny(DENY_PROTECTED)
    if role.assignment_tier is None:
        return Decision.deny(DENY_TIER_UNCLASSIFIED)
    if action not in (Action.grant, Action.revoke):
        return Decision.deny(DENY_NOT_GRANTABLE)
    required = _ROLE_CHANGE_PERMISSION[(action, role.assignment_tier)]
    if required not in actor.permissions:
        return Decision.deny(DENY_PERMISSION)
    return Decision.allow()


def authorize_suspension(
    actor: Actor,
    target: Target,
    *,
    project_id: UUID,
    action: Action,
) -> Decision:
    """Suspend or reinstate a target membership (design §3.6, D11, D12).

    Order: boundary, active actor, self-suspension, protected target, permission.
    """
    if action not in (Action.suspend, Action.reinstate):
        return Decision.deny(DENY_NOT_GRANTABLE)
    early = _boundary_and_active(actor, target, project_id)
    if early:
        return early
    if action is Action.suspend and actor.membership_id == target.membership_id:
        return Decision.deny(DENY_SELF_SUSPEND)
    if target.is_owner:
        return Decision.deny(DENY_PROTECTED)
    if target.holds_admin:
        required = (
            perms.MEMBER_SUSPEND_ADMIN if action is Action.suspend else perms.MEMBER_REINSTATE_ADMIN
        )
    else:
        required = perms.MEMBER_SUSPEND if action is Action.suspend else perms.MEMBER_REINSTATE
    if required not in actor.permissions:
        return Decision.deny(DENY_PERMISSION)
    return Decision.allow()


DENY_SOURCE_NOT_RELEASABLE = "hold_source_not_releasable"


def authorize_hold_release(
    actor: Actor,
    target: Target,
    *,
    project_id: UUID,
    source: str,
) -> Decision:
    """Release one source's suspension hold (design D30, §3.6).

    workspace holds are managed only by Workspace reconciliation. local holds follow the
    reinstate permissions. legacy holds (unknown provenance) require the owner-only
    member.reinstate_legacy permission, so admins can never clear them by accident.
    """
    early = _boundary_and_active(actor, target, project_id)
    if early:
        return early
    if source == "workspace":
        return Decision.deny(DENY_SOURCE_NOT_RELEASABLE)
    if target.is_owner:
        return Decision.deny(DENY_PROTECTED)
    if source == "legacy":
        required = perms.MEMBER_REINSTATE_LEGACY
    elif source == "local":
        required = perms.MEMBER_REINSTATE_ADMIN if target.holds_admin else perms.MEMBER_REINSTATE
    else:
        return Decision.deny(DENY_NOT_GRANTABLE)
    if required not in actor.permissions:
        return Decision.deny(DENY_PERMISSION)
    return Decision.allow()


def check_owner_invariant(active_owner_count: int) -> Decision:
    """Every Authorization v2 project has exactly one active owner (D13)."""
    if active_owner_count == 1:
        return Decision.allow()
    return Decision.deny(DENY_OWNER_INVARIANT)
