"""
Persistence helpers for audit runs and findings.

The append-only ``AppendOnlyManager`` on ``AuditRun`` /
``AuditFinding`` blocks ``update()`` and ``bulk_update()`` at the
manager and QuerySet layers; per-finding state changes go through
``instance.save(update_fields=[...])`` exclusively.

These helpers wrap the common write patterns:

- ``bulk_create_findings``: insert PENDING findings in batches.
- ``flip_run_state``: unconditional ``AuditRun`` state flip — used by
  test fixtures and ``--abandon``'s force-quit hatch only.
- ``transition_run_state``: compare-and-set FSM transition with
  matrix-validation; used by every production state change that
  could race with a concurrent worker.
- ``persist_first_run_coercion``: writes the in-place coerced
  policy + ``dry_run=True`` + ``first_run_lock_was_active=True``
  + ``summary['first_run_lock']=True``.
- ``persist_finding_applied`` / ``_skipped`` / ``_failed``: per-finding
  state writes with the correct allow-listed fields.
  ``persist_finding_skipped`` and ``persist_finding_failed`` accept
  an optional ``role_ids_removed`` collection so a finding that
  successfully stripped some roles before the apply path raised
  (partial Discord-side mutation) records the partial outcome.
- ``expire_idempotency_keys``: NULL-ify the ``idempotency_key`` slot
  on runs aged past a TTL while preserving the run row itself for the
  longer retention window.
"""

from __future__ import annotations

from collections.abc import Callable, Collection, Mapping
from typing import TYPE_CHECKING, Final, TypeAlias

from django.db import transaction
from django.db.models import Count, QuerySet
from django.utils import timezone

from ._log import get_logger
from ._metrics import observe_keys_expired
from .models import (
    AuditFinding,
    AuditFindingState,
    AuditRun,
    AuditRunState,
    FindingSkipReason,
    OrphanAction,
)

logger = get_logger(__name__)

if TYPE_CHECKING:  # pragma: no cover
    from datetime import datetime

    from .audit import OrphanFinding

# Resolver from finding to the action that should be persisted on the
# finding row. Lives outside persistence: plan/CLI/policy decide the
# mapping; this module just applies the resolver to each finding.
# The resolver receives the full ``OrphanFinding`` so callers can
# route by category, by aa_user_id, or both — the per-group / per-
# state policy in ``CategoryPolicy`` needs the linked AA user.
ActionResolver: TypeAlias = Callable[["OrphanFinding"], OrphanAction]

# Finding insert batches sized to bound the per-COMMIT cost on
# realistic guild scales (50k findings -> 100 commits, not 50k).
_BULK_CREATE_BATCH_SIZE: Final[int] = 500


# ---------------------------------------------------------------------
# AuditRun.summary keys.
#
# Centralised here so the read-side helper ``get_summary_marker`` and
# the write-side helpers (``stamp_summary_marker`` /
# ``persist_first_run_coercion``) cannot drift in their spelling. The
# notification fan-out imports the marker keys from this module to keep
# producer and consumer in sync.
# ---------------------------------------------------------------------
SUMMARY_KEY_NOTIFIED_ADMINS: Final[str] = "admins_notified_at"
SUMMARY_KEY_WEBHOOK_POSTED: Final[str] = "webhook_posted_at"
SUMMARY_KEY_FIRST_RUN_LOCK: Final[str] = "first_run_lock"
# Confirmation-gate coercion key: distinct from
# ``SUMMARY_KEY_FIRST_RUN_LOCK`` so the trail tells apart "coerced:
# run was not confirmed/authorized" from "coerced: first-run lock".
# The two coercions are independent — a first-run-locked run is
# already coerced before this gate runs, and this gate must NOT set
# ``first_run_lock_was_active`` (that flag is owned by
# ``persist_first_run_coercion`` alone).
SUMMARY_KEY_CONFIRMATION_COERCED: Final[str] = "confirmation_coerced"
# Beat-destructive coercion key: a beat-triggered run carries a
# destructive policy but the operator did not opt the unattended beat
# into destructive actions. Distinct from the confirmation marker so the
# trail tells apart "coerced: beat not opted into destructive" from
# "coerced: run not confirmed". Releasing the first-run lock for one
# manual CLI run otherwise arms the unattended beat permanently, so this
# gate stays independent of the one-shot lock.
SUMMARY_KEY_BEAT_COERCED: Final[str] = "beat_destructive_coerced"

