"""
``_ApplyFindingExecutor`` + phase-result discriminated unions.

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. ``isinstance(...)`` in
``execute()`` gives the type-checker exhaustive narrowing without
runtime overhead. Splitting into phase methods keeps the destructive
contract (atomic-HTTP-atomic sandwich) at the level the architecture
tests against.

The pre-executor resolution (db_finding lookup, INTERNAL_INCONSISTENCY,
PROTECTED_CONFIG, policy ``action_for``) lives in
``_finding._orchestrator``: 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.

Exception classifier note (BLE001): the HTTP read in ``phase1`` and the
role-strip in ``phase2`` both wrap user code in ``except Exception`` →
``http_error_or_auth(exc)``. Each Discord-side raise (HTTPError,
ConnectionError, Timeout, malformed-response ValueError) maps onto one
of the three classifier branches (auth / forbidden / generic) instead
of bubbling out and breaking the apply loop. ``noqa: BLE001`` at the
two ``except`` sites references this paragraph rather than restating
the rationale verbatim.
"""

from __future__ import annotations

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

import requests
from django.contrib.auth import get_user_model
from django.db import transaction

from ..._redaction import redact_exception_message, scrub_exception
from ...models import (
    AuditFinding,
    FindingSkipReason,
    OrphanAction,
    OrphanCategory,
)
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 (
    _AUTH_FAILED_ERROR_MESSAGE,
    AppliedAction,
    AuditPlan,
    OrphanFinding,
)
from ._helpers import (
    _arrange_kick_handoff,
    _legitimate_role_ids_for,
    _skipped_action,
    _strip_managed_roles,
)

if TYPE_CHECKING:  # pragma: no cover
    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

User = get_user_model()


# --- 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


