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

import logging
from dataclasses import replace
from types import MappingProxyType
from typing import TYPE_CHECKING, Final

from django.utils import timezone

from .._redaction import redact_str, scrub_exception
from ..models import (
    AuditFinding,
    AuditFindingState,
    AuditRun,
    AuditRunState,
    DiscordIdentity,
    FindingSkipReason,
    InitialAuditAcknowledgement,
    OrphanAction,
    ProtectedDiscordMember,
)
from ..persistence import (
    flip_run_state,
    increment_run_counters,
    persist_finding_failed,
    persist_first_run_coercion,
)
from ..policy import CategoryPolicy, policy_to_snapshot
from ._finding import _apply_finding, _skipped_action
from ._types import AppliedAction, AuditPlan, AuditResult, OrphanFinding

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

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

# Single source of truth for the auth-fail forensic tag. Three sites
# write it: ``_finding._http_error_or_auth`` (per-AuditFinding row),
# ``_walk_findings`` here (AuditRun.error_message via FAILED flip),
# and ``_resume.run_resume`` (same flip in the resume path). Pre-
# Round-4 the three diverged ("auth_failed" snake on the finding,
# "auth failed" space on the run); a forensic operator filtering by
# value caught only one or the other. ``_finding.py`` reads this
# constant via a lazy import inside ``_http_error_or_auth`` (the
# 401 branch) — see the comment there for the circular-import note.
_AUTH_FAILED_ERROR_MESSAGE: Final[str] = "auth failed"


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_state=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 flip_run_state(
        run,
        AuditRunState.RUNNING,
        expected_state=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)
    )

    applied, deadline_hit, auth_failed, auth_error = _walk_findings(
        plan, client, run, protected_uids
    )

    return _finalize_apply(
        plan, run, applied, auth_failed, auth_error, deadline_hit
    )


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

    Returns ``(applied, deadline_hit, auth_failed, auth_error)``.
    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
    auth_error = ""
    for finding in plan.findings:
        if timezone.now() >= plan.deadline_at:
            deadline_hit = True
            break
        if auth_failed:
            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
            auth_error = _AUTH_FAILED_ERROR_MESSAGE
    return applied, deadline_hit, auth_failed, auth_error


def _finalize_apply(
    plan: AuditPlan,
    run: AuditRun,
    applied: list[AppliedAction],
    auth_failed: bool,
    auth_error: str,
    deadline_hit: bool,
) -> AuditResult:
    """Flip the run to its terminal state + build the AuditResult."""
    # One ``flip_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_state=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 by an unconditional save. The matrix
    # already permits RUNNING → all four terminal states, so this is
    # purely defensive — but the matrix-validation only fires for the
    # gated form.
    if auth_failed:
        flip_run_state(
            run,
            AuditRunState.FAILED,
            error_message=auth_error,
            expected_state=AuditRunState.RUNNING,
        )
        return AuditResult(
            run_id=plan.run_id,
            state=AuditRunState.FAILED,
            applied=tuple(applied),
            error_message=auth_error,
        )
    if deadline_hit:
        flip_run_state(
            run,
            AuditRunState.BUDGET_EXCEEDED,
            expected_state=AuditRunState.RUNNING,
        )
        return AuditResult(
            run_id=plan.run_id,
            state=AuditRunState.BUDGET_EXCEEDED,
            applied=tuple(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,
    }
    flip_run_state(
        run,
        AuditRunState.SUCCEEDED,
        summary=summary,
        expected_state=AuditRunState.RUNNING,
    )
    return AuditResult(
        run_id=plan.run_id,
        state=AuditRunState.SUCCEEDED,
        applied=tuple(applied),
    )


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.
    # ``scrub_exception`` BEFORE ``logger.exception`` so the formatted
    # traceback in the ERROR log line cannot leak the webhook URL.
    # 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.
    scrub_exception(exc)
    logger.exception(
        "apply_finding raised for run=%s member_id=%s",
        plan.run_id,
        finding.member_id,
    )
    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 ``_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_str(str(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),
    )
