"""
``apply_plan``: walk an ``AuditPlan`` and execute each finding.

This module is the orchestrator: it loads the run row, applies
the first-run-lock coercion, flips PENDING→RUNNING via a
compare-and-set, refreshes ``last_seen_in_guild_at`` for the
observed uids, then iterates findings calling
``_apply_finding`` (in ``._finding``) on each. The exception
handling around the per-finding call lives in
``_handle_apply_exception`` here so the orchestrator stays
focused on control flow.
"""

from __future__ import annotations

from dataclasses import dataclass, replace
from types import MappingProxyType
from typing import TYPE_CHECKING

from django.utils import timezone

from .._log import get_logger
from .._metrics import observe_finding, observe_run_completed
from .._redaction import log_redacted_exception, redact_exception_message
from ..models import (
    AuditFinding,
    AuditFindingState,
    AuditRun,
    AuditRunState,
    DiscordIdentity,
    FindingSkipReason,
    InitialAuditAcknowledgement,
    OrphanAction,
    ProtectedDiscordMember,
)
from ..persistence import (
    diagnose_lost_cas_message,
    increment_run_counters,
    persist_finding_failed,
    persist_first_run_coercion,
    transition_run_state,
)
from ..policy import CategoryPolicy, policy_to_snapshot
from ._finding import _apply_finding, _skipped_action
from ._types import (
    _AUTH_FAILED_ERROR_MESSAGE,
    AppliedAction,
    AuditPlan,
    AuditResult,
    OrphanFinding,
)

if TYPE_CHECKING:
    from ..client import AuditedDiscordClient
    from ..policy import Policy

logger = get_logger(__name__)


def apply_plan(
    plan: AuditPlan,
    client: AuditedDiscordClient,
) -> AuditResult:
    """
    Walk ``plan.findings`` applying the resolved action for each.

    First-run-lock coercion is the very first thing this function
    does after loading the run row — operators with no
    ``InitialAuditAcknowledgement`` row get a no-op-with-log even
    if they passed ``--action strip_kick --yes``.
    """
    run = AuditRun.objects.get(pk=plan.run_id)

    # First-run lock: coerce to REPORT for every category, persist
    # the coerced policy, mark the snapshot. The coerced map's
    # entries are full ``CategoryPolicy`` envelopes so the post-
    # coercion plan still satisfies the ``Policy`` type alias the
    # rest of the apply path consumes.
    if not InitialAuditAcknowledgement.objects.exists():
        coerced_policy: Policy = MappingProxyType(
            {
                category: CategoryPolicy(default=OrphanAction.REPORT)
                for category in plan.policy
            }
        )
        logger.warning(
            "First-run lock active; coercing actions to REPORT for run %s. "
            "Create an InitialAuditAcknowledgement row to release the lock.",
            plan.run_id,
        )
        plan = replace(
            plan, policy=coerced_policy, cli_override=OrphanAction.REPORT
        )
        persist_first_run_coercion(run, policy_to_snapshot(coerced_policy))

    # Flip RUNNING. Gated by ``expected=PENDING`` so two apply_plan
    # calls racing on the same run (Celery retry without task-level
    # dedup, or operator double-launch) cannot both proceed: the
    # second compare-and-set sees rowcount=0 and we
    # raise to surface the duplication instead of silently double-
    # applying findings.
    if not transition_run_state(
        run,
        AuditRunState.RUNNING,
        expected=AuditRunState.PENDING,
    ):
        raise RuntimeError(
            f"AuditRun id={run.pk} is not in PENDING state; another "
            f"worker has already started it (current state observed "
            f"in DB will reflect the winner). Refusing to double-apply."
        )

    # Refresh ``last_seen_in_guild_at`` for every uid the build saw.
    # Moved here from build_plan so the read-only contract of
    # build_plan holds and so --dry-run truly writes nothing.
    if not run.dry_run and plan.observed_uids:
        DiscordIdentity.objects.filter(uid__in=plan.observed_uids).update(
            last_seen_in_guild_at=timezone.now()
        )

    protected_uids = frozenset(
        ProtectedDiscordMember.objects.values_list("uid", flat=True)
    )

    outcome = _walk_findings(plan, client, run, protected_uids)

    return _finalize_apply(plan, run, outcome)


@dataclass(frozen=True)
class _WalkOutcome:
    """Result of ``_walk_findings`` consumed by ``_finalize_apply``."""

    applied: list[AppliedAction]
    deadline_hit: bool = False
    auth_failed: bool = False


