"""
"AA-managed role" predicates and persistent-role exclusion.

The audit's role matcher mirrors the naming primitive used by
``allianceauth.services.modules.discord.core.calculate_roles_for_user``
(Group.name and State.name) but enumerates them globally instead
of per-user — the audit's purpose is to identify a *class* of
roles, not to compute a specific user's expected role set.

A ``LINKED_NO_PERM`` member who still has an active
``DiscordUser`` row keeps any roles they legitimately hold via
state/group membership — only roles that AA has no record of
them holding are stripped. This prevents the data-loss class of
bug where a Discord-managed role with an AA-name collision (e.g.
"Nitro Booster" named identically to an AA Group) would be
removed.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Protocol

from django.contrib.auth.models import Group

from .models import ManagedRoleException, OrphanCategory

if TYPE_CHECKING:
    from collections.abc import Iterable

    from allianceauth.services.modules.discord.discord_client.models import (
        GuildMember,
    )


class RoleLike(Protocol):
    """
    Minimum read-only contract for a Discord ``Role``.

    AA core's ``Role`` lives in
    ``allianceauth.services.modules.discord.discord_client.models``
    without published stubs; depending on the concrete class for type
    checks couples the audit to AA-side signature drift. This Protocol
    declares exactly the three attributes the audit reads and lets
    minor AA bumps that add fields go through unnoticed.
    """

    id: int
    name: str
    managed: bool


def aa_role_names() -> frozenset[str]:
    """
    Build the global AA-managed role-name set.

    Group.name and State.name can collide (each is independently
    ``unique=True`` on its own model). The set union dedupes
    silently — accepted behavior.
    """
    # Lazy import — AA's authentication models pull a fair amount.
    from itertools import chain

    from allianceauth.authentication.models import State

    return frozenset(
        chain(
            Group.objects.values_list("name", flat=True),
            State.objects.values_list("name", flat=True),
        )
    )


def reserved_role_names() -> frozenset[str]:
    """
    Read AA's ReservedGroupName table.

    The reserved-name list lives in
    ``allianceauth.groupmanagement`` — names listed there cannot
    be used as AA Groups regardless of what other code does.
    """
    from allianceauth.groupmanagement.models import ReservedGroupName

    return frozenset(ReservedGroupName.objects.values_list("name", flat=True))


def exempt_role_ids() -> frozenset[int]:
    """Operator-managed exception list (admin-editable)."""
    return frozenset(
        ManagedRoleException.objects.values_list("role_id", flat=True)
    )


def persistent_role_ids_for(
    member: GuildMember,  # noqa: ARG001 — kept for API symmetry with derive_managed_role_ids
    *,
    role_catalog: Iterable[RoleLike],
    aa_user_legitimate_role_ids: frozenset[int] | None = None,
    member_category: OrphanCategory | None = None,
    reserved_names: frozenset[str],
) -> frozenset[int]:
    """
    Return the role-ids that must NOT be stripped for ``member``.

    Composition:

    - Discord-managed roles (``role.managed=True``) — these are
      created by integrations and cannot be removed via the API
      anyway; including them in the persistent set produces a
      cleaner skip path than letting the API 403.
    - Roles whose name appears in ``ReservedGroupName``.
    - For ``LINKED_NO_PERM`` members with an active ``DiscordUser``
      row: every role they legitimately hold via state / group
      membership (``aa_user_legitimate_role_ids``). This is the
      key data-loss-prevention rule: an ex-AA-perm user keeps the
      roles their remaining state/group memberships still grant
      them; only the role-class they have no AA basis to hold is
      stripped.
    """
    persistent: set[int] = set()
    for role in role_catalog:
        if role.managed or role.name in reserved_names:
            persistent.add(role.id)
    if (
        member_category == OrphanCategory.LINKED_NO_PERM
        and aa_user_legitimate_role_ids is not None
    ):
        persistent |= aa_user_legitimate_role_ids
    return frozenset(persistent)


def managed_by_aa(
    role: RoleLike,
    *,
    aa_role_names_set: frozenset[str],
    reserved_names_set: frozenset[str],
    exempt_ids: frozenset[int],
    persistent_ids: frozenset[int],
) -> bool:
    """Return True if this role should be treated as AA-managed."""
    return (
        role.id not in exempt_ids
        and not role.managed
        and role.name not in reserved_names_set
        and role.name in aa_role_names_set
        and role.id not in persistent_ids
    )


def derive_managed_role_ids(
    member: GuildMember,
    *,
    role_catalog: Iterable[RoleLike],
    aa_role_names_set: frozenset[str],
    reserved_names_set: frozenset[str],
    exempt_ids: frozenset[int],
    persistent_ids: frozenset[int],
) -> frozenset[int]:
    """
    Return the subset of ``member.roles`` that the audit considers AA-managed.

    Used by ``apply_finding`` to compute the exact role-removal set
    just before issuing the Discord API calls.
    """
    role_by_id = {r.id: r for r in role_catalog}
    return frozenset(
        role.id
        for role_id in member.roles
        if (role := role_by_id.get(role_id)) is not None
        and managed_by_aa(
            role,
            aa_role_names_set=aa_role_names_set,
            reserved_names_set=reserved_names_set,
            exempt_ids=exempt_ids,
            persistent_ids=persistent_ids,
        )
    )