# Active (non-terminal, non-resting) states a crashed worker can strand.
# ``reconcile_stuck_runs`` flips these to INTERRUPTED once a row has
# outlived the hard time limit. ``INTERRUPTED`` is intentionally NOT
# here — it is the resting state reconcile flips *to*, and re-flipping it
# would be an illegal INTERRUPTED -> INTERRUPTED transition.
_RECONCILABLE_RUN_STATES: Final[frozenset[AuditRunState]] = frozenset(
    {AuditRunState.PENDING, AuditRunState.RUNNING}
)

# Chunk size for the orphan-run iterator. The stranded set is expected
# to be tiny (only runs older than the hard time limit), but bounding
# the fetch keeps the sweep's memory footprint flat under a pathological
# backlog — mirrors the bounded ``retry_pending_kicks`` sweep.
_RECONCILE_ITERATOR_CHUNK_SIZE: Final[int] = 200

# Operator-facing note stamped on a run the reconcile sweep rescues. The
# row was left active by a worker that died (hard kill / crash) without
# running its own rollback; INTERRUPTED keeps it resumable via --resume
# or formally closable via --abandon.
_RECONCILE_ERROR_MESSAGE: Final[str] = (
    "reconciled: run outlived the hard time limit while still active "
    "(worker crash or hard kill); flipped to INTERRUPTED so it is "
    "resumable via --resume or closable via --abandon"
)

# Explicit FSM transition matrix for ``AuditRun.state``. Only legal
# transitions appear here; ``transition_run_state`` validates the
# requested ``(expected, new_state)`` pair against the matrix BEFORE
# the CAS-update so an illegal transition raises at the call site
# instead of silently no-op'ing through ``rowcount=0``. The
# unconditional ``flip_run_state`` (test fixtures, ``--abandon``'s
# force-quit hatch) bypasses this matrix by design.
#
# Terminal states (``SUCCEEDED``, ``FAILED``, ``BUDGET_EXCEEDED``,
# ``ABANDONED``) have no outgoing transitions: a SUCCEEDED run is
# done; reopening it for further work would require a fresh row.
# ``INTERRUPTED`` is the only non-terminal "rest" state — it can be
# resumed (``-> RUNNING``) or formally abandoned (``-> ABANDONED``).
_VALID_RUN_TRANSITIONS: Final[
    Mapping[AuditRunState, frozenset[AuditRunState]]
] = {
    AuditRunState.PENDING: frozenset(
        {
            AuditRunState.RUNNING,
            AuditRunState.ABANDONED,
            AuditRunState.INTERRUPTED,
        }
    ),
    AuditRunState.RUNNING: frozenset(
        {
            AuditRunState.SUCCEEDED,
            AuditRunState.FAILED,
            AuditRunState.BUDGET_EXCEEDED,
            AuditRunState.ABANDONED,
            AuditRunState.INTERRUPTED,
        }
    ),
    AuditRunState.INTERRUPTED: frozenset(
        {AuditRunState.RUNNING, AuditRunState.ABANDONED}
    ),
    AuditRunState.SUCCEEDED: frozenset(),
    AuditRunState.FAILED: frozenset(),
    AuditRunState.BUDGET_EXCEEDED: frozenset(),
    AuditRunState.ABANDONED: frozenset(),
}


def _set_attrs(obj: object, fields: Mapping[str, object]) -> None:
    """Mirror ``name -> value`` mapping onto an instance via ``setattr``."""
    # Used by ``flip_run_state`` + ``transition_run_state`` and
    # ``_transition_finding`` to keep the in-memory instance in sync
    # with the SQL write that just succeeded. Centralises the loop so
    # a future change (logging, accept-list) lands in one place.
    for name, value in fields.items():
        setattr(obj, name, value)


