"""
Classification + action-resolution primitives.

Re-exports ``OrphanCategory`` and ``OrphanAction`` from
``models`` for callers that don't need the model layer (e.g.
type hints in audit / persistence). The ``classify`` function
is the canonical entry point for converting a Discord
``GuildMember`` into ``(category, aa_user)`` per the audit's
identity model.

The ``Policy`` type maps each ``OrphanCategory`` to a
``CategoryPolicy`` envelope: a default action plus optional
per-group and per-state overrides keyed by AA group name and
state name. JSON shape accepts either a bare action string
(equivalent to ``{"default": <action>}``) or the full nested
form ``{"default", "by_group": {…}, "by_state": {…}}``.
"""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass, field
from types import MappingProxyType
from typing import TYPE_CHECKING, cast

from django.contrib.auth import get_user_model
from django.db.models import F, Q

from .app_settings import AA_DISCORD_AUDIT_POLICY
from .models import (
    BotAccountUid,
    DiscordIdentity,
    OrphanAction,
    OrphanCategory,
)

if TYPE_CHECKING:
    from allianceauth.services.modules.discord.discord_client.models import (
        GuildMember,
    )
    from django.contrib.auth.models import AbstractUser

User = get_user_model()


@dataclass(frozen=True)
class CategoryPolicy:
    """
    Per-category action resolution envelope.

    ``default`` is the fall-through action when neither a per-
    group nor a per-state match wins. ``by_group`` and ``by_state``
    map AA group names / state names to the action to apply when
    the finding's linked AA user is a member of the named group
    or has the named state. ``by_group`` is consulted first, then
    ``by_state``, then ``default`` — operators wanting state-only
    routing should leave ``by_group`` empty.

    Both maps are wrapped in ``MappingProxyType`` at construction
    so the dataclass's ``frozen=True`` envelope cannot be defeated
    by mutating a dict literal someone else still holds a
    reference to.
    """

    default: OrphanAction
    by_group: Mapping[str, OrphanAction] = field(
        default_factory=lambda: MappingProxyType({})
    )
    by_state: Mapping[str, OrphanAction] = field(
        default_factory=lambda: MappingProxyType({})
    )

    @classmethod
    def from_json(cls, value: object) -> CategoryPolicy:
        """
        Build a ``CategoryPolicy`` from a JSON-shaped value.

        Accepts either a bare action string (parsed as
        ``{"default": <action>}``) or a dict carrying
        ``default`` and the optional ``by_group`` / ``by_state``
        maps.
        """
        if isinstance(value, str):
            return cls(default=OrphanAction(value))
        if not isinstance(value, Mapping):
            raise TypeError(
                f"policy entry expected str or Mapping, got "
                f"{type(value).__name__}"
            )
        if "default" not in value:
            raise ValueError("policy entry missing required 'default' key")
        default = OrphanAction(value["default"])
        by_group_raw = value.get("by_group") or {}
        by_state_raw = value.get("by_state") or {}
        if not isinstance(by_group_raw, Mapping):
            raise TypeError("policy entry 'by_group' must be a mapping")
        if not isinstance(by_state_raw, Mapping):
            raise TypeError("policy entry 'by_state' must be a mapping")
        return cls(
            default=default,
            by_group=MappingProxyType(
                {k: OrphanAction(v) for k, v in by_group_raw.items()}
            ),
            by_state=MappingProxyType(
                {k: OrphanAction(v) for k, v in by_state_raw.items()}
            ),
        )

    def to_json(self) -> dict[str, object]:
        """Serialize back to the JSON shape expected by ``policy_snapshot``."""
        out: dict[str, object] = {"default": self.default.value}
        if self.by_group:
            out["by_group"] = {k: v.value for k, v in self.by_group.items()}
        if self.by_state:
            out["by_state"] = {k: v.value for k, v in self.by_state.items()}
        return out


# Frozen policy mapping consumed by ``resolve_action`` and the
# ``AuditPlan`` dataclass. Lifted to a module-level alias so the
# domain term has a single canonical name.
Policy = Mapping[OrphanCategory, CategoryPolicy]

__all__ = [
    "CategoryPolicy",
    "OrphanAction",
    "OrphanCategory",
    "Policy",
    "build_bot_uids",
    "build_discord_users_by_uid",
    "build_identities_by_uid",
    "build_users_with_discord_access",
    "classify",
    "policy_from_snapshot",
    "policy_to_snapshot",
    "resolve_action",
]


