"""
Per-finding apply work + the helpers that bracket it.

The destructive path is encapsulated in ``_ApplyFindingExecutor`` —
a frozen dataclass whose ``execute()`` runs the three-phase sandwich:

1. (atomic) lock + reclassify + decide  →  ``_phase1_lock_reclassify``
2. (no atomic) HTTP role-strip loop     →  ``_phase2_strip_roles``
3. (atomic) persist APPLIED + counter bump + on_commit kick →
   ``_phase3_persist``

Each phase method returns a discriminated union: either a ``Skipped``
variant carrying the ``AppliedAction`` to surface back to the apply
loop unchanged (e.g. RECLASSIFIED, HTTP_ERROR), or a ``Done`` variant
carrying the data the next phase needs. Splitting into phase methods
keeps the destructive contract (atomic-HTTP-atomic sandwich) at the
level the architecture tests against; binding ``finding`` / ``run`` /
``db_finding`` / ``action`` to the executor instance removes the
six-parameter call-site repetition the original orchestrator had.

The top-level ``_apply_finding`` function still owns the pre-executor
resolution (db_finding lookup, INTERNAL_INCONSISTENCY, PROTECTED_CONFIG,
policy ``action_for``); those steps don't need executor state, and
keeping them outside the class preserves a single resolution lane
that's easy to read top-to-bottom.

Module-level shim functions (``_emit_skipped`` / ``_emit_failed`` /
``_phase1_lock_reclassify`` / ``_phase2_strip_roles`` / ``_phase3_persist``
/ ``_http_error_or_auth``) delegate into the executor. They exist for
two reasons: (a) preserving ~30 ``mock.patch("…_finding.<name>")``
paths in the test suite without churn, and (b) keeping a stable
public-ish API for callers outside this module.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass
from http import HTTPStatus
from typing import TYPE_CHECKING, cast

from django.contrib.auth import get_user_model
from django.db import models, transaction
from django.utils import timezone

from .._redaction import redact_str, scrub_exception
from ..app_settings import AA_DISCORD_AUDIT_USE_BULK_ROLE_STRIP
from ..models import (
    AuditFinding,
    FindingSkipReason,
    OrphanAction,
    OrphanCategory,
    PendingKick,
)
from ..persistence import (
    increment_run_counters,
    persist_finding_applied,
    persist_finding_failed,
    persist_finding_skipped,
)
from ..policy import classify
from ..reasons import build_reason
from ..role_filter import (
    derive_managed_role_ids,
    persistent_role_ids_for,
)
from ._types import AppliedAction, AuditPlan, OrphanFinding

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

    from ..client import AuditedDiscordClient
    from ..models import AuditRun
    from ..role_filter import RoleLike

logger = logging.getLogger(f"extensions.{__name__}")

User = get_user_model()


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."""
    legit_names: set[str] = set(aa_user.groups.values_list("name", flat=True))
    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:
            # ``scrub_exception`` BEFORE ``logger.exception`` so the
            # formatted traceback in the ERROR log line cannot leak
            # the webhook URL via a nested broker exception chain.
            scrub_exception(exc)
            logger.exception(
                "delete_user.delay broker handoff failed for "
                "PendingKick pk=%s uid=%s",
                pending_kick_pk,
                aa_user_id,
            )
            PendingKick.objects.filter(pk=pending_kick_pk).update(
                attempts=models.F("attempts") + 1,
                last_attempt_at=timezone.now(),
                last_error=redact_str(str(exc)),
            )
            return
        # ``last_error=""`` clears any stale error from a prior
        # failed handoff attempt — a row that flipped to FAILED on
        # retry but eventually succeeded should not surface the old
        # error in admin / forensics.
        PendingKick.objects.filter(pk=pending_kick_pk).update(
            completed_at=timezone.now(),
            attempts=models.F("attempts") + 1,
            last_error="",
        )

    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 that returned 2xx; on partial
    # failure (DELETE-loop branch) the caller passes only the
    # accumulated subset to ``_http_error_or_auth(partial_role_ids=…)``
    # so downstream forensics see the actually-stripped set, not the
    # intended set. Bulk PATCH is atomic Discord-side, so its
    # all-or-nothing success populates the full ``role_ids`` only if
    # the call returned 2xx.
    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,
        kicked=False,
        skipped_reason=skipped_reason,
    )