def bulk_create_findings(
    run: AuditRun,
    findings: Collection[OrphanFinding],
    *,
    action_resolver: ActionResolver,
) -> int:
    """
    Insert PENDING ``AuditFinding`` rows for each ``OrphanFinding``.

    ``action_resolver`` is a callable taking
    ``(OrphanFinding) -> OrphanAction`` (see the ``ActionResolver``
    TypeAlias above) so the resolver can route by category,
    ``aa_user_id``, or both — the per-group / per-state policy in
    ``CategoryPolicy`` needs the linked AA user. The resolver lives
    outside persistence: the plan / CLI override / policy decide the
    mapping; this module just applies the resolver to each finding.
    """
    rows = [
        AuditFinding(
            run=run,
            member_id=f.member_id,
            member_username=f.member_username,
            category=f.category.value,
            aa_user_id=f.aa_user_id,
            state=AuditFindingState.PENDING.value,
            action=action_resolver(f).value,
        )
        for f in findings
    ]
    AuditFinding.objects.bulk_create(rows, batch_size=_BULK_CREATE_BATCH_SIZE)
    return len(rows)


def _run_state_extras(
    state: AuditRunState,
    *,
    error_message: str,
    summary: dict[str, object] | None,
) -> dict[str, object]:
    """
    Compute the side-field updates that accompany a state flip.

    ``finished_at`` writes only on terminal states; ``error_message``
    writes regardless of terminality. The asymmetry is intentional: a
    non-terminal flip carrying an ``error_message`` is the signal-flush
    path (``audit_discord_roles.py``: ``RUNNING -> INTERRUPTED`` with
    ``"interrupted by signal SIGTERM"``), where the run can still be
    resumed and ``finished_at`` would lie about completion. A future
    ``FAILED -> RUNNING`` reset path (out of scope for v1) would need
    to choose its own clearing semantics for both fields.
    """
    extras: dict[str, object] = {}
    if state.is_finished:
        extras["finished_at"] = timezone.now()
    if error_message:
        extras["error_message"] = error_message
    if summary is not None:
        extras["summary"] = summary
    return extras


def flip_run_state(
    run: AuditRun,
    state: AuditRunState,
    *,
    error_message: str = "",
    summary: dict[str, object] | None = None,
) -> None:
    """
    Unconditionally flip ``run`` to ``state`` with the side fields set.

    Use this only when no concurrent writer could be advancing the
    FSM — currently the test fixtures' setup path and the
    administrator's force-quit hatch. Every production transition
    that races with another worker must use ``transition_run_state``
    so a lost CAS surfaces as a ``False`` return rather than a
    silent overwrite.

    The terminal-state ``finished_at`` stamp, ``error_message``, and
    ``summary`` are written in the same ``save(update_fields=...)``
    call as ``state``.
    """
    extra_fields = _run_state_extras(
        state, error_message=error_message, summary=summary
    )
    run.state = state.value
    _set_attrs(run, extra_fields)
    run.save(update_fields=["state", *extra_fields.keys()])


def transition_run_state(
    run: AuditRun,
    state: AuditRunState,
    *,
    expected: AuditRunState | tuple[AuditRunState, ...],
    error_message: str = "",
    summary: dict[str, object] | None = None,
) -> bool:
    """
    Compare-and-set transition gated on the row's current state.

    Validates ``(predecessor -> state)`` against
    ``_VALID_RUN_TRANSITIONS`` and raises ``ValueError`` for an illegal
    pair (e.g. ``SUCCEEDED -> RUNNING``). The matrix check fails fast
    at the call site, distinguishing operator-typo errors from the
    legitimate CAS-rowcount-zero outcome (concurrent writer already
    advanced the FSM).

    ``expected`` accepts either a single ``AuditRunState`` or a tuple
    thereof. Tuple form is used by ``--resume``, which accepts both
    ``PENDING`` (run never started) and ``INTERRUPTED`` (signal cut
    a prior run short) as resumable predecessors of ``RUNNING``.

    Returns ``True`` if the row was transitioned, ``False`` if the
    CAS predicate matched zero rows. The in-memory ``run`` is NOT
    mutated on ``False`` so the caller sees the same view as the DB.

    .. note:: **summary semantics — overwrite, not merge.**

       ``summary=`` writes the dict verbatim, replacing any keys
       previously in ``run.summary``. Callers that want to *add* a key
       (e.g. the notification fan-out's idempotency markers) MUST use
       ``stamp_summary_marker``, which serialises read-modify-write
       under a row lock. The two writers stay race-safe because the
       only ``summary=`` caller in production (``_finalize_apply``)
       runs AFTER the notification fan-out, but adding a new caller
       that races with a stamper risks clobbering the marker.
    """
    expected_tuple: tuple[AuditRunState, ...] = (
        (expected,) if isinstance(expected, AuditRunState) else expected
    )
    for predecessor in expected_tuple:
        if state not in _VALID_RUN_TRANSITIONS.get(predecessor, frozenset()):
            raise ValueError(
                f"illegal AuditRun transition "
                f"{predecessor.value} -> {state.value}; "
                f"see _VALID_RUN_TRANSITIONS"
            )
    extra_fields = _run_state_extras(
        state, error_message=error_message, summary=summary
    )
    rowcount = AuditRun.objects.filter(pk=run.pk).transition_state(
        expected_state=tuple(s.value for s in expected_tuple),
        new_state=state.value,
        **extra_fields,
    )
    if rowcount == 0:
        return False
    run.state = state.value
    _set_attrs(run, extra_fields)
    return True