def _walk_findings(
    plan: AuditPlan,
    client: AuditedDiscordClient,
    run: AuditRun,
    protected_uids: frozenset[int],
) -> _WalkOutcome:
    """
    Walk plan.findings, return loop outcome for ``_finalize_apply``.

    Wrapped helper keeps ``apply_plan`` below the project's
    cyclomatic-complexity threshold AND mirrors the resume path's
    ``_resume_per_finding`` helper so the per-iteration semantics
    are factored once across both call sites.
    """
    applied: list[AppliedAction] = []
    deadline_hit = False
    auth_failed = False
    for finding in plan.findings:
        if timezone.now() >= plan.deadline_at:
            deadline_hit = True
            break
        outcome: AppliedAction | None
        try:
            outcome = _apply_finding(
                plan, finding, client, run, protected_uids
            )
        except Exception as exc:  # noqa: BLE001 — _handle_apply_exception classifies any raise (HTTP, ORM, sys-level) into SKIPPED/FAILED + counter bookkeeping; auth/transient classification belongs deeper.
            outcome = _handle_apply_exception(plan, finding, run, exc)
            if outcome is None:
                continue
        applied.append(outcome)
        if outcome.skipped_reason == FindingSkipReason.AUTH_FAILED:
            auth_failed = True
            break
    return _WalkOutcome(
        applied=applied,
        deadline_hit=deadline_hit,
        auth_failed=auth_failed,
    )


def _finalize_apply(
    plan: AuditPlan,
    run: AuditRun,
    outcome: _WalkOutcome,
) -> AuditResult:
    """Flip the run to its terminal state + build the AuditResult."""
    # One ``transition_run_state`` + one ``AuditResult`` builder per
    # branch so the (state, error, summary) triple cannot drift
    # across the three terminal paths. Branched (vs splat'd kwargs)
    # so each call site is type-checked against the typed signature.
    #
    # ``expected=RUNNING`` gates every terminal flip on the run
    # still being RUNNING. A racing ``--abandon`` (operator's
    # force-quit hatch) or signal-flip to INTERRUPTED would otherwise
    # be silently overwritten. The matrix already permits RUNNING →
    # all four terminal states, so this is purely defensive — but the
    # matrix-validation only fires for the gated form.
    #
    # A lost CAS (rowcount=0) is surfaced as a WARNING log so the
    # operator can correlate a "result says SUCCEEDED but admin shows
    # ABANDONED" divergence with whatever raced in. The ``AuditResult``
    # still reports what this walk produced — the DB row carries the
    # truth via the concurrent writer's flip.
    _observe_findings(outcome.applied)
    if outcome.auth_failed:
        if not transition_run_state(
            run,
            AuditRunState.FAILED,
            error_message=_AUTH_FAILED_ERROR_MESSAGE,
            expected=AuditRunState.RUNNING,
        ):
            _log_terminal_flip_race(run, AuditRunState.FAILED)
        _observe_run(run, AuditRunState.FAILED)
        return AuditResult(
            run_id=plan.run_id,
            state=AuditRunState.FAILED,
            applied=tuple(outcome.applied),
            error_message=_AUTH_FAILED_ERROR_MESSAGE,
        )
    if outcome.deadline_hit:
        if not transition_run_state(
            run,
            AuditRunState.BUDGET_EXCEEDED,
            expected=AuditRunState.RUNNING,
        ):
            _log_terminal_flip_race(run, AuditRunState.BUDGET_EXCEEDED)
        _observe_run(run, AuditRunState.BUDGET_EXCEEDED)
        return AuditResult(
            run_id=plan.run_id,
            state=AuditRunState.BUDGET_EXCEEDED,
            applied=tuple(outcome.applied),
        )
    # Refresh from DB before reading the counters: a phase-3 atomic
    # rollback (e.g. ``_arrange_kick_handoff`` raise) rolls back the
    # SQL ``F('actions_applied') + 1`` but NOT the in-memory mirror
    # in ``increment_run_counters``. Reading the in-memory value
    # would persist a summary that disagrees with the DB row.
    run.refresh_from_db(fields=("actions_applied", "actions_skipped"))
    summary: dict[str, object] = {
        **(run.summary or {}),
        "applied": run.actions_applied,
        "skipped": run.actions_skipped,
    }
    if not transition_run_state(
        run,
        AuditRunState.SUCCEEDED,
        summary=summary,
        expected=AuditRunState.RUNNING,
    ):
        _log_terminal_flip_race(run, AuditRunState.SUCCEEDED)
    _observe_run(run, AuditRunState.SUCCEEDED)
    return AuditResult(
        run_id=plan.run_id,
        state=AuditRunState.SUCCEEDED,
        applied=tuple(outcome.applied),
    )


# ``FindingSkipReason`` values that carry an *error* semantic — the
# finding's action could not be carried out because of an outage or a
# permissions failure on the Discord side. Other skip reasons (PROTECTED,
# RECLASSIFIED, MANUALLY_REVIEWED, INTERNAL_INCONSISTENCY) are deliberate
# non-acts, not failures. Splitting them here keeps the metric's
# ``outcome`` label honest — operators reading a
# ``rate(aa_discord_audit_findings_total{outcome="failed"}[5m])`` panel
# want errors, not policy-driven skips.
_FAILURE_SKIP_REASONS: frozenset[FindingSkipReason] = frozenset(
    {
        FindingSkipReason.HTTP_ERROR,
        FindingSkipReason.AUTH_FAILED,
        FindingSkipReason.FORBIDDEN_403,
    }
)