# --- Phase outcome unions -----------------------------------------
#
# Each ``_phaseN_*`` method returns one of two variants. The
# ``Skipped`` variant carries an ``AppliedAction`` that the apply
# loop must surface unchanged (RECLASSIFIED skip, HTTP_ERROR fail,
# AUTH_FAILED, etc.). The ``Done`` variant carries the data the
# next phase consumes. ``isinstance(...)`` in the orchestrator gives
# the type-checker exhaustive narrowing without runtime overhead.


@dataclass(frozen=True)
class _Phase1Skipped:
    """Phase 1 short-circuit (RECLASSIFIED / HTTP error / auth fail)."""

    outcome: AppliedAction


@dataclass(frozen=True)
class _Phase1Done:
    """Phase 1 succeeded; phase 2 consumes ``live_member`` + ``role_ids``."""

    live_member: GuildMember
    role_ids: frozenset[int]
    role_names_removed: tuple[str, ...]
    final_category: OrphanCategory
    final_user: AbstractUser | None


@dataclass(frozen=True)
class _Phase2Skipped:
    """Phase 2 short-circuit (HTTP error during role-strip)."""

    outcome: AppliedAction


@dataclass(frozen=True)
class _Phase2Done:
    """Phase 2 succeeded; phase 3 reads ``kicked`` for handoff decision."""

    kicked: bool