def force_transition_to_failed(run: AuditRun, *, error_message: str) -> bool:
    """
    Drive ``run`` to FAILED through RUNNING if it is still PENDING.

    ``_VALID_RUN_TRANSITIONS`` forbids PENDING -> FAILED directly: a
    PENDING run has not started the destructive phase, so terminal-
    failing it must pass through RUNNING first. Callers in exception
    handlers (``tasks.process_pending_run``, future web-launch error
    paths) only know "the run died" and do not always know which side
    of the build/apply split raised — this helper encodes the legal
    PENDING -> RUNNING -> FAILED escape so the call site stays focused
    on logging and rollback bookkeeping.

    Refreshes ``run`` from the DB before the first hop so a concurrent
    writer (``--abandon``, reconcile sweep) that already advanced the
    FSM is observed and the legal predecessor is honoured. A lost CAS
    on either hop is acceptable: the row ends up in a terminal state
    regardless, and the returned ``flipped`` flag tells the caller
    which branch fired (CAS-won FAILED vs CAS-lost-to-another-terminal).
    """
    run.refresh_from_db()
    if run.state == AuditRunState.PENDING:
        transition_run_state(
            run,
            AuditRunState.RUNNING,
            expected=AuditRunState.PENDING,
        )
        run.refresh_from_db()
    return transition_run_state(
        run,
        AuditRunState.FAILED,
        expected=AuditRunState.RUNNING,
        error_message=error_message,
    )


def reconcile_stuck_runs(stale_before: datetime) -> int:
    """
    Flip orphaned active runs older than ``stale_before`` to INTERRUPTED.

    A worker that dies by hard kill or crash between ``build_plan`` and
    the terminal flip leaves its ``AuditRun`` row stranded in PENDING or
    RUNNING with no in-process handler to roll it back — it would sit
    that way until retention pruning deletes it. This sweep is the
    eventual-recovery path: a healthy run cannot outlive the hard time
    limit (Celery kills it at that ceiling), so any PENDING/RUNNING row
    whose ``started_at`` predates ``stale_before`` is definitively
    orphaned and is flipped to the resumable INTERRUPTED state.

    Each flip goes through the compare-and-set ``transition_run_state``
    (not a bulk ``update``) so a row that legitimately reaches a terminal
    state between the SELECT and the CAS is left to the winning writer —
    the rowcount-zero outcome is silently skipped, never clobbered.

    Returns the count of rows actually flipped this pass.
    """
    stuck = (
        AuditRun.objects.filter(
            state__in=[s.value for s in _RECONCILABLE_RUN_STATES],
            started_at__lt=stale_before,
        )
        .order_by("started_at")
        .iterator(chunk_size=_RECONCILE_ITERATOR_CHUNK_SIZE)
    )
    reconciled = 0
    for run in stuck:
        # Capture the pre-flip state for the log: a successful
        # ``transition_run_state`` mutates ``run.state`` in place to
        # INTERRUPTED, so reading it afterwards would lose the origin.
        previous_state = run.state
        if transition_run_state(
            run,
            AuditRunState.INTERRUPTED,
            expected=(AuditRunState.PENDING, AuditRunState.RUNNING),
            error_message=_RECONCILE_ERROR_MESSAGE,
        ):
            logger.warning(
                "Reconciled orphaned AuditRun id=%s (was %s, started %s) "
                "to INTERRUPTED.",
                run.pk,
                previous_state,
                run.started_at.isoformat(),
            )
            reconciled += 1
    return reconciled