def _finding_outcome_label(applied: AppliedAction) -> str:
    """Resolve the metric ``outcome`` label for an :class:`AppliedAction`."""
    if applied.skipped_reason is None:
        return "applied"
    if applied.skipped_reason in _FAILURE_SKIP_REASONS:
        return "failed"
    return "skipped"


def _observe_findings(applied_actions: list[AppliedAction]) -> None:
    """Emit per-finding metrics for every walked outcome."""
    for action in applied_actions:
        observe_finding(
            category=action.finding.category.value,
            action=action.action.value,
            outcome=_finding_outcome_label(action),
        )


def _observe_run(run: AuditRun, state: AuditRunState) -> None:
    """
    Emit the terminal run-completion metric.

    Duration is the local wall-clock between the row's ``started_at``
    and now — a lost terminal CAS would not change what *this* walk
    spent; the DB's authoritative ``finished_at`` may differ on a race
    but the observer is reporting its own work. ``trigger`` reads from
    the persisted ``RunTrigger`` value so the label aligns with the
    enum vocabulary.
    """
    duration_seconds = (timezone.now() - run.started_at).total_seconds()
    observe_run_completed(
        state=state.value,
        trigger=run.triggered_by,
        duration_seconds=duration_seconds,
    )


def _log_terminal_flip_race(run: AuditRun, intended: AuditRunState) -> None:
    """Surface a lost CAS at the terminal flip so divergence is auditable."""
    # A WARNING (not ERROR): the lost CAS is a legitimate race outcome
    # (operator ran ``--abandon`` or a SIGTERM flipped INTERRUPTED
    # between the loop's last iteration and this flip). Without the
    # log, ``AuditResult`` says SUCCEEDED while the DB row stays
    # ABANDONED/INTERRUPTED and the operator chases a phantom
    # success in the webhook embed.
    logger.warning(
        "%s",
        diagnose_lost_cas_message(
            run, context="terminal flip", intended=intended.value
        ),
    )


def _handle_apply_exception(
    plan: AuditPlan,
    finding: OrphanFinding,
    run: AuditRun,
    exc: Exception,
) -> AppliedAction | None:
    """Translate a per-finding raise into a SKIPPED AppliedAction (or None)."""
    # Two cases the apply loop must distinguish:
    #
    # 1. Row is still PENDING — phase 1 / phase 2 raised before phase 3
    #    committed. Persist FAILED, bump ``actions_skipped``, return a
    #    SKIPPED ``AppliedAction(skipped_reason=HTTP_ERROR)``.
    # 2. Row is already terminal (APPLIED / SKIPPED / FAILED) — phase 3
    #    atomic already committed and the raise came from a post-commit
    #    side effect (typically an ``on_commit`` callback's broker /
    #    kick failure). The work was already counted by phase 3.
    #    Double-counting would corrupt the run summary and emitting a
    #    SKIPPED outcome would contradict the persisted APPLIED state.
    #    Return None so the caller leaves counters untouched.
    # The audit's Discord-API URL does not carry the bot token, but a
    # nested ``MaxRetryError`` / proxy chain inside the same
    # ``requests.Session`` can still embed the operator's webhook URL
    # — symmetry with the apply / kick / retry sites is the cheapest
    # invariant. ``log_redacted_exception`` couples the scrub + log so
    # the order cannot drift.
    log_redacted_exception(
        logger,
        "apply_finding raised for run=%s member_id=%s",
        plan.run_id,
        finding.member_id,
        exc=exc,
    )
    db_finding = AuditFinding.objects.filter(
        run=run, member_id=finding.member_id
    ).first()
    still_pending = (
        db_finding is None
        or db_finding.state == AuditFindingState.PENDING.value
    )
    if not still_pending:
        logger.warning(
            "apply_finding raised after phase-3 commit for "
            "run=%s member_id=%s state=%s — counters NOT "
            "double-bumped (work already accounted for)",
            plan.run_id,
            finding.member_id,
            db_finding.state,
        )
        return None
    # Gate counter bump on the FSM CAS actually firing. Under the
    # single-writer-per-run invariant the rowcount cannot lose, but
    # the local invariant must not depend on the higher-level guarantee
    # — symmetry with the executor's ``phase3_persist`` and ``emit_*``
    # is the cheapest defense-in-depth.
    transitioned = db_finding is not None and persist_finding_failed(
        db_finding, error_message=redact_exception_message(exc)
    )
    if db_finding is None or transitioned:
        # ``db_finding is None`` is the build/apply-disagree path
        # (Round-2 INTERNAL_INCONSISTENCY) — the counter bump tracks
        # the in-memory finding even when no DB row exists to flip.
        increment_run_counters(run, actions_skipped=1)
    # ``aa_user`` not threaded here: this AppliedAction is the
    # SKIPPED outcome for an exception-mid-finding case where the
    # per-user policy resolution would not change behaviour (the
    # row is going to FAILED regardless).
    return _skipped_action(
        finding,
        skipped_reason=FindingSkipReason.HTTP_ERROR,
        action=plan.action_for(finding.category, member_id=finding.member_id),
    )