@dataclass(frozen=True)
class _ApplyFindingExecutor:
    """
    Bind per-finding state to the destructive-sandwich phase methods.

    Frozen so a single executor cannot be re-targeted mid-pipeline by
    accident (a mutating helper would raise ``FrozenInstanceError``).
    The pre-executor resolution lives in the module-level
    ``_apply_finding`` because it has no useful state to bind: looking
    up ``db_finding`` is what *produces* the state.

    ``plan`` and ``client`` default to ``None`` because the persistence-
    only methods (``_emit_skipped`` / ``_emit_failed`` /
    ``_http_error_or_auth`` / ``_phase3_persist``) never read them.
    The two phases that issue Discord HTTP calls assert their presence
    explicitly, narrowing the optional types for static analysis.
    """

    finding: OrphanFinding
    run: AuditRun
    db_finding: AuditFinding
    action: OrphanAction
    plan: AuditPlan | None = None
    client: AuditedDiscordClient | None = None

    def execute(self) -> AppliedAction:
        """Run REPORT short-circuit or the three-phase destructive sandwich."""
        if self.action == OrphanAction.REPORT:
            # ``REPORT`` short-circuits before the destructive sandwich —
            # no Discord API mutation needed.
            persist_finding_applied(
                self.db_finding,
                role_ids_removed=(),
                role_names_removed=(),
            )
            increment_run_counters(self.run, actions_applied=1)
            return AppliedAction(
                finding=self.finding,
                final_category=self.finding.category,
                action=OrphanAction.REPORT,
                role_ids_removed=frozenset(),
                kicked=False,
                skipped_reason=None,
            )

        # Destructive sandwich: short atomic + HTTP gap + short atomic.
        # Trades a single longer transaction for two short ones with
        # an HTTP gap between, since ``persist_finding_applied`` is
        # idempotent via FSM compare-and-set and Discord-side
        # mutations are idempotent at the API surface.
        p1 = self.phase1_lock_reclassify()
        if isinstance(p1, _Phase1Skipped):
            return p1.outcome

        p2 = self.phase2_strip_roles(p1)
        if isinstance(p2, _Phase2Skipped):
            return p2.outcome

        return self.phase3_persist(p1, p2)

    def emit_skipped(
        self,
        *,
        skipped_reason: FindingSkipReason,
        final_category: OrphanCategory | None = None,
        role_ids_removed: frozenset[int] = frozenset(),
    ) -> AppliedAction:
        """Persist SKIPPED, bump counter, return the AppliedAction."""
        # ``persist_finding_skipped`` returns False if the FSM CAS lost
        # the race (a concurrent writer already advanced the row). Under
        # the single-writer-per-run invariant this is unreachable, but
        # the local invariant must not depend on the higher-level
        # guarantee — a counter bump without a row state change would
        # corrupt ``actions_applied + actions_skipped == findings_count``.
        if persist_finding_skipped(self.db_finding, reason=skipped_reason):
            increment_run_counters(self.run, actions_skipped=1)
        return _skipped_action(
            self.finding,
            skipped_reason=skipped_reason,
            action=self.action,
            final_category=final_category,
            role_ids_removed=role_ids_removed,
        )

    def emit_failed(
        self,
        *,
        skipped_reason: FindingSkipReason,
        error_message: str,
        role_ids_removed: frozenset[int] = frozenset(),
    ) -> AppliedAction:
        """Persist FAILED, bump counter, return the AppliedAction."""
        # The AppliedAction still carries ``skipped_reason`` so
        # downstream consumers see one taxonomy regardless of
        # SKIPPED-vs-FAILED row state; the DB row goes to FAILED to
        # record the error_message for forensic admin display.
        # Counter bump is gated on the CAS rowcount — see
        # ``_emit_skipped`` for the invariant rationale.
        if persist_finding_failed(
            self.db_finding, error_message=error_message
        ):
            increment_run_counters(self.run, actions_skipped=1)
        return _skipped_action(
            self.finding,
            skipped_reason=skipped_reason,
            action=self.action,
            role_ids_removed=role_ids_removed,
        )

    def phase1_lock_reclassify(self) -> _Phase1Skipped | _Phase1Done:
        """
        Fetch live member, reclassify, lock + derive role IDs.

        HTTP read runs FIRST, OUTSIDE the atomic block, so AA-side
        ``select_for_update`` row locks on the User and DiscordUser
        are not held for the duration of the Discord round-trip
        (which can be minutes under rate-limit backoff). The lock
        window narrows to the role-derivation step that actually
        reads ``aa_user.groups`` / ``profile.state`` — the only
        AA-state ``update_groups`` could mutate concurrently. Reading
        post-lock is BETTER than holding-during-HTTP: a concurrent
        update_groups flip lands in the role-derivation result
        instead of producing a stale snapshot.

        The classify step runs outside the lock too — it consults
        the plan-frozen ``bot_uids`` / ``users_with_access`` snapshots,
        not the live DB, so the lock would not protect anything
        classify reads.
        """
        from allianceauth.services.modules.discord.discord_client import (
            models as _client_models,
        )
        from allianceauth.services.modules.discord.models import DiscordUser

        plan = self.plan
        client = self.client
        if plan is None or client is None:
            msg = "_phase1_lock_reclassify requires plan + client"
            raise AssertionError(msg)

        # HTTP read — slow, runs without holding any AA-side row lock.
        try:
            raw_member = client.guild_member(
                guild_id=plan.guild_id,
                user_id=self.finding.member_id,
            )
        except Exception as exc:  # noqa: BLE001 — _http_error_or_auth classifies any exception (HTTPError / ConnectionError / Timeout) into auth_failed / forbidden / generic_skip
            return _Phase1Skipped(outcome=self.http_error_or_auth(exc))

        live_member = _client_models.GuildMember.from_dict(raw_member)

        # classify reads only plan-frozen sets — no DB IO, no lock needed.
        final_category, final_user = classify(
            live_member,
            bot_uids=plan.bot_uids,
            users_with_access=plan.users_with_access,
        )
        if final_category != self.finding.category:
            return _Phase1Skipped(
                outcome=self.emit_skipped(
                    skipped_reason=FindingSkipReason.RECLASSIFIED,
                    final_category=final_category,
                )
            )
        # After the equality check with the non-None
        # ``finding.category`` we know ``final_category`` equals it;
        # reassign to make the narrowing explicit to the type
        # checker without a stripped-under-``-O`` ``assert``.
        final_category = self.finding.category
        if (
            final_user is not None
            and self.finding.aa_user_id is not None
            and final_user.pk != self.finding.aa_user_id
        ):
            return _Phase1Skipped(
                outcome=self.emit_skipped(
                    skipped_reason=FindingSkipReason.RECLASSIFIED_USER_CHANGE,
                    final_category=final_category,
                )
            )

        # Lock + role-derivation. The atomic block scope shrinks to
        # just the AA-state read (groups + profile.state) consumed by
        # ``_legitimate_role_ids_for``. ``select_for_update`` without
        # ``skip_locked``: a parallel admin-action waits briefly while
        # this short critical section completes.
        with transaction.atomic():
            if self.finding.aa_user_id is not None:
                list(
                    User.objects.select_for_update().filter(
                        pk=self.finding.aa_user_id
                    )
                )
                list(
                    DiscordUser.objects.select_for_update().filter(
                        uid=self.finding.member_id
                    )
                )
            role_ids = derive_managed_role_ids(
                live_member,
                role_catalog=plan.role_catalog,
                aa_role_names_set=plan.aa_role_names,
                reserved_names_set=plan.reserved_role_names,
                exempt_ids=plan.exempt_role_ids,
                persistent_ids=persistent_role_ids_for(
                    live_member,
                    role_catalog=plan.role_catalog,
                    aa_user_legitimate_role_ids=(
                        _legitimate_role_ids_for(final_user, plan.role_catalog)
                        if final_user is not None
                        else None
                    ),
                    member_category=final_category,
                    reserved_names=plan.reserved_role_names,
                ),
            )
            role_names_removed = tuple(
                plan.role_name_by_id[rid]
                for rid in role_ids
                if rid in plan.role_name_by_id
            )

        return _Phase1Done(
            live_member=live_member,
            role_ids=role_ids,
            role_names_removed=role_names_removed,
            final_category=final_category,
            final_user=final_user,
        )

    def phase2_strip_roles(
        self,
        p1: _Phase1Done,
    ) -> _Phase2Skipped | _Phase2Done:
        """
        No-atomic: HTTP role-strip; surface a skip on ``HTTPError``.

        Discord-side mutations are idempotent on retry; partial
        progress on a transient error is recoverable via ``--resume``
        walking the ``role_ids_removed`` already persisted on the
        finding row.
        """
        from ..client import with_reason

        plan = self.plan
        client = self.client
        if plan is None or client is None:
            msg = "_phase2_strip_roles requires plan + client"
            raise AssertionError(msg)

        reason = build_reason(
            run_id=plan.run_id,
            category=self.finding.category.value,
            action=self.action.value,
        )
        kicked = False
        succeeded_role_ids: set[int] = set()
        try:
            with with_reason(reason):
                _strip_managed_roles(
                    client,
                    guild_id=plan.guild_id,
                    member_id=self.finding.member_id,
                    live_role_ids=list(p1.live_member.roles),
                    role_ids=p1.role_ids,
                    succeeded=succeeded_role_ids,
                )
            if (
                self.action == OrphanAction.STRIP_KICK
                and self.finding.aa_user_id is not None
            ):
                kicked = True
        except Exception as exc:  # noqa: BLE001 — _http_error_or_auth classifies any exception (HTTPError / ConnectionError / Timeout) into auth_failed / forbidden / generic_skip
            return _Phase2Skipped(
                outcome=self.http_error_or_auth(
                    exc, partial_role_ids=frozenset(succeeded_role_ids)
                )
            )
        return _Phase2Done(kicked=kicked)

    def phase3_persist(
        self,
        p1: _Phase1Done,
        p2: _Phase2Done,
    ) -> AppliedAction:
        """
        Atomic: persist APPLIED + counter bump + on_commit kick handoff.

        The kick is bound to *this* transaction's commit via
        ``transaction.on_commit`` so a rollback here un-fires it; the
        role strips in phase 2 are unaffected — Discord cannot be
        rolled back, we accept that asymmetry.
        """
        with transaction.atomic():
            transitioned = persist_finding_applied(
                self.db_finding,
                role_ids_removed=p1.role_ids,
                role_names_removed=p1.role_names_removed,
                kicked=p2.kicked,
            )
            # Gate counter bump and on_commit kick on the FSM
            # transition actually firing. ``transitioned == False``
            # means a concurrent writer already advanced the row to
            # APPLIED; double-counting ``actions_applied`` or
            # enqueueing a second kick would corrupt the run summary
            # and risk a duplicate ``delete_user.delay`` invocation.
            # Under the run-level FSM gate this branch is unreachable,
            # but the local invariant must not depend on a higher-
            # level guarantee.
            if transitioned:
                increment_run_counters(self.run, actions_applied=1)
                if p2.kicked and self.finding.aa_user_id is not None:
                    _arrange_kick_handoff(
                        self.db_finding,
                        uid=self.finding.member_id,
                        aa_user_id=self.finding.aa_user_id,
                    )

        return AppliedAction(
            finding=self.finding,
            final_category=p1.final_category,
            action=self.action,
            role_ids_removed=p1.role_ids,
            kicked=p2.kicked,
            skipped_reason=None,
        )

    def http_error_or_auth(
        self,
        exc: Exception,
        *,
        partial_role_ids: frozenset[int] = frozenset(),
    ) -> AppliedAction:
        """Translate a Discord-side exception into an AppliedAction skip."""
        import requests

        status: int | None = None
        if (
            isinstance(exc, requests.exceptions.HTTPError)
            and exc.response is not None
        ):
            status = exc.response.status_code

        # 401 maps to FAILED (auth must alert the run-level handler);
        # 403 maps to SKIPPED (per-target permission gap, run continues);
        # everything else falls through to the generic HTTP_ERROR FAILED
        # path with ``str(exc)`` recorded for forensic display.
        if status == HTTPStatus.UNAUTHORIZED:
            # Single source of truth in ``_apply._AUTH_FAILED_ERROR_MESSAGE``.
            # Lazy import: ``_apply.py`` toplevel imports
            # ``_apply_finding`` from this module, so a top-level
            # ``from ._apply import ...`` here would deadlock when
            # ``_finding`` is loaded first by an external caller
            # (e.g. ``from .audit._finding import _apply_finding``
            # in tests). The 401 path is rare; one ``dict.get`` per
            # call is cheaper than reordering the package's import
            # graph.
            from ._apply import _AUTH_FAILED_ERROR_MESSAGE

            return self.emit_failed(
                skipped_reason=FindingSkipReason.AUTH_FAILED,
                role_ids_removed=partial_role_ids,
                error_message=_AUTH_FAILED_ERROR_MESSAGE,
            )
        if status == HTTPStatus.FORBIDDEN:
            return self.emit_skipped(
                skipped_reason=FindingSkipReason.FORBIDDEN_403,
                role_ids_removed=partial_role_ids,
            )
        # Scrub the exception's args + response URL in place, then
        # read ``str(exc)`` through the redactor as a belt-and-
        # suspenders pass in case ``__str__`` builds the message from
        # non-args fields. The audit Discord-API URL does not carry
        # the bot token, but a nested ``MaxRetryError`` / proxy chain
        # can still embed the operator's webhook URL if the same
        # ``requests.Session`` was touched earlier in the process —
        # symmetry with the webhook path is the cheapest invariant.
        scrub_exception(exc)
        return self.emit_failed(
            skipped_reason=FindingSkipReason.HTTP_ERROR,
            role_ids_removed=partial_role_ids,
            error_message=redact_str(str(exc)),
        )


