"""
``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, Literal

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,
    RunTrigger,
)
from ..persistence import (
    diagnose_lost_cas_message,
    increment_run_counters,
    persist_beat_coercion,
    persist_confirmation_coercion,
    persist_finding_failed,
    persist_first_run_coercion,
    transition_run_state,
)
from ..policy import (
    CategoryPolicy,
    policy_has_destructive,
    policy_to_snapshot,
)
from ._finding import (
    _apply_finding,
    _skipped_action,
    preload_users_for_findings,
)
from ._types import (
    _AUTH_FAILED_ERROR_MESSAGE,
    AppliedAction,
    AuditPlan,
    AuditResult,
    OrphanFinding,
)

if TYPE_CHECKING:  # pragma: no cover
    from collections.abc import Callable

    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.

    Two safety coercions run before any finding is touched, via
    ``coerce_plan_for_safety``:

    1. First-run-lock coercion — operators with no
       ``InitialAuditAcknowledgement`` row get a no-op-with-log even
       if they passed ``--action strip_kick --yes``.
    2. Engine-side confirmation gate — a run whose
       ``was_confirmation_bypassed`` flag is ``False`` has every
       destructive action coerced to REPORT, so a non-CLI caller
       (future web button, direct ``apply_plan``) cannot reach
       strip/kick without an explicit confirmation signal.
    """
    run = AuditRun.objects.get(pk=plan.run_id)

    plan = coerce_plan_for_safety(plan, run)

    # 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`` is captured ONCE per run start; subsequent
    # admin edits to ``ProtectedDiscordMember`` during the run do NOT
    # apply to in-flight findings. Two reasons this is by-design
    # rather than a TOCTOU bug: (a) a moving target makes
    # the apply pass non-replayable — ``--resume`` would see a
    # different protect set than the original walk; (b) operators
    # who decide mid-run to defend a uid still have ``--abandon``
    # available, which is more honest than a partial protect that
    # only blocks findings the walk hasn't reached yet. Document
    # here so operator surprise has a single answerable source.
    protected_uids = frozenset(
        ProtectedDiscordMember.objects.values_list("uid", flat=True).iterator()
    )

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

    return _finalize_apply(plan, run, outcome)


def _coerce_policy_to_report(policy: Policy) -> Policy:
    """Build an all-REPORT policy with the same category keys as ``policy``."""
    # 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.
    return MappingProxyType(
        {
            category: CategoryPolicy(default=OrphanAction.REPORT)
            for category in policy
        }
    )


def _plan_has_destructive_intent(plan: AuditPlan) -> bool:
    """
    Return ``True`` if any reachable action in ``plan`` is destructive.

    Conservative / fail-safe: it inspects ``cli_override``, every
    ``CategoryPolicy`` branch (``default`` + ``by_group`` + ``by_state``
    values), and every per-uid override — so a destructive action
    reachable through ANY routing branch trips the gate even when no
    finding happens to route to it on this walk. Over-coercion (gating
    a run whose specific findings would not have hit the destructive
    branch) is the safe direction; under-coercion would let strip/kick
    slip past an unconfirmed run.

    Read-only audits whose policy resolves exclusively to REPORT return
    ``False`` so the gate stays silent on the common benign path — no
    spurious WARNING, no ``confirmation_coerced`` marker.
    """
    if plan.cli_override is not None and plan.cli_override.is_destructive:
        return True
    if any(action.is_destructive for action in plan.override_by_uid.values()):
        return True
    return policy_has_destructive(plan.policy)


def _apply_report_coercion(
    plan: AuditPlan,
    run: AuditRun,
    *,
    persist: Callable[[AuditRun, dict[str, object]], None],
    warning: str,
) -> AuditPlan:
    """
    Coerce ``plan`` to all-REPORT, log ``warning``, persist the marker.

    Shared tail of the three safety gates (first-run lock,
    beat-destructive, unconfirmed-destructive). Each gate keeps its own
    predicate and supplies the gate-specific ``persist`` writer (whose
    DISTINCT summary marker keeps the forensic reasons apart) and the
    ``warning`` log template (one ``%s`` for the run id). The returned
    plan carries ``cli_override=REPORT`` so downstream action resolution
    sees the coercion.
    """
    coerced_policy = _coerce_policy_to_report(plan.policy)
    logger.warning(warning, plan.run_id)
    persist(run, policy_to_snapshot(coerced_policy))
    return replace(
        plan, policy=coerced_policy, cli_override=OrphanAction.REPORT
    )