def diagnose_lost_cas_message(
    run: AuditRun,
    *,
    context: str,
    intended: str,
    source: str = "concurrent --abandon or signal-flip",
) -> str:
    """
    Build the canonical 'lost the CAS race' diagnostic line.

    Refreshes ``run.state`` from the DB so the caller-visible message
    reports the WINNING writer's state, not the in-memory stale copy.
    The fixed shape — ``id=… <context> lost the CAS race; intended=X
    but DB state=Y (<source>)`` — lets forensic grep across logger and
    CLI-stdout streams match a single substring (``lost the CAS race``)
    to discover every race-loss event regardless of which terminal
    flip site emitted it.

    ``context`` describes which flip lost the race (e.g.
    ``"terminal flip"``, ``"resume hit deadline but the
    BUDGET_EXCEEDED flip"``, ``"--abandon"``). ``intended`` is the
    state the caller tried to write. ``source`` defaults to the common
    abandon/signal-flip case; callers with a different concurrent
    writer (e.g. an upfront state-check race) pass their own.

    Note: stdout mirroring is the caller's responsibility — CLI sites
    typically dual-channel via ``cmd.stdout.write(...) + logger.warning``
    so the live operator and the forensic aggregator both see the line,
    while in-process callers (e.g. ``apply_plan``) emit only to the
    logger. The helper does not decide which channels to use.
    """
    run.refresh_from_db(fields=("state",))
    return (
        f"AuditRun id={run.pk} {context} lost the CAS race; "
        f"intended={intended} but DB state={run.state} ({source})."
    )


def get_summary_marker(run: AuditRun, key: str) -> object | None:
    """
    Return ``run.summary[key]`` if present, else ``None``.

    Read-side counterpart to ``stamp_summary_marker``. Centralised so a
    caller cannot accidentally treat a missing ``summary`` JSONField as
    ``KeyError`` (it can legitimately be ``None`` for runs that never
    received a stamp). Callers comparing with ``is not None`` get the
    stamp value (typically an ISO-8601 string), enabling ``logger.info``
    to surface the timestamp without re-reading the summary dict.
    """
    return (run.summary or {}).get(key)


def stamp_summary_marker(run: AuditRun, key: str) -> bool:
    """
    Merge ``{key: now().isoformat()}`` into ``run.summary``.

    Returns ``True`` if this call was the first stamper for ``key`` and
    actually wrote it; ``False`` if a concurrent caller already set
    ``key`` between this caller's pre-check and the lock acquisition.

    Used by ``notify_admins`` and ``post_webhook`` to record their
    idempotency markers (``admins_notified_at`` and
    ``webhook_posted_at`` respectively). Wraps the standard "merge
    onto the existing dict, save the summary field" pattern so a
    third marker site cannot drift in the key shape or the save
    fields.

    Concurrency: the merge runs inside ``transaction.atomic()`` with a
    ``select_for_update`` row lock so two parallel stampers (e.g.
    Celery beat retry + CLI re-run reaching ``fan_out_for_run``
    simultaneously) serialise. Without the lock, the read-modify-write
    on the JSONField loses one of the two markers via last-write-wins,
    defeating the idempotency the helper exists to provide.

    The ``bool`` return lets a caller doing check-then-action-then-stamp
    (e.g. ``post_webhook`` after a successful HTTP 2xx) detect that
    another worker won the race and skip user-visible side effects on
    the second call. Callers that only care about ``run.summary``
    being eventually consistent may ignore the return.
    """
    with transaction.atomic():
        locked = AuditRun.objects.select_for_update().get(pk=run.pk)
        existing = locked.summary or {}
        won_race = key not in existing
        if won_race:
            locked.summary = {
                **existing,
                key: timezone.now().isoformat(),
            }
            locked.save(update_fields=["summary"])
    # Mirror onto the caller's instance so a subsequent in-process
    # idempotency check (e.g. the same ``fan_out_for_run`` reading
    # ``run.summary`` after this returns) sees the stamp without a
    # second DB round-trip. Routed through ``_set_attrs`` for symmetry
    # with the other flip/transition sites — every in-memory mirror
    # write in this module goes through the helper so a future
    # extension (additional field to mirror, audit hook) lands in one
    # place.
    _set_attrs(run, {"summary": locked.summary})
    return won_race