# --- Module-level shims -------------------------------------------
#
# These tiny wrappers preserve the pre-refactor public surface so
# direct imports (``from ._finding import _phase1_lock_reclassify``)
# and ``mock.patch("…_finding._arrange_kick_handoff", …)`` paths
# keep working. Each delegates straight into the executor without
# adding logic; nothing here is the source of truth.


def _emit_skipped(
    *,
    db_finding: AuditFinding,
    finding: OrphanFinding,
    run: AuditRun,
    skipped_reason: FindingSkipReason,
    action: OrphanAction,
    final_category: OrphanCategory | None = None,
    role_ids_removed: frozenset[int] = frozenset(),
) -> AppliedAction:
    """Shim that delegates to ``_ApplyFindingExecutor.emit_skipped``."""
    return _ApplyFindingExecutor(
        finding=finding,
        run=run,
        db_finding=db_finding,
        action=action,
    ).emit_skipped(
        skipped_reason=skipped_reason,
        final_category=final_category,
        role_ids_removed=role_ids_removed,
    )


def _emit_failed(
    *,
    db_finding: AuditFinding,
    finding: OrphanFinding,
    run: AuditRun,
    skipped_reason: FindingSkipReason,
    action: OrphanAction,
    error_message: str,
    role_ids_removed: frozenset[int] = frozenset(),
) -> AppliedAction:
    """Shim that delegates to ``_ApplyFindingExecutor.emit_failed``."""
    return _ApplyFindingExecutor(
        finding=finding,
        run=run,
        db_finding=db_finding,
        action=action,
    ).emit_failed(
        skipped_reason=skipped_reason,
        error_message=error_message,
        role_ids_removed=role_ids_removed,
    )