def coerce_plan_for_safety(plan: AuditPlan, run: AuditRun) -> AuditPlan:
    """
    Apply the first-run-lock, beat-destructive, and confirmation
    coercions to ``plan``.

    Used by ``apply_plan`` (the fresh-apply / programmatic entry point).
    The coercions are layered: the first-run lock already forces every
    action to REPORT and persists its own marker, so when it fires the
    later gates are skipped (the run is already safe and re-marking would
    double-attribute the coercion). Otherwise the beat-destructive gate
    runs first (downgrading an un-opted-in unattended beat), then the
    confirmation gate. A plan already coerced to all-REPORT by the beat
    gate carries no destructive intent, so the confirmation gate then
    stays silent.

    The ``--resume`` path does NOT call this helper — it enforces the
    first-run lock through the persisted ``first_run_lock_was_active``
    refusal upstream (``run_resume``) rather than a live
    ``InitialAuditAcknowledgement`` check, and applies only the
    confirmation gate via ``coerce_unconfirmed_destructive`` so a later
    ack-row deletion cannot retroactively coerce an already-confirmed
    queued run.
    """
    if not InitialAuditAcknowledgement.objects.exists():
        return _apply_report_coercion(
            plan,
            run,
            persist=persist_first_run_coercion,
            warning=(
                "First-run lock active; coercing actions to REPORT for run "
                "%s. Run 'aa-manager audit_acknowledge_initial' (or use "
                "Django admin) to release the lock."
            ),
        )

    plan = coerce_beat_destructive(plan, run)
    return coerce_unconfirmed_destructive(plan, run)


def _beat_destructive_allowed() -> bool:
    """Read the beat-destructive opt-in per call (override_settings-safe)."""
    # Read off ``settings`` rather than an import-frozen module constant so
    # tests using ``override_settings`` and operators flipping the knob at
    # runtime both take effect without a process restart — same pattern as
    # the CLI-actor setting. Absent setting defaults to the safe ``False``.
    from django.conf import settings

    return bool(
        getattr(settings, "AA_DISCORD_AUDIT_BEAT_ALLOW_DESTRUCTIVE", False)
    )


def coerce_beat_destructive(plan: AuditPlan, run: AuditRun) -> AuditPlan:
    """
    Coerce destructive actions to REPORT for an un-opted-in beat run.

    The first-run lock is a one-shot human gate: releasing it for a single
    manual CLI run otherwise arms the unattended Celery beat to strip /
    kick guild-wide on every tick. This gate is independent of that lock —
    a beat-triggered run (``run.triggered_by == BEAT``) carrying
    destructive intent is downgraded to REPORT unless the operator
    explicitly set ``AA_DISCORD_AUDIT_BEAT_ALLOW_DESTRUCTIVE``.

    Keyed on ``run.triggered_by`` so only the unattended path is gated;
    CLI / manual runs are untouched here (their own confirmation gate
    applies). Resume of a beat run inherits the same gate because it
    passes the same ``run`` row. Fires only when the plan actually
    carries destructive intent, so a benign report-only beat stays silent.
    """
    is_beat = run.triggered_by == RunTrigger.BEAT
    if (
        is_beat
        and not _beat_destructive_allowed()
        and _plan_has_destructive_intent(plan)
    ):
        return _apply_report_coercion(
            plan,
            run,
            persist=persist_beat_coercion,
            warning=(
                "Beat-destructive gate active; run %s is beat-triggered and "
                "AA_DISCORD_AUDIT_BEAT_ALLOW_DESTRUCTIVE is not set, so "
                "destructive actions are coerced to REPORT. Set the flag to "
                "opt the unattended beat into strip/kick."
            ),
        )

    return plan