def build_users_with_discord_access() -> frozenset[int]:
    """
    Pre-compute the set of ``User.pk`` holding ``discord.access_discord``.

    ``classify`` is called once per Discord guild member during
    ``build_plan`` (5000+ on a typical alliance). The naive
    ``user.has_perm("discord.access_discord")`` call hits the
    standard Django permission cache, which on a fresh ``User``
    instance triggers three SQL queries
    (``auth_user_user_permissions`` + ``auth_user_groups`` +
    ``auth_group_permissions``). Pre-loading the set once at
    plan-build time collapses those 3N queries into a single
    union query plus N O(1) set-membership checks.
    """
    from django.contrib.auth.models import Permission

    perm = Permission.objects.filter(
        content_type__app_label="discord",
        codename="access_discord",
    ).first()
    if perm is None:
        # Discord service module not installed or migrations not run.
        # ``classify`` falls back to user.has_perm() in that case.
        return frozenset()
    # ``is_active=True`` matters: a deactivated AA account should be
    # treated as LINKED_NO_PERM (deactivation is the canonical way to
    # revoke access after off-boarding), not silently kept off the
    # orphan list because a stale superuser flag still grants the
    # permission via ``has_perm``. ``user.has_perm`` itself returns
    # False for inactive users, so the pre-loaded set must mirror that
    # semantics or active and deactivated users would classify
    # differently depending on whether the set was supplied.
    return frozenset(
        User.objects.filter(is_active=True)
        .filter(
            Q(user_permissions=perm)
            | Q(groups__permissions=perm)
            | Q(is_superuser=True),
        )
        .values_list("pk", flat=True)
        .distinct(),
    )


def policy_to_snapshot(policy: Policy) -> dict[str, object]:
    """
    Serialize a ``Policy`` mapping to its JSONField representation.

    Each ``CategoryPolicy`` rendered via ``to_json``; the result
    is JSON-serialisable and is what ``AuditRun.policy_snapshot``
    stores.
    """
    return {
        category.value: entry.to_json() for category, entry in policy.items()
    }


def policy_from_snapshot(
    snapshot: Mapping[str, object],
) -> dict[OrphanCategory, CategoryPolicy]:
    """
    Rebuild a ``Policy`` from its JSONField representation.

    Inverse of ``policy_to_snapshot``. Used by ``--resume`` and any
    helper that needs to drive an audit from an already-persisted
    ``AuditRun.policy_snapshot``. Each value is parsed via
    ``CategoryPolicy.from_json``, which accepts both the bare
    action string and the full nested object.
    """
    return {
        OrphanCategory(k): CategoryPolicy.from_json(v)
        for k, v in snapshot.items()
    }


def build_discord_users_by_uid() -> dict[int, object]:
    """
    Pre-load every ``DiscordUser`` keyed by ``uid``.

    ``classify`` previously issued one ``DiscordUser.objects.filter(
    uid=...).first()`` per guild member during ``build_plan`` —
    O(N) round-trips on a 5000-member alliance. This helper does
    one bounded SELECT with the ``user`` FK pre-joined and returns
    a dict the caller threads through ``classify``; per-member
    classification becomes O(1).

    On the rare case of duplicate (uid, ...) rows (which AA core's
    unique constraint would reject in normal operation), the
    ``order_by("-pk")`` keep-first idiom matches the
    ``order_by("-pk").first()`` semantics ``classify`` falls back
    to without the pre-load.

    Returned values are ``DiscordUser`` instances; the type is
    declared as ``object`` so this module avoids a top-level
    ``DiscordUser`` import (the lazy import lives in ``classify``).
    """
    from allianceauth.services.modules.discord.models import DiscordUser

    by_uid: dict[int, object] = {}
    for du in DiscordUser.objects.select_related("user").order_by("-pk"):
        by_uid.setdefault(du.uid, du)
    return by_uid


def build_identities_by_uid() -> dict[int, DiscordIdentity]:
    """
    Pre-load the "best" ``DiscordIdentity`` per uid.

    Mirrors the ordering ``classify`` falls back to when no map is
    supplied: most-recently-seen wins, with ``created_at`` and
    ``-pk`` as deterministic tie-breaks. Only identities with a
    non-NULL user are loaded — NULL-user gravestones never win the
    LINKED_NO_PERM classification.
    """
    by_uid: dict[int, DiscordIdentity] = {}
    for ident in (
        DiscordIdentity.objects.filter(user__isnull=False)
        .select_related("user")
        .order_by(
            F("last_seen_in_guild_at").desc(nulls_last=True),
            "-created_at",
            "-pk",
        )
    ):
        by_uid.setdefault(ident.uid, ident)
    return by_uid


