"""Permission handlers for the messages core app."""

from django.core import exceptions

from rest_framework import permissions
from rest_framework.exceptions import PermissionDenied

from core import enums, models

ACTION_FOR_METHOD_TO_PERMISSION = {
    "versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"},
    "children": {"GET": "children_list", "POST": "children_create"},
}


class DenyAll(permissions.BasePermission):
    """Always denies access. Used to disable endpoints via feature flags."""

    def has_permission(self, request, view):
        return False


class IsAuthenticated(permissions.BasePermission):
    """
    Allows access only to authenticated users. Alternative method checking the presence
    of the auth token to avoid hitting the database.
    """

    def has_permission(self, request, view):
        return bool(request.auth) or request.user.is_authenticated


class IsSuperUser(permissions.IsAdminUser):
    """Allows access only to superusers users."""

    def has_permission(self, request, view):
        return request.user and (request.user.is_superuser)


class IsAuthenticatedOrSafe(IsAuthenticated):
    """Allows access to authenticated users (or anonymous users but only on safe methods)."""

    def has_permission(self, request, view):
        if request.method in permissions.SAFE_METHODS:
            return True
        return super().has_permission(request, view)


class IsSelf(IsAuthenticated):
    """
    Allows access only to authenticated users. Alternative method checking the presence
    of the auth token to avoid hitting the database.
    """

    def has_object_permission(self, request, view, obj):
        """Write permissions are only allowed to the user itself."""
        return obj == request.user


class IsOwnedOrPublic(IsAuthenticated):
    """
    Allows access to authenticated users only for objects that are owned or not related
    to any user via the "owner" field.
    """

    def has_object_permission(self, request, view, obj):
        """Unsafe permissions are only allowed for the owner of the object."""
        if obj.owner == request.user:
            return True

        if request.method in permissions.SAFE_METHODS and obj.owner is None:
            return True

        try:
            return obj.user == request.user
        except exceptions.ObjectDoesNotExist:
            return False


class AccessPermission(permissions.BasePermission):
    """Permission class for access objects."""

    def has_permission(self, request, view):
        return request.user.is_authenticated or view.action != "create"

    def has_object_permission(self, request, view, obj):
        """Check permission for a given object."""
        abilities = obj.get_abilities(request.user)
        action = view.action
        try:
            action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
        except KeyError:
            pass
        return abilities.get(action, False)


class IsAllowedToAccess(IsAuthenticated):
    """Permission class for access to a mailbox context or specific threads/messages."""

    def has_permission(self, request, view):
        """Check if user has permission to access the mailbox thread list or message list.
        Only role VIEWER is required to access the mailbox threads/messages.
        So we just need to check if user has any access role on mailbox or thread.
        """

        if not IsAuthenticated.has_permission(self, request, view):
            return False

        # For nested routes under /threads/{thread_id}/events/ or /threads/{thread_id}/accesses/
        # URL path kwargs take priority — ignore query params when URL provides thread_id
        thread_id_from_url = view.kwargs.get("thread_id")

        # Query params are only used for flat routes (Thread list, Message list)
        mailbox_id = (
            request.query_params.get("mailbox_id") if not thread_id_from_url else None
        )
        thread_id = (
            request.query_params.get("thread_id") if not thread_id_from_url else None
        )

        # If it's a detail action (retrieve, update, destroy), object-level permission is checked
        # by has_object_permission. If it's a list action without filters, deny access.
        is_list_action = hasattr(view, "action") and view.action == "list"

        if not is_list_action:
            # For create action on nested routes, check full edit rights
            # on the thread: EDITOR ThreadAccess role AND CAN_EDIT MailboxAccess role.
            if (
                thread_id_from_url
                and hasattr(view, "action")
                and view.action == "create"
            ):
                return (
                    models.ThreadAccess.objects.editable_by(request.user)
                    .filter(thread_id=thread_id_from_url)
                    .exists()
                )
            # Allow non-list actions (like detail views or specific APIViews like SendMessageView)
            # to proceed to object-level checks or handle permissions within the view.
            return True

        # --- The following logic only applies if is_list_action is True --- #
        # Check access for nested thread routes (e.g., /threads/{id}/events/)
        if thread_id_from_url:
            return models.ThreadAccess.objects.filter(
                thread_id=thread_id_from_url,
                mailbox__accesses__user=request.user,
            ).exists()

        # Check access based on query params for LIST action
        if thread_id:
            # Check if the user has access to this specific thread to list messages
            return models.ThreadAccess.objects.filter(
                thread_id=thread_id, mailbox__accesses__user=request.user
            ).exists()
        if mailbox_id:
            # Check if the user has access to this specific mailbox to list threads
            return models.Mailbox.objects.filter(
                id=mailbox_id, accesses__user=request.user
            ).exists()

        return False  # Should not be reached if logic above is correct

    def has_object_permission(self, request, view, obj):
        """Check if user has permission to access the specific object (Message, Thread, Mailbox)."""
        user = request.user
        if isinstance(obj, models.Mailbox):
            # Check access directly on the mailbox
            return models.MailboxAccess.objects.filter(mailbox=obj, user=user).exists()

        if isinstance(obj, models.ThreadEvent):
            # Write actions are handled by HasThreadEditAccess, so we only
            # need to gate read access on any ThreadAccess for the thread.
            return models.ThreadAccess.objects.filter(
                thread=obj.thread, mailbox__accesses__user=user
            ).exists()

        if isinstance(obj, (models.Message, models.Thread)):
            thread = obj.thread if isinstance(obj, models.Message) else obj
            # Check access via the message's thread using ThreadAccess
            # First, just check if *any* access exists for the user to this thread.
            has_access = models.ThreadAccess.objects.filter(
                thread=thread, mailbox__accesses__user=user
            ).exists()
            if not has_access:
                return False

            # Only EDITOR, SENDER or ADMIN role can destroy/send/delivery_statuses.
            if view.action in ["destroy", "send", "delivery_statuses"]:
                # filter only mailboxes with editor role on thread
                # and check if user has enough role on those mailboxes
                if view.action in ["destroy", "delivery_statuses"]:
                    role_list = enums.MAILBOX_ROLES_CAN_EDIT
                else:
                    role_list = enums.MAILBOX_ROLES_CAN_SEND
                return thread.accesses.filter(
                    role=enums.ThreadAccessRoleChoices.EDITOR,
                    mailbox__accesses__user=user,
                    mailbox__accesses__role__in=role_list,
                ).exists()
            # for retrieve action has_access is already checked above
            return True

        # Deny access for other object types or if type is unknown
        return False


