"""``--resume`` subcommand: re-walk PENDING findings of an existing run."""

from __future__ import annotations

from datetime import timedelta
from types import MappingProxyType
from typing import TYPE_CHECKING

from django.core.management.base import CommandError
from django.utils import timezone

from ...._log import get_logger
from ...._metrics import observe_finding, observe_run_completed
from ....app_settings import AA_DISCORD_AUDIT_RUN_DEADLINE_MINUTES
from ....audit import OrphanFinding
from ....audit._apply import _finding_outcome_label, _handle_apply_exception
from ....audit._finding import _apply_finding
from ....audit._types import _AUTH_FAILED_ERROR_MESSAGE
from ....models import (
    AuditFinding,
    AuditFindingState,
    AuditRunState,
    OrphanCategory,
)
from ....persistence import diagnose_lost_cas_message
from ._invocation import (
    _begin_cli_invocation_accepted,
    _get_run_or_raise,
    _link_invocation,
)

logger = get_logger(__name__)

if TYPE_CHECKING:
    from ....audit._types import AppliedAction, AuditPlan
    from ....client import AuditedDiscordClient
    from ....models import AuditRun
    from ..audit_discord_roles import Command


def _resume_per_finding(
    plan: AuditPlan,
    db_finding: AuditFinding,
    client: AuditedDiscordClient,
    run: AuditRun,
    protected_uids: frozenset[int],
) -> AppliedAction | None:
    """Mirror of apply_plan's per-finding try/except + classifier."""
    # Wrapping the per-iteration body keeps the orchestrator below
    # the project's cyclomatic-complexity threshold AND makes the
    # symmetry with ``apply_plan`` explicit (both paths route any
    # raise through ``_handle_apply_exception`` so a transient
    # broker / DB / HTTP failure cannot leak the run into a stuck
    # RUNNING state).
    in_memory = OrphanFinding(
        member_id=db_finding.member_id,
        member_username=db_finding.member_username,
        category=OrphanCategory(db_finding.category),
        aa_user_id=db_finding.aa_user_id,
    )
    try:
        return _apply_finding(plan, in_memory, 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.
        return _handle_apply_exception(plan, in_memory, run, exc)


def _emit_resume_terminal_message(
    cmd: Command,
    run: AuditRun,
    run_id: int,
    flipped: bool,
) -> None:
    """Print the SUCCEEDED-or-race message after the resume terminal flip."""
    if flipped:
        cmd.stdout.write(
            cmd.style.SUCCESS(
                f"AuditRun id={run_id} resumed; final state SUCCEEDED."
            )
        )
        return
    # The CAS rowcount-False branch (a concurrent ``--abandon`` /
    # signal-flip won the race) is surfaced honestly so the operator
    # can correlate with whatever raced in. The DB row already
    # carries the truth; we just print it.
    #
    # Dual-channel: stdout for the live operator, logger.warning for
    # forensic-aggregator (Sentry / Loki) so a grep on "lost the CAS
    # race" hits every flip site uniformly.
    message = diagnose_lost_cas_message(
        run, context="resume terminal flip", intended="succeeded"
    )
    cmd.stdout.write(cmd.style.WARNING(message))
    logger.warning("%s", message)


# State-keyed refusal templates; ``{run_id}`` is filled at raise-site
# so the lookup stays O(1) on ``run.state`` and a new terminal state
# is added by extending this map (no new ``if`` branch).
_RESUME_STATE_REFUSAL = {
    AuditRunState.SUCCEEDED.value: (
        "AuditRun id={run_id} is already SUCCEEDED; create a fresh "
        "run or use --abandon to mark a stuck run dead."
    ),
    AuditRunState.ABANDONED.value: (
        "AuditRun id={run_id} is ABANDONED; refusing to resume a "
        "manually-abandoned run."
    ),
    AuditRunState.FAILED.value: (
        "AuditRun id={run_id} is FAILED; create a fresh run. "
        "Resume operates on PENDING / INTERRUPTED runs only."
    ),
    AuditRunState.BUDGET_EXCEEDED.value: (
        "AuditRun id={run_id} hit BUDGET_EXCEEDED; create a fresh "
        "run with a longer deadline. Resume operates on PENDING / "
        "INTERRUPTED runs only."
    ),
}


def run_resume(cmd: Command, run_id: int) -> None:
    """Re-apply a PENDING run; refuse if first-run-lock-locked."""
    # Record an AuditInvocation so the resume itself leaves a trail.
    # Resume is exempt from rate-limit accounting (it does not
    # create a new run; it finishes an existing one).
    invocation = _begin_cli_invocation_accepted(action_label="resume an audit")

    # Concurrent ``--resume`` protection lives on the
    # ``transition_run_state(expected=PENDING)`` compare-and-set
    # below, not on a row lock here: the prior ``select_for_update``
    # released its lock the moment the ``with transaction.atomic``
    # block exited (which it did immediately, having only run a
    # single ``get``), so the lock was decorative. The FSM
    # compare-and-set is correct under concurrency: the first
    # resume flips PENDING→RUNNING, the second sees rowcount=0
    # and raises CommandError below.
    run = _get_run_or_raise(run_id)

    if run.first_run_lock_was_active:
        raise CommandError(
            f"AuditRun id={run_id} was created under the first-run "
            f"lock and is permanently coerced to REPORT. To act on a "
            f"destructive policy now that the lock is released, "
            f"create a fresh run (this guard prevents social-"
            f"engineering replay of queued destructive intent across "
            f"an ack release)."
        )
    if (refusal := _RESUME_STATE_REFUSAL.get(run.state)) is not None:
        raise CommandError(refusal.format(run_id=run_id))

    # Walk PENDING findings via apply_plan; the existing infrastructure
    # handles re-classify + state transitions.
    from allianceauth.services.modules.discord.app_settings import (
        DISCORD_GUILD_ID,
    )

    from ....audit import AuditPlan
    from ....client import make_audited_client
    from ....models import (
        FindingSkipReason,
        ProtectedDiscordMember,
    )
    from ....persistence import transition_run_state
    from ....policy import (
        build_bot_uids,
        build_users_with_discord_access,
        policy_from_snapshot,
    )
    from ....role_filter import (
        aa_role_names,
        exempt_role_ids,
        reserved_role_names,
    )

    client = make_audited_client()
    # Resume rebuilds the lightweight plan envelope from persisted
    # state; role_catalog and aa_role_names come from the live DB.
    # Deadline is the same as a fresh run — uses
    # AA_DISCORD_AUDIT_RUN_DEADLINE_MINUTES from settings, NOT the
    # --limit flag (which caps findings, not wall-clock time).
    # Conflating the two in earlier revisions produced 3-day
    # deadlines on --resume --limit 5000.
    role_catalog = tuple(client.guild_roles(guild_id=DISCORD_GUILD_ID))
    plan = AuditPlan(
        run_id=run.pk,
        guild_id=int(DISCORD_GUILD_ID),
        findings=(),  # apply_plan reads from DB
        role_catalog=role_catalog,
        role_name_by_id=MappingProxyType({r.id: r.name for r in role_catalog}),
        aa_role_names=aa_role_names(),
        reserved_role_names=reserved_role_names(),
        bot_uids=build_bot_uids(),
        exempt_role_ids=exempt_role_ids(),
        # Resume re-walks PENDING findings only; observed_uids
        # was already captured at the original build and the
        # last_seen refresh ran then. Empty set here is a no-op
        # in apply_plan's refresh block.
        observed_uids=frozenset(),
        users_with_access=build_users_with_discord_access(),
        policy=policy_from_snapshot(run.policy_snapshot),
        cli_override=None,
        started_at=run.started_at,
        deadline_at=timezone.now()
        + timedelta(minutes=AA_DISCORD_AUDIT_RUN_DEADLINE_MINUTES),
    )

    # Track the active run so the signal handler flips it on Ctrl-C.
    # The reset lives in ``handle``'s outer ``finally`` AFTER
    # ``_flush_signal_to_run`` — see ``handle`` for why an inner
    # reset breaks the SIGTERM flush.
    cmd.track_active_run(run.pk)
    # Compare-and-set: only the first ``--resume`` to reach this
    # point flips PENDING→RUNNING. A racing second resume sees
    # rowcount=0 and raises, leaving its AuditInvocation row with
    # ``audit_run_pk=None`` so the operator can correlate the
    # refusal with the trail.
    # Accept both PENDING (run never started) and INTERRUPTED
    # (signal cut a prior run mid-flight) as resumable predecessors.
    # ``_VALID_RUN_TRANSITIONS`` enumerates both as legal predecessors
    # of RUNNING; the CAS still rejects RUNNING (another worker
    # already started) and the terminal states (already filtered
    # above by ``_RESUME_STATE_REFUSAL``).
    if not transition_run_state(
        run,
        AuditRunState.RUNNING,
        expected=(
            AuditRunState.PENDING,
            AuditRunState.INTERRUPTED,
        ),
    ):
        raise CommandError(
            f"AuditRun id={run.pk} could not be advanced from "
            f"PENDING or INTERRUPTED; another --resume invocation is "
            f"already running or the run state changed underneath."
        )
    # Link the resume invocation to the run NOW, the moment we
    # win the compare-and-set. If ``_apply_finding`` raises mid-
    # loop the invocation row would otherwise be orphaned (no
    # ``audit_run_id``) and the trail would lose the linkage
    # successful resumes record.
    _link_invocation(invocation, run.pk)
    protected_uids = frozenset(
        ProtectedDiscordMember.objects.values_list("uid", flat=True)
    )
    pending_findings = AuditFinding.objects.filter(
        run=run,
        state=AuditFindingState.PENDING.value,
    )
    # Loop-safety mirrors ``apply_plan``: per-iteration deadline
    # check, AUTH_FAILED short-circuit, and per-finding exception
    # translation via ``_handle_apply_exception``. Without these,
    # a 50k-finding resume runs to completion regardless of deadline,
    # a token-revocation 401 from finding #1 still hammers Discord
    # with thousands more failing 401s, and any unhandled exception
    # mid-loop bubbles out leaving the run stuck in RUNNING (only
    # ``--abandon`` could clear it).
    auth_failed = False
    for db_finding in pending_findings:
        # Round-4 M-1: ``auth_failed`` checked BEFORE the deadline so
        # the priority mirrors ``_finalize_apply``'s ordering — when
        # a 401 on iter N flips the flag and the deadline expires
        # between iter N and iter N+1, the run finalizes FAILED
        # (actionable: bot logged out) instead of BUDGET_EXCEEDED
        # (misleading: looks like a slow-run problem). Without the
        # ordering, the deadline-branch's early ``return`` would
        # short-circuit the post-loop ``if auth_failed:`` block.
        if auth_failed:
            break
        if timezone.now() >= plan.deadline_at:
            if not transition_run_state(
                run,
                AuditRunState.BUDGET_EXCEEDED,
                expected=AuditRunState.RUNNING,
            ):
                message = diagnose_lost_cas_message(
                    run,
                    context=(
                        "resume hit deadline but the BUDGET_EXCEEDED flip"
                    ),
                    intended="budget_exceeded",
                )
                cmd.stdout.write(cmd.style.WARNING(message))
                logger.warning("%s", message)
                return
            _observe_resume_terminal(run, AuditRunState.BUDGET_EXCEEDED)
            cmd.stdout.write(
                cmd.style.WARNING(
                    f"AuditRun id={run_id} resumed; deadline hit, "
                    f"final state BUDGET_EXCEEDED."
                )
            )
            return
        outcome = _resume_per_finding(
            plan, db_finding, client, run, protected_uids
        )
        if outcome is None:
            continue
        observe_finding(
            category=outcome.finding.category.value,
            action=outcome.action.value,
            outcome=_finding_outcome_label(outcome),
        )
        if outcome.skipped_reason == FindingSkipReason.AUTH_FAILED:
            auth_failed = True
    if auth_failed:
        if not transition_run_state(
            run,
            AuditRunState.FAILED,
            error_message=_AUTH_FAILED_ERROR_MESSAGE,
            expected=AuditRunState.RUNNING,
        ):
            message = diagnose_lost_cas_message(
                run,
                context="resume saw auth failure but the FAILED flip",
                intended="failed",
            )
            cmd.stdout.write(cmd.style.WARNING(message))
            logger.warning("%s", message)
            return
        _observe_resume_terminal(run, AuditRunState.FAILED)
        cmd.stdout.write(
            cmd.style.WARNING(
                f"AuditRun id={run_id} resumed; auth failed, "
                f"final state FAILED."
            )
        )
        return
    # ``expected=RUNNING`` mirrors the apply_plan terminal flips:
    # a racing ``--abandon`` or signal-flip to INTERRUPTED that lands
    # between the loop's last iteration and this flip must NOT be
    # silently overwritten with SUCCEEDED. The matrix permits RUNNING
    # → SUCCEEDED, so a legitimate flip still succeeds; an illegal
    # source raises ``ValueError`` instead of looking like a no-op.
    flipped = transition_run_state(
        run,
        AuditRunState.SUCCEEDED,
        expected=AuditRunState.RUNNING,
    )
    _observe_resume_terminal(run, AuditRunState.SUCCEEDED)
    _emit_resume_terminal_message(cmd, run, run_id, flipped)


def _observe_resume_terminal(run: AuditRun, state: AuditRunState) -> None:
    """Emit the run-completion metric on a resume terminal flip."""
    # Mirrors ``_apply._observe_run``. Kept inline rather than imported
    # so the resume path's metrics do not load-bear on a private
    # ``_apply`` helper signature; the two sites can drift in
    # implementation while still emitting the same metric.
    duration_seconds = (timezone.now() - run.started_at).total_seconds()
    observe_run_completed(
        state=state.value,
        trigger=run.triggered_by,
        duration_seconds=duration_seconds,
    )