def _persist_policy_coercion(
    run: AuditRun,
    coerced_policy: dict[str, object],
    *,
    marker: str,
    extra_fields: dict[str, object] | None = None,
) -> None:
    """
    Write a policy coercion through the allow-listed ``AuditRun`` fields.

    Shared body of the three ``persist_*_coercion`` writers: set
    ``policy_snapshot``, merge exactly one ``marker`` flag into
    ``summary``, and persist only the allow-listed fields.
    ``extra_fields`` carries the coercion-specific flags (the first-run
    lock additionally sets ``dry_run`` and ``first_run_lock_was_active``);
    every key it supplies MUST be in the ``AuditRun.save()`` allow-list.
    The DISTINCT ``marker`` per caller is what keeps the forensic trail
    able to tell the three coercion reasons apart.
    """
    fields: dict[str, object] = {
        **(extra_fields or {}),
        "policy_snapshot": coerced_policy,
        "summary": {**(run.summary or {}), marker: True},
    }
    _set_attrs(run, fields)
    run.save(update_fields=list(fields))


def persist_first_run_coercion(
    run: AuditRun, coerced_policy: dict[str, object]
) -> None:
    """
    Write the first-run-lock coercion via the allow-listed fields.

    All four fields touched here (``dry_run``,
    ``first_run_lock_was_active``, ``policy_snapshot``,
    ``summary``) are in the ``AuditRun.save()`` allow-list.
    """
    _persist_policy_coercion(
        run,
        coerced_policy,
        marker=SUMMARY_KEY_FIRST_RUN_LOCK,
        extra_fields={"dry_run": True, "first_run_lock_was_active": True},
    )


def persist_confirmation_coercion(
    run: AuditRun, coerced_policy: dict[str, object]
) -> None:
    """
    Write the confirmation-gate coercion via the allow-listed fields.

    Mirrors ``persist_first_run_coercion`` but with a DISTINCT marker:
    only ``policy_snapshot`` + ``summary`` are touched, and the summary
    carries ``SUMMARY_KEY_CONFIRMATION_COERCED`` — NOT
    ``first_run_lock_was_active`` (that flag belongs to the first-run
    lock). The confirmation gate also leaves ``dry_run`` untouched: an
    unconfirmed run is coerced to REPORT, but it is not a dry run
    (REPORT findings are still persisted and
    ``last_seen_in_guild_at`` is still refreshed).

    Both ``policy_snapshot`` and ``summary`` are in the
    ``AuditRun.save()`` allow-list.
    """
    _persist_policy_coercion(
        run, coerced_policy, marker=SUMMARY_KEY_CONFIRMATION_COERCED
    )


def persist_beat_coercion(
    run: AuditRun, coerced_policy: dict[str, object]
) -> None:
    """
    Write the beat-destructive coercion via the allow-listed fields.

    Mirrors ``persist_confirmation_coercion`` (only ``policy_snapshot`` +
    ``summary`` touched, ``dry_run`` left alone) but stamps the DISTINCT
    ``SUMMARY_KEY_BEAT_COERCED`` marker so the trail records that the
    unattended beat was downgraded to REPORT for lack of the
    destructive-beat opt-in — not confused with the confirmation gate.
    Both fields are in the ``AuditRun.save()`` allow-list.
    """
    _persist_policy_coercion(
        run, coerced_policy, marker=SUMMARY_KEY_BEAT_COERCED
    )