def build_bot_uids() -> frozenset[int]:
    """
    Compose the bot-uid skip set from DB rows + settings.

    Frozen on the ``AuditPlan`` so a mid-run edit to the
    ``BotAccountUid`` table does not reshape classification
    inside the same run (the operator's view of the plan they
    confirmed must stay stable).
    """
    from .app_settings import AA_DISCORD_AUDIT_BOT_UIDS

    db_uids = set(BotAccountUid.objects.values_list("uid", flat=True))
    db_uids.update(int(u) for u in AA_DISCORD_AUDIT_BOT_UIDS)
    return frozenset(db_uids)


def _resolve_via_discord_user(
    discord_user: object,
    *,
    users_with_access: frozenset[int] | None,
) -> tuple[OrphanCategory | None, AbstractUser | None] | None:
    """
    Map a ``DiscordUser`` row to a classify outcome, or ``None`` if orphan.

    Returns ``None`` when ``user_id`` AND ``user`` are both missing on the
    snapshot — the row is orphaned (the AA-side ``User`` it pointed at
    was deleted but the cascade has not propagated yet, or
    ``select_related("user")`` served a stale JOIN). Falling through to
    the identity catalog in that case preserves AA-User attribution
    instead of emitting a finding with ``aa_user=None`` (which would
    bypass ``_legitimate_role_ids_for`` in apply and over-strip).
    """
    du_user_id = getattr(discord_user, "user_id", None)
    du_user = cast("AbstractUser | None", getattr(discord_user, "user", None))
    if du_user_id is None and du_user is None:
        return None
    # ``users_with_access`` is gated on truthiness, not ``is not None``:
    # ``build_users_with_discord_access`` returns an empty frozenset
    # when the discord ``access_discord`` permission row is missing
    # (service module not installed or migrations not run), and the
    # docstring contract is "fall back to ``user.has_perm``" in that
    # case. An ``is not None`` guard would silently treat every active
    # user as LINKED_NO_PERM.
    if users_with_access and du_user_id is not None:
        has_access = du_user_id in users_with_access
    elif du_user is not None:
        has_access = du_user.has_perm("discord.access_discord")
    else:
        has_access = False
    if has_access:
        return None, du_user
    return OrphanCategory.LINKED_NO_PERM, du_user


def classify(
    member: GuildMember,
    *,
    bot_uids: frozenset[int],
    users_with_access: frozenset[int] | None = None,
    discord_users_by_uid: Mapping[int, object] | None = None,
    identities_by_uid: Mapping[int, DiscordIdentity] | None = None,
) -> tuple[OrphanCategory | None, AbstractUser | None]:
    """
    Classify a guild member.

    Returns a tuple ``(category, user)`` where ``category`` is an
    ``OrphanCategory`` (or ``None`` for active AA users with the
    Discord access permission) and ``user`` is the linked AA
    ``User`` (or ``None`` for unknown guests / bot accounts).

    ``users_with_access``, ``discord_users_by_uid`` and
    ``identities_by_uid`` are the pre-loaded maps; when supplied,
    classification is O(1) per call instead of issuing 2-3 SQL
    queries. ``None`` falls back to per-call DB lookups, which is
    correct for the per-finding reclassify path in ``apply_plan``
    (where caching the build-time snapshot would mask a
    DiscordUser linked between build and apply).

    Precedence:

    1. Bot uid in ``bot_uids`` → ``(BOT_FILTERED, None)``.
    2. ``DiscordUser`` row exists → that's the active contract.
       Returns ``(None, user)`` if the user has
       ``discord.access_discord``, else
       ``(LINKED_NO_PERM, user)``.
    3. ``DiscordIdentity`` row exists for the uid → most-recently-
       seen wins (``last_seen_in_guild_at desc nulls last,
       created_at desc``).
    4. Otherwise ``(UNKNOWN_GUEST, None)``.
    """
    # Lazy import to avoid AA-core import on classify-less code paths.
    from allianceauth.services.modules.discord.models import DiscordUser

    # AA-core's ``GuildMember.from_dict`` may return ``user.id`` as
    # ``str`` depending on the upstream client version; coerce to
    # int once so the downstream filters and the bot-uid set check
    # are type-stable. Falls through to UNKNOWN_GUEST if the value
    # cannot be coerced (intentionally malformed payload, never seen
    # in practice from Discord but worth defending against).
    try:
        member_uid = int(member.user.id)
    except (TypeError, ValueError):
        return OrphanCategory.UNKNOWN_GUEST, None

    if member_uid in bot_uids:
        return OrphanCategory.BOT_FILTERED, None

    discord_user: object | None
    if discord_users_by_uid is not None:
        discord_user = discord_users_by_uid.get(member_uid)
    else:
        # ``order_by("-pk")`` provides a stable tie-break when two
        # DiscordUser rows somehow share a uid (only possible if
        # AA's core unique constraint is breached during a
        # migration). Without it, ``first()`` is non-deterministic
        # on duplicates.
        discord_user = (
            DiscordUser.objects.filter(uid=member_uid)
            .select_related("user")
            .order_by("-pk")
            .first()
        )
    if discord_user is not None:
        resolved = _resolve_via_discord_user(
            discord_user, users_with_access=users_with_access
        )
        if resolved is not None:
            return resolved

    if identities_by_uid is not None:
        identity = identities_by_uid.get(member_uid)
    else:
        # ``F("last_seen_in_guild_at").desc(nulls_last=True)`` makes
        # the NULL-handling explicit and consistent across
        # PostgreSQL (NULLS LAST in DESC by default), MySQL (NULLS
        # FIRST), and SQLite (NULLS FIRST). ``-pk`` is a tertiary
        # tie-break for the case where two identities share both
        # ``last_seen_in_guild_at`` and ``created_at``.
        identity = (
            DiscordIdentity.objects.filter(uid=member_uid, user__isnull=False)
            .select_related("user")
            .order_by(
                F("last_seen_in_guild_at").desc(nulls_last=True),
                "-created_at",
                "-pk",
            )
            .first()
        )
    if identity is not None:
        return OrphanCategory.LINKED_NO_PERM, identity.user

    return OrphanCategory.UNKNOWN_GUEST, None