def _user_can_manage_thread_access(user, thread_id):
    """True if ``user`` has full edit rights on the thread.

    Full edit rights on a thread are the prerequisite for managing its
    ``ThreadAccess`` rows (list, create, update, destroy peers' access).
    They require an EDITOR ``ThreadAccess`` on the thread via a mailbox
    where the user holds ``MAILBOX_ROLES_CAN_EDIT``.
    """
    return (
        models.ThreadAccess.objects.editable_by(user)
        .filter(thread_id=thread_id)
        .exists()
    )


class IsAllowedToManageThreadAccess(IsAuthenticated):
    """Permission class for access to create, update, delete and list thread accesses."""

    def has_permission(self, request, view):
        # Get thread_id from URL kwargs instead of query params/data
        thread_id = view.kwargs.get("thread_id")
        if not thread_id:
            return False

        if view.action in ("create", "list"):
            return _user_can_manage_thread_access(request.user, thread_id)

        return True  # to proceed to object-level checks

    def has_object_permission(self, request, view, obj):
        """Check if user has permission to access the specific object (ThreadAccess).
        Manage retrieve, update, destroy actions here.
        """
        # Verify the thread access belongs to the thread in the URL
        if obj.thread.id != view.kwargs.get("thread_id"):
            return False

        can_manage_thread_access = _user_can_manage_thread_access(
            request.user, obj.thread_id
        )

        # Destroy is symmetric with create: any user who can manage the
        # thread's accesses (full edit rights on the thread) can revoke a
        # peer's ThreadAccess. Additionally users who only have edit-rights
        # on the mailbox being removed can leave the thread.
        if view.action == "destroy" and not can_manage_thread_access:
            return obj.mailbox.accesses.filter(
                user=request.user,
                role__in=enums.MAILBOX_ROLES_CAN_EDIT,
            ).exists()

        return can_manage_thread_access


class IsMailDomainAdmin(permissions.BasePermission):
    """
    Allows access only to users who have ADMIN MailDomainAccess
    to the maildomain specified by 'maildomain_pk' in the URL.
    Used for viewsets nested under a maildomain.
    If no maildomain_pk is provided, the permission fails.
    """

    message = "You do not have administrative rights for this mail domain."

    def has_permission(self, request, view):
        if not request.user or not request.user.is_authenticated:
            return False

        qs_filters = {
            "user": request.user,
            "role": models.MailDomainAccessRoleChoices.ADMIN,
        }
        maildomain_pk = view.kwargs.get("maildomain_pk")

        if maildomain_pk:
            qs_filters["maildomain_id"] = maildomain_pk

        return models.MailDomainAccess.objects.filter(**qs_filters).exists()

    # No has_object_permission, assumes objects are correctly scoped by view's get_queryset
    # based on the maildomain_pk.


