"""DMS permissions — service layer.

All mutation logic for ACLPolicy, ACLRule, DocumentPermission, and ShareLink
lives here.  Views must not touch ORM directly.

Public surface
--------------
create_policy           — create ACLPolicy attached to a resource
update_policy           — rename / toggle active / toggle inheritable
delete_policy           — soft-delete policy + all its rules

add_rule                — add ACLRule to a policy
remove_rule             — soft-delete an ACLRule

grant_document_permission  — create DirectPermission on a document
revoke_document_permission — soft-delete a DocumentPermission

create_share_link       — create an expiring ShareLink for a document
use_share_link          — validate token + password, increment use_count
revoke_share_link       — deactivate (is_active=False) a ShareLink

resolve_document_permissions  — walk hierarchy, compute effective actions

Domain errors
-------------
ACLError        — base domain error for this bounded context
ShareLinkError  — share link specific errors (expired, exhausted, wrong pw)
"""

from __future__ import annotations

from django.contrib.auth.hashers import check_password, make_password
from django.db import transaction
from django.utils import timezone

from simorgh.apps.dms.permissions.models import (
    ACLAction,
    ACLEffect,
    ACLPolicy,
    ACLPrincipalType,
    ACLRule,
    ACLSubjectType,
    DocumentPermission,
    ShareLink,
)


# ---------------------------------------------------------------------------
# Domain errors
# ---------------------------------------------------------------------------

class ACLError(Exception):
    """Raised for invalid ACL operations."""


class ShareLinkError(Exception):
    """Raised for invalid share link operations."""


# ---------------------------------------------------------------------------
# ACLPolicy
# ---------------------------------------------------------------------------

def create_policy(
    *,
    tenant_id: int,
    organization_node_id: int,
    subject_type: str,
    subject_id: str,
    name: str,
    description: str = "",
    inheritable: bool = True,
) -> ACLPolicy:
    """Create a new ACLPolicy attached to a DMS resource.

    Args:
        subject_type: One of :class:`~.models.ACLSubjectType`.
        subject_id:   ``public_id`` (UUID string) of the target resource.

    Raises:
        ACLError: If ``subject_type`` is not a valid choice.
    """
    if subject_type not in ACLSubjectType.values:
        raise ACLError(f"Invalid subject type: {subject_type!r}")

    return ACLPolicy.objects.create(
        tenant_id=tenant_id,
        organization_node_id=organization_node_id,
        subject_type=subject_type,
        subject_id=str(subject_id),
        name=name,
        description=description,
        inheritable=inheritable,
    )


def update_policy(
    policy: ACLPolicy,
    *,
    name: str | None = None,
    description: str | None = None,
    inheritable: bool | None = None,
    is_active: bool | None = None,
) -> ACLPolicy:
    """Update editable fields of an ACLPolicy."""
    changed = []
    if name is not None:
        policy.name = name
        changed.append("name")
    if description is not None:
        policy.description = description
        changed.append("description")
    if inheritable is not None:
        policy.inheritable = inheritable
        changed.append("inheritable")
    if is_active is not None:
        policy.is_active = is_active
        changed.append("is_active")
    if changed:
        policy.save(update_fields=changed)
    return policy


@transaction.atomic
def delete_policy(policy: ACLPolicy) -> None:
    """Soft-delete the policy and all its rules."""
    ACLRule.objects.filter(policy=policy, is_deleted=False).update(is_deleted=True)
    policy.is_deleted = True
    policy.save(update_fields=["is_deleted"])


# ---------------------------------------------------------------------------
# ACLRule
# ---------------------------------------------------------------------------