@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 ``was_kicked`` for handoff decision."""

    was_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
    ``_finding._orchestrator._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 _require_plan_client(self) -> tuple[AuditPlan, AuditedDiscordClient]:
        """Narrow ``plan`` / ``client`` to non-None for the HTTP phases."""
        # ``plan`` / ``client`` are optional on the executor because the
        # persistence-only methods (``emit_skipped`` / ``emit_failed`` /
        # ``http_error_or_auth`` / ``phase3_persist``) never touch them.
        # The two phases that issue Discord HTTP calls need both; this
        # helper raises a single uniform AssertionError instead of two
        # near-identical guards.
        if self.plan is None or self.client is None:
            msg = (
                f"{type(self).__name__} requires plan + client for this phase"
            )
            raise AssertionError(msg)
        return self.plan, self.client

    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. Gate the counter bump on
            # the FSM CAS result, matching the discipline of every
            # other terminal site (``_emit_terminal_skip:282`` and
            # ``phase3_persist`` below). Under the single-writer-per-run
            # invariant the CAS cannot lose here, but the local
            # invariant must not depend on the higher-level guarantee:
            # a retry hitting an already-APPLIED REPORT finding would
            # otherwise double-bump ``actions_applied`` past
            # ``findings_count``.
            transitioned = persist_finding_applied(
                self.db_finding,
                role_ids_removed=(),
                role_names_removed=(),
            )
            if transitioned:
                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(),
                was_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."""
        return self._emit_terminal_skip(
            skipped_reason=skipped_reason,
            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.
        return self._emit_terminal_skip(
            skipped_reason=skipped_reason,
            role_ids_removed=role_ids_removed,
            error_message=error_message,
        )

    def _emit_terminal_skip(
        self,
        *,
        skipped_reason: FindingSkipReason,
        final_category: OrphanCategory | None = None,
        role_ids_removed: frozenset[int] = frozenset(),
        error_message: str | None = None,
    ) -> AppliedAction:
        """Persist SKIPPED-or-FAILED row + counter; return AppliedAction."""
        # Shared body for ``emit_skipped`` / ``emit_failed``: the only
        # behavioural difference is which persistence helper flips the
        # DB row (``persist_finding_skipped`` writes SKIPPED;
        # ``persist_finding_failed`` writes FAILED with an
        # error_message). The counter bump and the returned
        # ``AppliedAction`` shape are identical.
        #
        # ``persist_finding_*`` 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``.
        # ``role_ids_removed`` plumbs partial-strip outcome through to
        # the DB row, so a 403 / auth abort after K of N successful
        # DELETEs leaves a forensic trail that matches the actual
        # Discord guild state. The in-memory ``AppliedAction`` already
        # carried this; the DB row used to be silent.
        # ``persist_finding_*`` skips the field when the collection is
        # empty to keep policy-skip writes minimal.
        partial = role_ids_removed or None
        if error_message is None:
            transitioned = persist_finding_skipped(
                self.db_finding,
                reason=skipped_reason,
                role_ids_removed=partial,
            )
        else:
            transitioned = persist_finding_failed(
                self.db_finding,
                error_message=error_message,
                role_ids_removed=partial,
            )
        if transitioned:
            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 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, client = self._require_plan_client()

        # 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 — see module docstring's "Exception classifier note"
            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,
                )
            )
        # Past the equality check, ``final_category`` equals
        # ``self.finding.category`` — downstream consumers (the
        # _Phase1Done envelope, persistent_role_ids_for) read the
        # ``self.finding.category`` value directly so there is no
        # narrowing-only reassignment to maintain.
        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=self.finding.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. The User lock is
        # combined with ``prefetch_related("groups", "profile__state")``
        # so the post-lock ``_legitimate_role_ids_for`` reads from the
        # prefetch cache rather than re-issuing one SELECT per group +
        # one per profile.state (was 2-3 extra queries per finding).
        with transaction.atomic():
            locked_user: AbstractUser | None = None
            if self.finding.aa_user_id is not None:
                locked_user = cast(
                    "AbstractUser | None",
                    User.objects.select_for_update()
                    .prefetch_related("groups", "profile__state")
                    .filter(pk=self.finding.aa_user_id)
                    .first(),
                )
                list(
                    DiscordUser.objects.select_for_update().filter(
                        uid=self.finding.member_id
                    )
                )
            # ``locked_user`` reads POST-LOCK, which is the freshest
            # source for legitimate-role derivation. ``final_user``
            # came from the pre-lock classify and is kept as the
            # fallback for the (impossible-under-PK-equality but
            # cheap to handle) case where the row vanished between
            # classify and ``select_for_update``.
            legit_user = locked_user if locked_user is not None else final_user
            role_ids = derive_managed_role_ids(
                live_member,
                role_by_id=plan.role_by_id,
                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(legit_user, plan.role_catalog)
                        if legit_user is not None
                        else None
                    ),
                    member_category=final_category,
                    reserved_names=plan.reserved_role_names,
                ),
            )
            role_names_removed = tuple(
                name
                for rid in role_ids
                if (name := plan.role_name_by_id.get(rid)) is not None
            )

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

    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, client = self._require_plan_client()

        reason = build_reason(
            run_id=plan.run_id,
            category=self.finding.category.value,
            action=self.action.value,
        )
        was_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
            ):
                was_kicked = True
        except Exception as exc:  # noqa: BLE001 — see module docstring's "Exception classifier note"
            return _Phase2Skipped(
                outcome=self.http_error_or_auth(
                    exc, partial_role_ids=frozenset(succeeded_role_ids)
                )
            )
        return _Phase2Done(was_kicked=was_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,
                was_kicked=p2.was_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.was_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,
            was_kicked=p2.was_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."""
        # 401 → FAILED (auth must alert the run-level handler);
        # 403 → SKIPPED (per-target permission gap, run continues);
        # anything else → generic HTTP_ERROR FAILED with ``str(exc)``.
        kind = _classify_http(exc)
        if kind == "auth":
            # Single source of truth lives in ``audit._types`` so this
            # executor can import it top-level; the previous home in
            # ``audit._apply`` forced a lazy in-function import to dodge
            # a circular cycle (``_apply`` imports ``_apply_finding``
            # from this package).
            return self.emit_failed(
                skipped_reason=FindingSkipReason.AUTH_FAILED,
                role_ids_removed=partial_role_ids,
                error_message=_AUTH_FAILED_ERROR_MESSAGE,
            )
        if kind == "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_exception_message(exc),
        )


def _classify_http(exc: Exception) -> Literal["auth", "forbidden", "generic"]:
    """Map a Discord-side exception to the executor's response branch."""
    # Non-HTTPError raises (ConnectionError / Timeout / RequestException)
    # do not carry a response, so they fall through to ``generic`` for
    # ``str(exc)``-based forensic logging. Within HTTPError, only the
    # two statuses the audit's policy distinguishes are pulled out;
    # everything else (5xx server error, 404 stale member, unexpected
    # 4xx) is generic — operators read the status from the message.
    #
    # Defensive ``__cause__`` / ``__context__`` walk: urllib3's
    # ``MaxRetryError.reraise`` can produce an ``HTTPError`` whose
    # ``.response`` was set to None during the retry chain even
    # though an INNER ``HTTPError`` in the cause chain still carries
    # the original 401 / 403 response. Without this walk, a token
    # revocation that travels through retry exhaustion would fall
    # to ``generic`` and the audit would keep firing requests
    # instead of escalating to AUTH_FAILED on the run.
    candidate: BaseException | None = exc
    while candidate is not None:
        if (
            isinstance(candidate, requests.exceptions.HTTPError)
            and candidate.response is not None
        ):
            status = candidate.response.status_code
            if status == HTTPStatus.UNAUTHORIZED:
                return "auth"
            if status == HTTPStatus.FORBIDDEN:
                return "forbidden"
            return "generic"
        candidate = candidate.__cause__ or candidate.__context__
    return "generic"