def log_pending_purges(queryset: QuerySet[AuditRun]) -> None:
    """Emit one INFO line per PENDING run before retention cascade."""
    # Used by the Celery ``prune_audit_runs`` task and the management
    # command symmetrically. Annotates the finding count once via
    # ``Count('findings')`` so the preamble executes a single SELECT
    # instead of N COUNT(*) subqueries (the original N+1 path on
    # installs with many stuck PENDING runs).
    pending_runs = queryset.filter(state=AuditRunState.PENDING.value).annotate(
        _finding_count=Count("findings")
    )
    for run in pending_runs:
        # ``getattr`` rather than direct attribute access: the annotated
        # alias is dynamic and not on the model class, so neither the
        # type checker nor the ``SLF001`` private-access lint sees it.
        finding_count = getattr(run, "_finding_count")  # noqa: B009
        logger.info(
            "auto_purged pending audit_run id=%s findings=%s started_at=%s",
            run.pk,
            finding_count,
            run.started_at.isoformat(),
        )


def log_destructive_purges(queryset: QuerySet[AuditRun]) -> None:
    """
    Emit one WARNING line per destructive run before retention cascade.

    A run that applied strip / kick actions (``actions_applied > 0``) is
    the only persisted record of those mutations; the retention cascade
    deletes the run and its findings together. This breadcrumb preserves
    the who / how-many / when in the operator's log aggregator — which is
    typically retained far longer than the audit DB rows — before the row
    is gone. Surfaced at WARNING (vs. the INFO ``log_pending_purges``
    tombstone for never-applied PENDING runs) because purging applied
    actions erases forensic history, not just an inert placeholder.
    """
    for run in queryset.filter(actions_applied__gt=0):
        logger.warning(
            "auto_purged destructive audit_run id=%s state=%s "
            "actions_applied=%s members_scanned=%s started_at=%s",
            run.pk,
            run.state,
            run.actions_applied,
            run.members_scanned,
            run.started_at.isoformat(),
        )


def expire_idempotency_keys(cutoff: datetime) -> int:
    """
    NULL-ify ``idempotency_key`` on runs started before ``cutoff``.

    Used by the retention command to release dedup tokens on aged
    runs while the row itself is preserved for the longer
    ``RUN_RETENTION_DAYS`` window — letting an operator reuse the
    same key (e.g. a date-derived token on a daily Beat) after the
    TTL even when the original run is still on disk for forensic
    review. Returns the count of rows whose key was cleared.

    The ``idempotency_key__isnull=False`` narrowing avoids touching
    rows that never had a key in the first place, so the reported
    count matches the operator's intuition of "keys released".
    """
    # Local annotation is the implicit cast: ``AppendOnlyManager``
    # erodes to ``Any`` through ``Manager.from_queryset`` (see the
    # ``# type: ignore[misc,valid-type]`` in ``_append_only.py``), so
    # mypy cannot see the ``-> int`` on ``clear_idempotency_keys``
    # without help. Same pattern would apply to ``bump_counters`` /
    # ``transition_state`` if their return value crossed a typed
    # function boundary; today it does not.
    affected: int = AuditRun.objects.filter(
        started_at__lt=cutoff,
        idempotency_key__isnull=False,
    ).clear_idempotency_keys()
    observe_keys_expired(affected)
    return affected


def _transition_finding(
    finding: AuditFinding,
    *,
    new_state: AuditFindingState,
    **fields: object,
) -> bool:
    """CAS-transition a finding from PENDING + mirror fields locally."""
    # Idempotent under retry: the SQL UPDATE is gated by
    # ``state = PENDING`` so a second invocation on an already-terminal
    # row is a no-op (rowcount=0). On success the in-memory ``finding``
    # is updated to match the row write so callers reading the
    # instance see consistent values. ``**fields`` is forwarded
    # verbatim to ``transition_state``; the carve-out's allow-list
    # enforces that only ``_APPEND_ONLY_UPDATABLE_FIELDS`` keys reach
    # the SQL UPDATE.
    rowcount = AuditFinding.objects.filter(pk=finding.pk).transition_state(
        expected_state=AuditFindingState.PENDING.value,
        new_state=new_state.value,
        **fields,
    )
    if rowcount == 0:
        return False
    finding.state = new_state.value
    _set_attrs(finding, fields)
    return True


def persist_finding_applied(
    finding: AuditFinding,
    *,
    role_ids_removed: Collection[int],
    role_names_removed: Collection[str],
    was_kicked: bool = False,
) -> bool:
    """Mark a finding as APPLIED with the role-removal record (idempotent)."""
    return _transition_finding(
        finding,
        new_state=AuditFindingState.APPLIED,
        role_ids_removed=list(role_ids_removed),
        role_names_removed=list(role_names_removed),
        was_kicked=bool(was_kicked),
    )