def coerce_unconfirmed_destructive(
    plan: AuditPlan, run: AuditRun
) -> AuditPlan:
    """
    Coerce destructive actions to REPORT when the run was not confirmed.

    The engine-side confirmation gate, shared by ``apply_plan`` and
    ``--resume`` so the invariant lives in ONE place. It keys SOLELY on
    ``run.was_confirmation_bypassed`` — NOT on ``run.triggered_by`` — so
    both the CLI ``--yes`` path and the beat task (which sets
    ``was_confirmation_bypassed=True`` by design) are authorized, while
    any path without the flag is coerced fail-safe regardless of trigger.
    Resume inherits the original run's flag because it passes the same
    ``run`` row.

    Only fires when the plan carries destructive intent
    (``_plan_has_destructive_intent``) so a benign read-only audit
    without confirmation stays silent — no spurious warning or marker.
    """
    if not run.was_confirmation_bypassed and _plan_has_destructive_intent(
        plan
    ):
        return _apply_report_coercion(
            plan,
            run,
            persist=persist_confirmation_coercion,
            warning=(
                "Confirmation gate active; run %s was not confirmed "
                "(was_confirmation_bypassed=False) so destructive actions "
                "are coerced to REPORT. Re-run with --yes (CLI) or supply "
                "the confirmation signal to act destructively."
            ),
        )

    return plan


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

    applied: list[AppliedAction]
    was_deadline_hit: bool = False
    did_auth_fail: 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.
    """
    users_by_id = preload_users_for_findings(
        f.aa_user_id for f in plan.findings
    )
    applied: list[AppliedAction] = []
    was_deadline_hit = False
    did_auth_fail = False
    for finding in plan.findings:
        if timezone.now() >= plan.deadline_at:
            was_deadline_hit = True
            break
        outcome: AppliedAction | None
        try:
            outcome = _apply_finding(
                plan, finding, client, run, protected_uids, users_by_id
            )
        except Exception as exc:  # noqa: BLE001 — catches every truly-recoverable error (HTTP, ORM, classifier raise) but NOT SystemExit/KeyboardInterrupt: the audit_discord_roles signal handler raises SystemExit(128+signum) which must propagate up through this except to fire the run-state flush in the command's finally block. _handle_apply_exception classifies the recoverable arm 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:
            did_auth_fail = True
            break
    return _WalkOutcome(
        applied=applied,
        was_deadline_hit=was_deadline_hit,
        did_auth_fail=did_auth_fail,
    )


def _finalize_apply(
    plan: AuditPlan,
    run: AuditRun,
    outcome: _WalkOutcome,
) -> AuditResult:
    """Flip the run to its terminal state + build the AuditResult."""
    # One helper per terminal branch so the (state, error, summary,
    # metric, race-log) quintuple cannot drift across the three
    # paths. ``expected=RUNNING`` gates every flip on the row still
    # being RUNNING — a racing ``--abandon`` or signal-flip to
    # INTERRUPTED must NOT be silently overwritten.
    _observe_findings(outcome.applied)
    if outcome.did_auth_fail:
        return _finalize_terminal(
            plan,
            run,
            outcome,
            state=AuditRunState.FAILED,
            error_message=_AUTH_FAILED_ERROR_MESSAGE,
        )
    if outcome.was_deadline_hit:
        return _finalize_terminal(
            plan,
            run,
            outcome,
            state=AuditRunState.BUDGET_EXCEEDED,
        )
    # 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,
    }
    return _finalize_terminal(
        plan,
        run,
        outcome,
        state=AuditRunState.SUCCEEDED,
        summary=summary,
    )


def _finalize_terminal(
    plan: AuditPlan,
    run: AuditRun,
    outcome: _WalkOutcome,
    *,
    state: AuditRunState,
    error_message: str = "",
    summary: dict[str, object] | None = None,
) -> AuditResult:
    """Run the shared terminal CAS + race-log + metric + AuditResult build."""
    # 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. ``error_message=""`` and
    # ``summary=None`` already match ``transition_run_state``'s
    # defaults, so passing them through directly is equivalent to
    # omitting them and lets the type checker keep the signature
    # contract on the call site.
    if not transition_run_state(
        run,
        state,
        expected=AuditRunState.RUNNING,
        error_message=error_message,
        summary=summary,
    ):
        _log_terminal_flip_race(run, state)
    observe_run_terminal(run, state)
    return AuditResult(
        run_id=plan.run_id,
        state=state,
        applied=tuple(outcome.applied),
        error_message=error_message,
    )


def _finding_outcome_label(
    applied: AppliedAction,
) -> Literal["applied", "failed", "skipped"]:
    """Resolve the metric ``outcome`` label for an :class:`AppliedAction`."""
    if applied.skipped_reason is None:
        return "applied"
    if applied.skipped_reason in FindingSkipReason.failure_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_terminal(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.

    Shared between ``_finalize_apply`` and the ``--resume`` / ``--abandon``
    CLI paths so the same trio (state, trigger, duration) lands in the
    same metric regardless of which terminal CAS fired the flip.
    """
    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
    )
    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
        # (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),
    )