def _phase1_lock_reclassify(
    plan: AuditPlan,
    finding: OrphanFinding,
    client: AuditedDiscordClient,
    run: AuditRun,
    db_finding: AuditFinding,
    action: OrphanAction,
) -> _Phase1Skipped | _Phase1Done:
    """Shim — see ``_ApplyFindingExecutor.phase1_lock_reclassify``."""
    return _ApplyFindingExecutor(
        plan=plan,
        client=client,
        finding=finding,
        run=run,
        db_finding=db_finding,
        action=action,
    ).phase1_lock_reclassify()


def _phase2_strip_roles(
    plan: AuditPlan,
    finding: OrphanFinding,
    client: AuditedDiscordClient,
    run: AuditRun,
    db_finding: AuditFinding,
    action: OrphanAction,
    p1: _Phase1Done,
) -> _Phase2Skipped | _Phase2Done:
    """Shim — see ``_ApplyFindingExecutor.phase2_strip_roles``."""
    return _ApplyFindingExecutor(
        plan=plan,
        client=client,
        finding=finding,
        run=run,
        db_finding=db_finding,
        action=action,
    ).phase2_strip_roles(p1)


def _phase3_persist(
    finding: OrphanFinding,
    run: AuditRun,
    db_finding: AuditFinding,
    action: OrphanAction,
    p1: _Phase1Done,
    p2: _Phase2Done,
) -> AppliedAction:
    """Shim — see ``_ApplyFindingExecutor.phase3_persist``."""
    return _ApplyFindingExecutor(
        finding=finding,
        run=run,
        db_finding=db_finding,
        action=action,
    ).phase3_persist(p1, p2)


