"""
Pure per-finding helpers used by the apply pipeline.

Split out of ``_finding.py`` to keep the executor module focused on the
3-phase destructive sandwich. Each helper here is either:

* purely computational (``_legitimate_role_ids_for``, ``_skipped_action``)
  — no DB writes, no HTTP, free of executor state, or
* a side-effecting helper bracketed by the executor's atomic blocks
  (``_arrange_kick_handoff`` persists ``PendingKick`` + binds the
  ``on_commit`` callback; ``_strip_managed_roles`` issues the role-
  strip HTTP calls), which makes them easier to unit-test in isolation
  from the surrounding ``execute()`` pipeline.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from django.db import transaction
from django.utils import timezone

from ..._log import get_logger
from ..._redaction import log_redacted_exception
from ...app_settings import AA_DISCORD_AUDIT_USE_BULK_ROLE_STRIP
from ...models import (
    AuditFinding,
    FindingSkipReason,
    OrphanAction,
    OrphanCategory,
    PendingKick,
)
from .._types import AppliedAction, OrphanFinding

if TYPE_CHECKING:  # pragma: no cover
    from django.contrib.auth.models import AbstractUser

    from ...client import AuditedDiscordClient
    from ...role_filter import RoleLike

logger = get_logger(__name__)


def _legitimate_role_ids_for(
    aa_user: AbstractUser,
    role_catalog: tuple[RoleLike, ...],
) -> frozenset[int]:
    """Compute role-ids the AA user legitimately holds via state/group."""
    # ``groups.all()`` honours upstream ``prefetch_related("groups")``
    # — reading via the prefetch cache avoids a SELECT per finding
    # under the apply lock. Switching to ``.values_list("name", flat=True)``
    # here would bypass the cache (it builds a fresh QuerySet) and
    # re-issue the SELECT each call.
    legit_names: set[str] = {g.name for g in aa_user.groups.all()}
    state = getattr(getattr(aa_user, "profile", None), "state", None)
    if state is not None:
        legit_names.add(state.name)
    return frozenset(r.id for r in role_catalog if r.name in legit_names)


def _arrange_kick_handoff(
    db_finding: AuditFinding,
    *,
    uid: int,
    aa_user_id: int,
) -> None:
    """Persist a ``PendingKick`` + bind the on_commit Celery handoff."""
    # The PendingKick row commits with the surrounding atomic block;
    # the on_commit callback then attempts the broker handoff and
    # marks ``completed_at`` on success. A broker outage leaves the
    # row uncompleted with ``last_error`` populated, ready for
    # ``retry_pending_kicks``. Without this DLQ a ``delete_user.delay``
    # raise inside on_commit would be swallowed by Django's logger
    # and the kick silently lost.
    # ``last_attempt_at=now`` stamps the row at creation so a
    # ``retry_pending_kicks`` tick racing the on_commit callback
    # excludes this row via ``.exclude(last_attempt_at__gt=cutoff)``.
    # Without the stamp, ``last_attempt_at IS NULL`` slips past the
    # cooldown filter and both the on_commit closure (uid bound in
    # the closure) and the retry task fire ``delete_user.delay`` for
    # the same uid. AA's ``delete_user`` is documented idempotent so
    # the harm is bounded to extra broker work + log noise, but the
    # architectural intent (one handoff per kick) is the contract.
    pending_kick = PendingKick.objects.create(
        audit_finding=db_finding,
        uid=uid,
        aa_user_id=aa_user_id,
        last_attempt_at=timezone.now(),
    )
    pending_kick_pk: int = pending_kick.pk

    def _enqueue_kick() -> None:
        from allianceauth.services.modules.discord.tasks import delete_user

        # ``except Exception`` is intentional: the broker / driver
        # class is broad (kombu / redis / AMQP can raise a dozen
        # different exception types); record any raise verbatim
        # into ``last_error`` for ``retry_pending_kicks`` to sweep.
        try:
            delete_user.delay(aa_user_id)  # pyright: ignore[reportCallIssue]
        except Exception as exc:  # noqa: BLE001 — broker drivers (kombu / redis / AMQP) raise unrelated exception classes; record any raise verbatim into ``last_error`` for ``retry_pending_kicks`` to sweep.
            log_redacted_exception(
                logger,
                "delete_user.delay broker handoff failed for "
                "PendingKick pk=%s uid=%s",
                pending_kick_pk,
                aa_user_id,
                exc=exc,
            )
            PendingKick.record_failure(
                pending_kick_pk, exc, should_stamp_last_attempt=True
            )
            return
        # ``record_success`` filters on ``completed_at__isnull=True``
        # so a racing ``retry_pending_kicks`` tick that already flipped
        # the row to complete is not clobbered with a later timestamp
        # or double-bumped on ``attempts``. The on_commit closure
        # losing this race is benign — the kick was delivered either
        # way; we just leave the existing forensic state intact.
        PendingKick.record_success(pending_kick_pk)

    transaction.on_commit(_enqueue_kick)


def _strip_managed_roles(
    client: AuditedDiscordClient,
    *,
    guild_id: int,
    member_id: int,
    live_role_ids: list[int],
    role_ids: frozenset[int],
    succeeded: set[int],
) -> None:
    """Issue the role-strip HTTP calls — bulk PATCH or per-role DELETE."""
    # Default path is N DELETEs (one per managed role); the bulk PATCH
    # path replaces the whole roles array with the keep-list and trades
    # N HTTP calls for 1, at the cost of a TOCTOU window on roles
    # added between phase-1 fetch and this call. ``keep_role_ids ==
    # []`` means the operator wants every role gone — AA's wrapper
    # rejects an empty array, so the strip-all case falls back to
    # the DELETE loop regardless of the setting.
    #
    # ``succeeded`` accumulates IDs we have HTTP CONFIRMATION of
    # stripping; both branches commit to this invariant. The
    # invariant is "we received a 2xx for this id", NOT "server
    # state is X". Implications for the FAILED-path forensic record:
    #
    #   - DELETE-loop: ``succeeded.add(role_id)`` runs only after a
    #     successful return; if call K of N raises, ``succeeded``
    #     holds {1..K-1}. The 2xx-without-effect window (server
    #     applied, transport dropped response) leaves that id off the
    #     observed set even though Discord stripped it.
    #
    #   - Bulk PATCH: ``succeeded.update(role_ids)`` runs only after
    #     the single call returned 2xx; if it raises, ``succeeded``
    #     stays empty. Discord documents ``modify_guild_member`` as
    #     atomic, so server state is "all or nothing" — but the same
    #     2xx-without-effect window means we may have lost the
    #     confirmation of a complete commit.
    #
    # Both paths therefore report a strict-LOWER-BOUND on the actual
    # mutation: ``succeeded`` is what we OBSERVED, not what server
    # state IS. Operators investigating a FAILED row with empty (or
    # short) ``partial_role_ids`` must re-run an audit pass to
    # reconcile against Discord — the audit's idempotent design makes
    # the reconciliation safe; the forensic record honestly conveys
    # what we knew at the time, not what we guessed.
    keep_role_ids = [rid for rid in live_role_ids if rid not in role_ids]
    if AA_DISCORD_AUDIT_USE_BULK_ROLE_STRIP and keep_role_ids:
        client.modify_guild_member(
            guild_id=guild_id,
            user_id=member_id,
            role_ids=keep_role_ids,
        )
        succeeded.update(role_ids)
        return
    for role_id in role_ids:
        client.remove_guild_member_role(
            guild_id=guild_id,
            user_id=member_id,
            role_id=role_id,
        )
        succeeded.add(role_id)


def _skipped_action(
    finding: OrphanFinding,
    *,
    skipped_reason: FindingSkipReason,
    action: OrphanAction,
    final_category: OrphanCategory | None = None,
    role_ids_removed: frozenset[int] = frozenset(),
) -> AppliedAction:
    """Build the SKIPPED ``AppliedAction`` value (no DB write)."""
    return AppliedAction(
        finding=finding,
        final_category=(
            final_category if final_category is not None else finding.category
        ),
        action=action,
        role_ids_removed=role_ids_removed,
        was_kicked=False,
        skipped_reason=skipped_reason,
    )