class IsMailboxAdmin(permissions.BasePermission):
    """
    Allows access if the user has ADMIN MailboxAccess to the specific Mailbox
    identified by `view.kwargs['mailbox_id']`, OR if the user has ADMIN
    MailDomainAccess to the domain of that Mailbox.
    """

    message = "You do not have administrative rights for this mailbox or its domain."

    def has_permission(self, request, view):
        if not request.user or not request.user.is_authenticated:
            return False

        user = request.user
        mailbox_id_from_url = view.kwargs.get("mailbox_id")
        if not mailbox_id_from_url:
            return False  # Should not happen with correct URL configuration

        try:
            target_mailbox = models.Mailbox.objects.select_related("domain").get(
                pk=mailbox_id_from_url
            )
        except (models.Mailbox.DoesNotExist, ValueError):  # ValueError for invalid UUID
            return False

        # Check 1 : Is user a super user ?
        if user.is_superuser:
            return True

        # Check 2: Is user an admin of the specific mailbox?
        is_mailbox_admin = models.MailboxAccess.objects.filter(
            user=user, mailbox=target_mailbox, role=models.MailboxRoleChoices.ADMIN
        ).exists()

        if is_mailbox_admin:
            return True

        # Check 3: Is user an admin of the mailbox's domain?
        if target_mailbox.domain:
            is_domain_admin = models.MailDomainAccess.objects.filter(
                user=user,
                maildomain=target_mailbox.domain,
                role=models.MailDomainAccessRoleChoices.ADMIN,
            ).exists()
            if is_domain_admin:
                return True

        return False

    def has_object_permission(self, request, view, obj):
        # obj is a MailboxAccess instance.
        if not request.user or not request.user.is_authenticated:
            return False

        if not hasattr(obj, "mailbox") or not obj.mailbox or not obj.mailbox.domain:
            return False  # MailboxAccess must be linked to a Mailbox with a Domain

        # Ensure the object being acted upon belongs to the mailbox specified in the URL
        mailbox_id_from_url = view.kwargs.get("mailbox_id")
        if str(obj.mailbox.id) != str(mailbox_id_from_url):
            return False  # Object's mailbox does not match URL mailbox

        user = request.user
        target_mailbox = obj.mailbox  # The mailbox related to the MailboxAccess object

        # Check 1 : Is user a super user ?
        if user.is_superuser:
            return True

        # Check 2: Is user an admin of this specific mailbox?
        is_mailbox_admin = models.MailboxAccess.objects.filter(
            user=user, mailbox=target_mailbox, role=models.MailboxRoleChoices.ADMIN
        ).exists()

        if is_mailbox_admin:
            return True

        # Check 3: Is user an admin of the mailbox's domain?
        is_domain_admin = models.MailDomainAccess.objects.filter(
            user=user,
            maildomain=target_mailbox.domain,
            role=models.MailDomainAccessRoleChoices.ADMIN,
        ).exists()

        return is_domain_admin


class IsMailboxAdminObject(permissions.BasePermission):
    """Object-level permission granting access on a Mailbox instance to its
    admins (MailboxAccess role ADMIN), its domain admins, or superusers.

    Unlike :class:`IsMailboxAdmin`, this works on the resolved Mailbox object
    (e.g. ``/mailboxes/{pk}/``) rather than a nested ``mailbox_id`` URL kwarg.
    """

    message = "You do not have administrative rights for this mailbox or its domain."

    def has_object_permission(self, request, view, obj):
        user = request.user
        if not user or not user.is_authenticated:
            return False

        if user.is_superuser:
            return True

        is_mailbox_admin = models.MailboxAccess.objects.filter(
            user=user, mailbox=obj, role=models.MailboxRoleChoices.ADMIN
        ).exists()
        if is_mailbox_admin:
            return True

        if obj.domain:
            return models.MailDomainAccess.objects.filter(
                user=user,
                maildomain=obj.domain,
                role=models.MailDomainAccessRoleChoices.ADMIN,
            ).exists()

        return False