def _resolve_against_user(
    entry: CategoryPolicy, aa_user: AbstractUser | None
) -> OrphanAction:
    """Match ``entry`` against ``aa_user``'s groups + state, then default."""
    # by_group wins over by_state by design: an operator who set both
    # is naming a more specific group (e.g. "Senior Recruiters")
    # than the broader state (e.g. "Member"). For aa_user=None
    # (unknown_guest findings) only ``default`` is consulted.
    if aa_user is None:
        return entry.default
    if entry.by_group:
        # ``order_by("name")`` makes the matching deterministic across
        # databases: the raw ``values_list("name")`` is unordered, so a
        # user in two ``by_group``-listed groups with conflicting actions
        # would resolve differently per query without this guard.
        # Operators MUST avoid placing the same user in two ``by_group``
        # groups with different actions; lexicographic order is the
        # documented tie-breaker so the match is reproducible.
        for group_name in aa_user.groups.order_by("name").values_list(
            "name", flat=True
        ):
            override = entry.by_group.get(group_name)
            if override is not None:
                return override
    if entry.by_state:
        state = getattr(getattr(aa_user, "profile", None), "state", None)
        state_name = getattr(state, "name", None)
        if state_name is not None:
            override = entry.by_state.get(state_name)
            if override is not None:
                return override
    return entry.default


def resolve_action(
    category: OrphanCategory,
    *,
    policy: Policy | None = None,
    cli_override: OrphanAction | None = None,
    aa_user: AbstractUser | None = None,
) -> OrphanAction:
    """
    Resolve the action to apply for a given orphan category.

    Resolution order (highest precedence first):

    1. ``cli_override`` (the ``--action`` flag) — applied to all
       categories uniformly.
    2. ``policy[category]`` — a ``CategoryPolicy`` envelope that
       can route per-AA-group or per-state via ``aa_user``;
       falls back to ``default`` when no targeted match wins or
       ``aa_user`` is ``None``.
    3. Default: ``REPORT``.

    ``BOT_FILTERED`` always resolves to ``REPORT`` regardless of
    inputs — bot uids never receive destructive actions.
    """
    if category == OrphanCategory.BOT_FILTERED:
        return OrphanAction.REPORT
    if cli_override is not None:
        return cli_override
    if policy is None:
        policy = policy_from_snapshot(AA_DISCORD_AUDIT_POLICY)
    entry = policy.get(category)
    if entry is None:
        return OrphanAction.REPORT
    return _resolve_against_user(entry, aa_user)