def add_rule(
    policy: ACLPolicy,
    *,
    principal_type: str,
    action: str,
    effect: str = ACLEffect.ALLOW,
    principal_id: str = "",
    priority: int = 0,
) -> ACLRule:
    """Add a new rule to an existing ACLPolicy.

    Raises:
        ACLError: If principal_type, action, or effect are invalid.
        ACLError: If the (policy, principal_type, principal_id, action)
                  combination already exists.
    """
    if principal_type not in ACLPrincipalType.values:
        raise ACLError(f"Invalid principal type: {principal_type!r}")
    if action not in ACLAction.values:
        raise ACLError(f"Invalid action: {action!r}")
    if effect not in ACLEffect.values:
        raise ACLError(f"Invalid effect: {effect!r}")
    if principal_type == ACLPrincipalType.EVERYONE:
        principal_id = ""

    if ACLRule.objects.filter(
        policy=policy,
        principal_type=principal_type,
        principal_id=principal_id,
        action=action,
        is_deleted=False,
    ).exists():
        raise ACLError(
            f"A rule for {principal_type}:{principal_id!r} / {action} already exists "
            f"in policy {policy.public_id}."
        )

    return ACLRule.objects.create(
        tenant_id=policy.tenant_id,
        organization_node_id=policy.organization_node_id,
        policy=policy,
        principal_type=principal_type,
        principal_id=principal_id,
        action=action,
        effect=effect,
        priority=priority,
    )


def remove_rule(rule: ACLRule) -> None:
    """Soft-delete an ACLRule."""
    rule.is_deleted = True
    rule.save(update_fields=["is_deleted"])


# ---------------------------------------------------------------------------
# DocumentPermission
# ---------------------------------------------------------------------------

def grant_document_permission(
    *,
    document,
    principal_type: str,
    action: str,
    tenant_id: int,
    organization_node_id: int,
    effect: str = ACLEffect.ALLOW,
    principal_id: str = "",
    granted_by=None,
    expires_at=None,
    inheritable: bool = False,
    notes: str = "",
) -> DocumentPermission:
    """Grant (or deny) a direct permission on a specific document.

    Raises:
        ACLError: If principal_type, action, or effect are invalid.
        ACLError: If an identical (document, principal, action) grant already
                  exists (use revoke first, then re-grant to change effect).
    """
    if principal_type not in ACLPrincipalType.values:
        raise ACLError(f"Invalid principal type: {principal_type!r}")
    if action not in ACLAction.values:
        raise ACLError(f"Invalid action: {action!r}")
    if effect not in ACLEffect.values:
        raise ACLError(f"Invalid effect: {effect!r}")
    if principal_type == ACLPrincipalType.EVERYONE:
        principal_id = ""

    if DocumentPermission.objects.filter(
        document=document,
        principal_type=principal_type,
        principal_id=principal_id,
        action=action,
        is_deleted=False,
    ).exists():
        raise ACLError(
            f"A permission for {principal_type}:{principal_id!r} / {action} already "
            f"exists on document {document.public_id}. Revoke it first."
        )

    return DocumentPermission.objects.create(
        tenant_id=tenant_id,
        organization_node_id=organization_node_id,
        document=document,
        principal_type=principal_type,
        principal_id=principal_id,
        action=action,
        effect=effect,
        granted_by=granted_by,
        expires_at=expires_at,
        inheritable=inheritable,
        notes=notes,
    )


def revoke_document_permission(perm: DocumentPermission) -> None:
    """Soft-delete a DocumentPermission."""
    perm.is_deleted = True
    perm.save(update_fields=["is_deleted"])


# ---------------------------------------------------------------------------
# ShareLink
# ---------------------------------------------------------------------------

def create_share_link(
    *,
    document,
    tenant_id: int,
    organization_node_id: int,
    created_by=None,
    label: str = "",
    expires_at=None,
    max_uses: int | None = None,
    allow_download: bool = True,
    allow_preview: bool = True,
    notes: str = "",
    password: str | None = None,
) -> ShareLink:
    """Create a ShareLink for a document.

    If ``password`` is provided it is hashed with Django's make_password;
    the raw password is never persisted.

    Raises:
        ACLError: If the document is archived or soft-deleted.
    """
    if getattr(document, "is_deleted", False):
        raise ACLError("Cannot create a share link for a deleted document.")

    password_hash = make_password(password) if password else ""

    return ShareLink.objects.create(
        tenant_id=tenant_id,
        organization_node_id=organization_node_id,
        document=document,
        created_by=created_by,
        label=label,
        expires_at=expires_at,
        max_uses=max_uses,
        allow_download=allow_download,
        allow_preview=allow_preview,
        notes=notes,
        password_hash=password_hash,
    )