class HasChannelScope(permissions.BasePermission):
    """Scope-based permission for service calls authenticated as a Channel.

    Auth-scheme-agnostic: it only inspects ``request.auth`` (which must be a
    ``Channel`` set by an authentication class like ``ChannelApiKeyAuthentication``)
    and checks that ``required_scope`` is listed in the channel's
    ``settings["scopes"]``. Additionally, if the required scope is in
    ``CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY``, the calling channel must itself
    be ``scope_level=global`` — this is the second of two enforcement points
    for global-only scopes (the first is the serializer at write time).

    The view remains responsible for resource-level bounds via
    ``request.auth.api_key_covers(...)`` — this class does not know which
    mailbox or domain the action is about to touch.

    Subclasses set ``required_scope`` — use the ``channel_scope()`` factory
    below to create one on the fly so ``permission_classes`` can reference it
    declaratively.
    """

    required_scope: str = ""

    def has_permission(self, request, view):
        channel = request.auth
        if not isinstance(channel, models.Channel):
            return False
        if not isinstance(channel.settings, dict):
            return False
        scopes = channel.settings.get("scopes")
        if not isinstance(scopes, list) or not all(isinstance(s, str) for s in scopes):
            return False
        if self.required_scope not in scopes:
            return False
        if (
            self.required_scope in enums.CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY
            and channel.scope_level != enums.ChannelScopeLevel.GLOBAL
        ):
            return False
        return True


def channel_scope(required_scope: str) -> type:
    """Return a ``HasChannelScope`` subclass with ``required_scope`` pre-bound.

    DRF's ``permission_classes`` expects a list of classes, not instances, so
    we synthesize a tiny subclass per scope. Usage:

        permission_classes = [channel_scope(ChannelApiKeyScope.MESSAGES_SEND)]
    """
    return type(
        f"HasChannelScope_{required_scope}",
        (HasChannelScope,),
        {"required_scope": str(required_scope)},
    )


class IsGlobalChannelMixin:
    """Mixin for APIViews that require ``request.auth`` to be a Channel
    with ``scope_level=global``. Drop this onto a view *in addition to*
    ``ChannelApiKeyAuthentication`` + ``channel_scope(...)``.

    Two-layer defense in depth: even when the required scope is in
    ``CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY`` (and ``HasChannelScope`` already
    rejects non-global callers), the view itself re-asserts the requirement
    so a future scope-set typo can't accidentally weaken the endpoint. The
    mixin's ``initial()`` runs after authentication+permission, so by the
    time it executes ``request.auth`` is guaranteed to be a Channel.
    """

    def initial(self, request, *args, **kwargs):
        """Re-assert scope_level=global after authentication+permission run."""
        super().initial(request, *args, **kwargs)
        channel = getattr(request, "auth", None)
        if (
            not isinstance(channel, models.Channel)
            or channel.scope_level != enums.ChannelScopeLevel.GLOBAL
        ):
            # Generic message — do not leak the scope_level requirement
            # to the caller.
            raise PermissionDenied()


def _user_can_comment_on_thread(user, thread_id):
    """True if ``user`` can post/read internal comments on ``thread_id``.

    Requires any ``ThreadAccess`` (viewer or editor) on a mailbox where the
    user has ``MAILBOX_ROLES_CAN_EDIT``. Writing an internal comment is a
    personal authoring act that does not mutate the thread's shared state,
    so VIEWER ThreadAccess is enough as long as the user is at least editor
    on the mailbox.
    """
    return models.ThreadAccess.objects.filter(
        thread_id=thread_id,
        mailbox__accesses__user=user,
        mailbox__accesses__role__in=enums.MAILBOX_ROLES_CAN_EDIT,
    ).exists()


def _is_thread_event_mutation_by_non_author(request, view, obj):
    """True when a ThreadEvent update/destroy is attempted by a non-author.

    Shared owner-only invariant enforced by every permission class that
    guards ThreadEvent writes — factored out so the rule is expressed once
    and the two classes below cannot drift.
    """
    if not isinstance(obj, models.ThreadEvent):
        return False
    if view.action not in ("update", "partial_update", "destroy"):
        return False
    return obj.author_id != request.user.id


class HasThreadCommentAccess(IsAuthenticated):
    """Allows users who can author internal comments on the thread.

    Used for endpoints that serve the comment authoring flow (posting an
    ``im`` ThreadEvent, listing thread users to pick mention targets): both
    must stay accessible to Thread VIEWERs as long as they are at least
    editor on one of the mailboxes sharing the thread.
    """

    def has_permission(self, request, view):
        if not super().has_permission(request, view):
            return False

        thread_id = view.kwargs.get("thread_id")
        if not thread_id:
            return False

        return _user_can_comment_on_thread(request.user, thread_id)


