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

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

from ...._log import get_logger
from ...._metrics import observe_finding
from ....app_settings import AA_DISCORD_AUDIT_RUN_DEADLINE_MINUTES
from ....audit import OrphanFinding
from ....audit._apply import (
    _finding_outcome_label,
    _handle_apply_exception,
    coerce_beat_destructive,
    coerce_unconfirmed_destructive,
    observe_run_terminal,
)
from ....audit._finding import _apply_finding, preload_users_for_findings
from ....audit._types import _AUTH_FAILED_ERROR_MESSAGE
from ....models import (
    AuditFinding,
    AuditFindingState,
    AuditRunState,
    OrphanCategory,
)
from ._invocation import (
    _begin_cli_invocation_accepted,
    _get_run_or_raise,
    _link_invocation,
    flip_or_emit,
)

logger = get_logger(__name__)

# Resume materialises the PENDING-finding set into memory along with
# the preloaded user+groups+profile.state map; a federation-scale guild
# with 500k+ findings would consume ~200 MB before the loop starts. The
# ceiling fails fast with an operator-actionable error rather than
# silent OOM. 50k headroom matches the README's design range.
_PENDING_FINDINGS_HARD_CAP: Final[int] = 100_000

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

    from django.contrib.auth.models import AbstractUser

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


def _finalize_resume(
    cmd: Command,
    run: AuditRun,
    run_id: int,
    *,
    state: AuditRunState,
    context: str,
    message: str,
    style: Callable[[str], str],
    error_message: str = "",
) -> None:
    """
    Flip the resumed run to a terminal ``state``; finish on the CAS win.

    Shared tail of the three ``--resume`` terminal outcomes
    (BUDGET_EXCEEDED, FAILED, SUCCEEDED). ``flip_or_emit`` already pairs
    the compare-and-set with the lost-race diagnostic; this adds the
    post-win work copied per outcome: the run-terminal metric and the
    operator summary line. ``expected=RUNNING`` mirrors the apply_plan
    terminal flips so a racing ``--abandon`` / signal flip to INTERRUPTED
    is not silently overwritten (the matrix still permits RUNNING → the
    terminal state). The metric and line are gated on the CAS win: on a
    lost race the racing writer emits its own terminal metric, so
    emitting here too would double-count.
    """
    if not flip_or_emit(
        cmd,
        run,
        new_state=state,
        expected=AuditRunState.RUNNING,
        context=context,
        intended=state.value,
        error_message=error_message,
    ):
        return
    observe_run_terminal(run, state)
    cmd.stdout.write(style(f"AuditRun id={run_id} resumed; {message}"))


def _resume_per_finding(
    plan: AuditPlan,
    db_finding: AuditFinding,
    client: AuditedDiscordClient,
    run: AuditRun,
    protected_uids: frozenset[int],
    users_by_id: Mapping[int, AbstractUser],
) -> 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). ``users_by_id`` carries the resume-time preload
    # (``preload_users_for_findings`` against the PENDING-finding
    # queryset) so per-group / per-state policy routing fires on
    # resume the same way it does on apply.
    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, users_by_id
        )
    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)