def _http_error_or_auth(
    exc: Exception,
    db_finding: AuditFinding,
    finding: OrphanFinding,
    run: AuditRun,
    action: OrphanAction,
    *,
    partial_role_ids: frozenset[int] = frozenset(),
) -> AppliedAction:
    """Shim — see ``_ApplyFindingExecutor.http_error_or_auth``."""
    return _ApplyFindingExecutor(
        finding=finding,
        run=run,
        db_finding=db_finding,
        action=action,
    ).http_error_or_auth(exc, partial_role_ids=partial_role_ids)


def _apply_finding(
    plan: AuditPlan,
    finding: OrphanFinding,
    client: AuditedDiscordClient,
    run: AuditRun,
    protected_uids: frozenset[int],
) -> AppliedAction:
    """
    Per-finding action loop body.

    Orchestrates the pre-executor short-circuits + the executor's
    three-phase destructive sandwich:

    1. Lookup ``db_finding`` (early return if build/apply disagree).
    2. ``ProtectedDiscordMember`` static check — wins over policy
       and per-uid overrides.
    3. Resolve the action via the plan (per-uid override → per-group
       / per-state policy → default).
    4. Hand off to ``_ApplyFindingExecutor.execute()``, which fans
       out into REPORT short-circuit or phase 1 / 2 / 3.
    """
    db_finding = AuditFinding.objects.filter(
        run=run,
        member_id=finding.member_id,
    ).first()
    if db_finding is None:
        # Unexpected; build_plan should have persisted every finding.
        # This branch fires only when build_plan and apply_plan
        # disagreed on which findings exist (typically: an admin
        # deleted the AuditFinding row out-of-band between phases).
        # Bump ``actions_skipped`` to keep the run summary's
        # ``actions_applied + actions_skipped == findings_count``
        # invariant intact for forensic admin views — otherwise the
        # run reports fewer actions than findings without explanation.
        # ``aa_user`` is left unresolved here: the persisted intent
        # column is the operator-visible field, and there is no DB
        # row to update either way.
        increment_run_counters(run, actions_skipped=1)
        return _skipped_action(
            finding,
            skipped_reason=FindingSkipReason.INTERNAL_INCONSISTENCY,
            action=plan.action_for(
                finding.category, member_id=finding.member_id
            ),
        )

    # Static protection check (admin-managed list). Wins over the
    # policy default AND any ``FindingActionOverride`` for the same
    # uid: the protected list is the strongest "do nothing" signal,
    # and an operator who set both is unambiguously asking for
    # protection.
    if finding.member_id in protected_uids:
        return _ApplyFindingExecutor(
            finding=finding,
            run=run,
            db_finding=db_finding,
            action=OrphanAction.REPORT,
        ).emit_skipped(skipped_reason=FindingSkipReason.PROTECTED_CONFIG)

    # ``aa_user`` resolution at apply time: for the REPORT short-
    # circuit, the linked user is loaded via ``finding.aa_user_id``
    # so per-group / per-state policy can route REPORT-coerced
    # findings. ``get_user_model()`` types its ``.first()`` as
    # ``AbstractBaseUser | None``; the configured user model in this
    # project is always an ``AbstractUser`` subclass with
    # ``PermissionsMixin`` (groups + profile.state attributes are
    # accessed downstream via the policy resolver).
    aa_user_for_policy = cast(
        "AbstractUser | None",
        User.objects.filter(pk=finding.aa_user_id)
        .prefetch_related("groups", "profile__state")
        .first()
        if finding.aa_user_id is not None
        else None,
    )
    action = plan.action_for(
        finding.category,
        member_id=finding.member_id,
        aa_user=aa_user_for_policy,
    )

    return _ApplyFindingExecutor(
        plan=plan,
        client=client,
        finding=finding,
        run=run,
        db_finding=db_finding,
        action=action,
    ).execute()