class HasThreadEventWriteAccess(IsAuthenticated):
    """Gates write actions on ThreadEvent — rules depend on the event type.

    Only ``im`` events (internal comments) relax the thread-level role: a
    Thread VIEWER with mailbox edit rights can create/update/destroy their
    own ``im`` events. Any other event type is considered a shared-state
    mutation (system event, status change, …) and keeps the stricter
    full-edit-rights policy (``MailboxAccess`` in ``MAILBOX_ROLES_CAN_EDIT``
    AND ``ThreadAccess.role == EDITOR``).

    Author check for update/destroy is enforced here regardless of type —
    no user can edit or delete another user's event.
    """

    message = "You do not have permission to perform this action on this thread."

    def has_permission(self, request, view):
        if not super().has_permission(request, view):
            return False

        thread_id = view.kwargs.get("thread_id")
        if thread_id is None:
            return True

        if view.action == "create":
            event_type = request.data.get("type") if hasattr(request, "data") else None
            if event_type == enums.ThreadEventTypeChoices.IM:
                return _user_can_comment_on_thread(request.user, thread_id)
            # Non-IM (or missing) types require full edit rights. Using the
            # stricter path as the default keeps unknown types safe.
            return (
                models.ThreadAccess.objects.editable_by(request.user)
                .filter(thread_id=thread_id)
                .exists()
            )

        # update / partial_update / destroy: defer to object-level check
        # (type is resolved from the stored instance, not the payload).
        return True

    def has_object_permission(self, request, view, obj):
        if not isinstance(obj, models.ThreadEvent):
            return False

        if _is_thread_event_mutation_by_non_author(request, view, obj):
            return False

        if obj.type == enums.ThreadEventTypeChoices.IM:
            return _user_can_comment_on_thread(request.user, obj.thread_id)

        return obj.thread.get_abilities(request.user)[enums.ThreadAbilities.CAN_EDIT]


class HasThreadEditAccess(IsAuthenticated):
    """Allows access only to users with full edit rights on the thread.

    Full edit rights require BOTH:
    - `ThreadAccess.role == EDITOR` on the thread
    - A `MailboxAccess.role` in `MAILBOX_ROLES_CAN_EDIT` on the same mailbox

    This prevents a user with VIEWER MailboxAccess on a shared mailbox
    from mutating threads that the mailbox has EDITOR ThreadAccess to.
    """

    message = "You do not have permission to perform this action on this thread."

    def has_permission(self, request, view):
        """Check editor access up-front on nested thread routes.

        On nested routes (e.g. ``/threads/{thread_id}/events/``), the thread
        id comes from the URL and we can enforce the editor role before the
        view resolves any object — required for ``create`` where no object
        exists yet. On top-level routes (e.g. ``/threads/{pk}/``), defer to
        ``has_object_permission``.
        """
        if not super().has_permission(request, view):
            return False

        thread_id_from_url = view.kwargs.get("thread_id")
        if thread_id_from_url is None:
            return True

        return (
            models.ThreadAccess.objects.editable_by(request.user)
            .filter(thread_id=thread_id_from_url)
            .exists()
        )

    def has_object_permission(self, request, view, obj):
        """Check editor access on the thread the object belongs to.

        Supports both ``Thread`` and ``ThreadEvent`` objects. For
        ``ThreadEvent``, also enforces that only the event author can perform
        update or destroy actions.
        """
        if _is_thread_event_mutation_by_non_author(request, view, obj):
            return False

        thread = obj.thread if isinstance(obj, models.ThreadEvent) else obj
        return thread.get_abilities(request.user)[enums.ThreadAbilities.CAN_EDIT]


class HasAccessToMailbox(IsAuthenticated):
    """Allows access only to users with the access to the mailbox."""

    def has_permission(self, request, view):
        if not super().has_permission(request, view):
            return False

        return models.MailboxAccess.objects.filter(
            user=request.user, mailbox=view.kwargs.get("mailbox_id")
        ).exists()


class HasWriteAccessToMailbox(IsAuthenticated):
    """Allows access only to users with an editor-or-above role on the mailbox.

    Use for state-changing endpoints whose effect is observable beyond the
    mailbox itself (e.g. writing to the mailbox's CalDAV calendar, which a
    VIEWER access shouldn't be able to do).
    """

    def has_permission(self, request, view):
        if not super().has_permission(request, view):
            return False

        return models.MailboxAccess.objects.filter(
            user=request.user,
            mailbox=view.kwargs.get("mailbox_id"),
            role__in=enums.MAILBOX_ROLES_CAN_EDIT,
        ).exists()