# 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, INTERRUPTED))``
    # compare-and-set below: the first resume flips
    # PENDING|INTERRUPTED → 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 (
        fetch_bot_uids,
        fetch_users_with_discord_access,
        policy_from_snapshot,
    )
    from ....role_filter import (
        fetch_aa_role_names,
        fetch_exempt_role_ids,
        fetch_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.
    #
    # The Discord ``guild_roles`` call runs BEFORE the
    # PENDING/INTERRUPTED → RUNNING CAS below — a losing race pays
    # one Discord round-trip and an AuditInvocation row before the
    # refusal lands. The asymmetry is intentional: if guild_roles
    # raises (transient outage), the run stays in PENDING /
    # INTERRUPTED so the operator can re-run ``--resume`` once
    # Discord recovers. Flipping to RUNNING first would strand the
    # run in RUNNING on transient outage, requiring an explicit
    # ``--abandon`` to recover. The cost of a wasted round-trip on
    # a losing race is acceptable; the cost of unrecoverable
    # RUNNING state is not.
    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}),
        role_by_id=MappingProxyType({r.id: r for r in role_catalog}),
        aa_role_names=fetch_aa_role_names(),
        reserved_role_names=fetch_reserved_role_names(),
        bot_uids=fetch_bot_uids(),
        exempt_role_ids=fetch_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=fetch_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),
    )

    # Engine-side coercions: resume inherits the ORIGINAL run's
    # ``triggered_by`` + ``was_confirmation_bypassed`` (the same ``run``
    # row). The beat-destructive gate runs first so resuming an
    # unattended beat run cannot strip/kick unless the operator opted in
    # via AA_DISCORD_AUDIT_BEAT_ALLOW_DESTRUCTIVE; then the confirmation
    # gate keeps an unconfirmed resume fail-safe. The first-run lock is
    # enforced upstream via the ``first_run_lock_was_active`` refusal, so
    # a later ack-row deletion cannot retroactively coerce an
    # already-confirmed queued run.
    plan = coerce_beat_destructive(plan, run)
    plan = coerce_unconfirmed_destructive(plan, run)

    # Materialise the PENDING-finding queryset once, BEFORE the RUNNING
    # compare-and-set below: the loop iterates it AND
    # ``preload_users_for_findings`` scans ``aa_user_id`` upfront, so a
    # lazy QuerySet iterated twice could issue two SELECTs whose row sets
    # diverge (a parallel writer flipping a finding to SUCCEEDED between
    # the two reads would leave a stale ``users_by_id`` entry and a
    # mismatched iteration). 50k findings x ~400 B/row is roughly 20 MB —
    # acceptable for the management-command path.
    #
    # The ``_PENDING_FINDINGS_HARD_CAP`` ceiling surfaces OOM-shaped
    # outliers (500k+ findings across a federation guild) as an
    # actionable CommandError instead of silent worker death. The check
    # runs HERE, before the CAS flips the run to RUNNING, so an over-cap
    # refusal leaves the run in its resumable PENDING / INTERRUPTED state
    # rather than stranding it in RUNNING (which only ``--abandon`` could
    # then clear).
    pending_findings = list(
        AuditFinding.objects.filter(
            run=run,
            state=AuditFindingState.PENDING.value,
        )
    )
    if len(pending_findings) > _PENDING_FINDINGS_HARD_CAP:
        raise CommandError(
            f"AuditRun id={run_id} has "
            f"{len(pending_findings)} PENDING findings, above the "
            f"--resume in-memory ceiling of "
            f"{_PENDING_FINDINGS_HARD_CAP}; split the run via "
            f"--user-id or shrink the guild scope first."
        )

    # 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)
    )
    # Preload AA users + groups + profile.state ONCE so resume-time
    # ``plan.action_for(..., aa_user=...)`` resolves per-group /
    # per-state routing the same way the fresh apply pass does.
    # Without this the resume fell through to ``_EMPTY_USERS_BY_ID``
    # and silently coerced every linked-user finding to the policy
    # ``default`` branch — a destructive-action regression vs. apply.
    users_by_id = preload_users_for_findings(
        f.aa_user_id for f in pending_findings
    )
    # 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:
        if timezone.now() >= plan.deadline_at:
            _finalize_resume(
                cmd,
                run,
                run_id,
                state=AuditRunState.BUDGET_EXCEEDED,
                context="resume hit deadline but the BUDGET_EXCEEDED flip",
                message="deadline hit, final state BUDGET_EXCEEDED.",
                style=cmd.style.WARNING,
            )
            return
        outcome = _resume_per_finding(
            plan, db_finding, client, run, protected_uids, users_by_id
        )
        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:
            # Break immediately — mirrors ``_walk_findings``. The
            # post-loop ``if auth_failed:`` block flips the run to
            # FAILED, taking priority over BUDGET_EXCEEDED: a 401 on
            # iter N short-circuits before iter N+1's deadline check,
            # so a token-revocation finalizes the run as FAILED
            # (actionable: bot logged out) rather than BUDGET_EXCEEDED
            # (misleading: looks like a slow-run problem).
            auth_failed = True
            break
    if auth_failed:
        _finalize_resume(
            cmd,
            run,
            run_id,
            state=AuditRunState.FAILED,
            context="resume saw auth failure but the FAILED flip",
            message="auth failed, final state FAILED.",
            style=cmd.style.WARNING,
            error_message=_AUTH_FAILED_ERROR_MESSAGE,
        )
        return
    _finalize_resume(
        cmd,
        run,
        run_id,
        state=AuditRunState.SUCCEEDED,
        context="resume terminal flip",
        message="final state SUCCEEDED.",
        style=cmd.style.SUCCESS,
    )