def persist_finding_skipped(
    finding: AuditFinding,
    *,
    reason: FindingSkipReason,
    role_ids_removed: Collection[int] | None = None,
) -> bool:
    """
    Mark a finding as SKIPPED with the reason (idempotent).

    ``role_ids_removed`` records partial Discord-side mutations on
    the SKIPPED path — when phase 2 strips K of N roles before a 403
    /  HTTP error aborts the rest, the K succeeded ids land on the
    DB row so forensic queries see the same state as the actual
    Discord guild. Omit (or pass ``None``) when no Discord write
    happened (the normal policy-skip case); an empty collection is
    equivalent to ``None`` and is normalised out of the update so the
    write does not clobber a prior value under FSM retry.
    """
    extra: dict[str, object] = {"skipped_reason": reason.value}
    if role_ids_removed:
        extra["role_ids_removed"] = list(role_ids_removed)
    return _transition_finding(
        finding,
        new_state=AuditFindingState.SKIPPED,
        **extra,
    )


def persist_finding_failed(
    finding: AuditFinding,
    *,
    error_message: str,
    role_ids_removed: Collection[int] | None = None,
) -> bool:
    """
    Mark a finding as FAILED with the error message (idempotent).

    See :func:`persist_finding_skipped` for the ``role_ids_removed``
    contract; the same partial-strip persistence semantics apply to
    the FAILED path (auth / generic HTTP error after K succeeded
    DELETE calls).
    """
    extra: dict[str, object] = {"error_message": error_message}
    if role_ids_removed:
        extra["role_ids_removed"] = list(role_ids_removed)
    return _transition_finding(
        finding,
        new_state=AuditFindingState.FAILED,
        **extra,
    )


def increment_run_counters(
    run: AuditRun,
    *,
    members_scanned: int = 0,
    actions_applied: int = 0,
    actions_skipped: int = 0,
) -> None:
    """
    Bump the run's monotonic counters atomically.

    Uses ``AppendOnlyQuerySet.bump_counters`` so the SQL is
    ``UPDATE … SET col = col + N``; this is safe under concurrent
    writers (a future fan-out worker bumping the same run does
    not lose updates). The in-memory ``run`` instance is refreshed
    from the deltas so callers reading ``run.actions_applied``
    after this call see the bumped value (single-writer assumption
    is documented in apply_plan; multi-writer correctness comes
    from the F() expression on the DB side).
    """
    deltas: dict[str, int] = {
        name: value
        for name, value in (
            ("members_scanned", members_scanned),
            ("actions_applied", actions_applied),
            ("actions_skipped", actions_skipped),
        )
        if value
    }
    if not deltas:
        return
    AuditRun.objects.filter(pk=run.pk).bump_counters(**deltas)
    # Refresh the in-memory copy so callers reading the bumped
    # fields after this call see consistent values. Routed through
    # ``_set_attrs`` so every in-memory mirror write in this module
    # passes through the helper — a future extension (logging hook,
    # change-tracking) lands in one place. Under the
    # single-writer-per-run invariant documented in apply_plan, the
    # in-memory delta matches the DB delta exactly. Counter fields
    # have ``default=0`` and are non-nullable, so direct
    # ``getattr(run, field)`` never yields ``None``.
    #
    # IMPORTANT (transaction-rollback semantics): the in-memory mirror
    # is bumped UNCONDITIONALLY here, but the SQL UPDATE rolls back
    # with the surrounding transaction. If a caller invokes
    # ``increment_run_counters`` inside a ``transaction.atomic()``
    # block that subsequently raises, the in-memory mirror is
    # stale-HIGH compared to the rolled-back DB row. Downstream
    # consumers (``_finalize_apply``, notification fan-out) MUST
    # ``run.refresh_from_db(...)`` before reading these counters,
    # which is what ``_finalize_apply`` already does for exactly this
    # reason. New call sites reading the mirror after a potentially-
    # rolled-back path must do the same.
    _set_attrs(
        run,
        {
            field: getattr(run, field) + delta
            for field, delta in deltas.items()
        },
    )