@transaction.atomic
def use_share_link(share_link: ShareLink, *, password: str | None = None) -> ShareLink:
    """Validate and consume one use of a ShareLink.

    Increments ``use_count`` atomically via select_for_update.

    Raises:
        ShareLinkError: If the link is inactive, expired, exhausted, or the
                        password is wrong.
    """
    # Re-read with lock
    share_link = ShareLink.objects.select_for_update().get(pk=share_link.pk)

    if not share_link.is_active:
        raise ShareLinkError("This share link has been revoked.")
    if share_link.is_deleted:
        raise ShareLinkError("This share link no longer exists.")
    if share_link.is_expired:
        raise ShareLinkError("This share link has expired.")
    if share_link.is_exhausted:
        raise ShareLinkError("This share link has reached its maximum use count.")

    if share_link.password_hash:
        if not password:
            raise ShareLinkError("This share link requires a password.")
        if not check_password(password, share_link.password_hash):
            raise ShareLinkError("Incorrect password.")

    share_link.use_count += 1
    share_link.save(update_fields=["use_count"])
    return share_link


def revoke_share_link(share_link: ShareLink) -> ShareLink:
    """Deactivate a ShareLink (is_active = False).

    The record is kept for auditing; soft-delete is handled separately.
    """
    share_link.is_active = False
    share_link.save(update_fields=["is_active"])
    return share_link


# ---------------------------------------------------------------------------
# Permission resolution
# ---------------------------------------------------------------------------

def resolve_document_permissions(
    document,
    *,
    user_id: str,
    role_slugs: list[str] | None = None,
    org_node_ids: list[str] | None = None,
) -> frozenset[str]:
    """Compute the effective set of allowed ACLActions for a principal.

    Resolution algorithm (highest to lowest priority):
    1. DocumentPermission entries (direct overrides, document-level)
    2. ACLPolicies on the document itself
    3. ACLPolicies on the folder hierarchy (inheritable only)
    4. ACLPolicies on the repository (inheritable only)

    Deny-wins: if the same action appears in both ALLOW and DENY across all
    applicable rules, DENY takes precedence.

    Principals matched:
    - ACLPrincipalType.USER        where principal_id == str(user_id)
    - ACLPrincipalType.ROLE        where principal_id in role_slugs
    - ACLPrincipalType.ORG_NODE    where principal_id in org_node_ids
    - ACLPrincipalType.EVERYONE    (always matched for active tenant members)

    Args:
        document:       DMS Document instance.
        user_id:        String representation of the user's PK.
        role_slugs:     List of IAM role slugs the user holds.
        org_node_ids:   List of organisation node PKs (as strings) the user
                        belongs to.

    Returns:
        frozenset of ACLAction values that are effectively allowed.
    """
    from simorgh.apps.dms.permissions import queries as perm_queries

    role_slugs = role_slugs or []
    org_node_ids = org_node_ids or []
    now = timezone.now()

    allowed: set[str] = set()
    denied: set[str] = set()

    def _matches_principal(p_type: str, p_id: str) -> bool:
        if p_type == ACLPrincipalType.EVERYONE:
            return True
        if p_type == ACLPrincipalType.USER:
            return p_id == str(user_id)
        if p_type == ACLPrincipalType.ROLE:
            return p_id in role_slugs
        if p_type == ACLPrincipalType.ORG_NODE:
            return p_id in org_node_ids
        return False

    # --- Tier 1: DocumentPermission (direct overrides) ---
    for dp in perm_queries.list_document_permissions(document):
        if dp.expires_at is not None and now >= dp.expires_at:
            continue  # skip expired entries
        if not _matches_principal(dp.principal_type, dp.principal_id):
            continue
        if dp.effect == ACLEffect.ALLOW:
            allowed.add(dp.action)
        else:
            denied.add(dp.action)

    # --- Tier 2-4: ACLPolicy rules (document → folders → repository) ---
    applicable_policies = perm_queries.get_effective_policies_for_document(document)
    for policy in applicable_policies:
        if not policy.is_active:
            continue
        for rule in policy.rules.filter(is_deleted=False).order_by("-priority"):
            if not _matches_principal(rule.principal_type, rule.principal_id):
                continue
            if rule.effect == ACLEffect.ALLOW:
                allowed.add(rule.action)
            else:
                denied.add(rule.action)

    # Deny-wins: remove denied actions from allowed set
    return frozenset(allowed - denied)
