"""
Celery tasks for the Discord audit module.

Four tasks ship in MVP:

- ``audit_orphan_members``: the periodic / manual audit driver.
  Wraps ``build_plan`` + ``apply_plan`` and runs under
  ``QueueOnce(graceful=True)`` so two overlapping triggers
  dedupe. Its soft / hard limits come from
  ``_AUDIT_SOFT_TIME_LIMIT_S`` / ``_AUDIT_HARD_TIME_LIMIT_S`` so the
  audit's own deadline stays authoritative. A soft-limit hit is
  caught and flips the in-flight run to the resumable INTERRUPTED
  state; each tick first reconciles runs a previous crash stranded
  past the hard limit (``reconcile_stuck_runs``).
- ``process_pending_run``: drives a pre-created PENDING ``AuditRun``
  to a terminal state. Task-layer idempotency via row lock + state
  check; non-retrying so an apply failure is surfaced as FAILED
  rather than re-attempted with destructive Discord side-effects.
- ``retry_pending_kicks``: bounded beat sweep that re-enqueues
  ``PendingKick`` rows whose previous ``delete_user.delay`` handoff
  failed. Per-row CAS reservation + cooldown filter prevent two
  concurrent workers from racing on the same row; a missed tick is
  harmless (next tick reprocesses the same backlog).
- ``prune_audit_runs``: retention pruning. Deletes ``AuditRun``
  rows older than ``AA_DISCORD_AUDIT_RUN_RETENTION_DAYS`` and
  cascades. Emits an INFO log line per purged PENDING run for
  post-mortem traceability. Note: this Celery task does NOT
  NULL-ify ``AuditRun.idempotency_key`` on aged rows — the
  identically-named management command does that during its Phase 1.
"""

from __future__ import annotations

from datetime import timedelta
from typing import Final

from celery import shared_task
from celery.exceptions import SoftTimeLimitExceeded
from django.utils import timezone

from ._log import get_logger
from ._metrics import observe_runs_pruned
from ._redaction import log_redacted_exception, redact_str, scrub_exception
from .app_settings import (
    AA_DISCORD_AUDIT_RUN_DEADLINE_MINUTES,
    AA_DISCORD_AUDIT_RUN_RETENTION_DAYS,
)
from .permissions import APP_LABEL
from .persistence import (
    log_destructive_purges,
    log_pending_purges,
    reconcile_stuck_runs,
)

# 0 is the indefinite-retention sentinel for
# ``AA_DISCORD_AUDIT_RUN_RETENTION_DAYS``; opt-out is gated at startup
# by ``AA_DISCORD_AUDIT_RETENTION_OPT_OUT_ACKNOWLEDGED``.
_RETENTION_DISABLED: Final[int] = 0

# Soft / hard time-limit deltas above the audit's own deadline. Soft
# sits 60 s above the in-loop ``deadline_at`` so the graceful
# BUDGET_EXCEEDED stop normally fires first; the soft limit only trips
# when a single finding's Discord I/O hangs past that grace. When it
# does, ``audit_orphan_members`` catches ``SoftTimeLimitExceeded`` and
# flips the in-flight run to the resumable INTERRUPTED state. The hard
# ``time_limit`` is the backstop that kills a wedged worker;
# ``_HARD_OVER_SOFT_MULTIPLIER`` keeps the soft -> hard window equal to the
# deadline -> soft window so the kill lands one grace-period after the soft
# signal (enough time for the catch-and-flip to run).
_SOFT_OVER_DEADLINE_S: Final[int] = 60
_HARD_OVER_SOFT_MULTIPLIER: Final[int] = 2

# Minutes -> seconds conversion. Named because the audit's deadline
# setting is operator-facing in minutes while Celery wants seconds.
_SECONDS_PER_MINUTE: Final[int] = 60

# ``audit_orphan_members`` soft / hard time limits, named so the same
# value feeds both the ``@shared_task`` decorator AND the reconcile
# cutoff inside the task body (a healthy run cannot outlive the hard
# limit, so anything older that is still active is a crashed orphan).
_AUDIT_SOFT_TIME_LIMIT_S: Final[int] = (
    AA_DISCORD_AUDIT_RUN_DEADLINE_MINUTES * _SECONDS_PER_MINUTE
    + _SOFT_OVER_DEADLINE_S
)
_AUDIT_HARD_TIME_LIMIT_S: Final[int] = (
    AA_DISCORD_AUDIT_RUN_DEADLINE_MINUTES * _SECONDS_PER_MINUTE
    + _HARD_OVER_SOFT_MULTIPLIER * _SOFT_OVER_DEADLINE_S
)

# Operator-facing note stamped on a run the task interrupts after a
# Celery soft-time-limit hit. INTERRUPTED keeps the row resumable.
_SOFT_LIMIT_INTERRUPT_MESSAGE: Final[str] = (
    "interrupted by Celery soft time limit; resumable via --resume"
)

# Sentinel ``audit_orphan_members`` return when the min-interval backstop
# skips the tick: no run was created, so there is no run pk to report.
_BEAT_SKIPPED_RUN_ID: Final[int] = 0

# QueueOnce lock TTL for ``audit_orphan_members``. QueueOnce's default
# lock TTL is 3600 s — shorter than the audit's own deadline window (the
# soft limit already sits above 3600 s with the default 60-minute
# deadline), so the default lock self-expires mid-run and lets a second
# concurrent tick start while the first is still applying. Pin the TTL
# one grace period above the hard kill so a healthy run that executes
# right up to the hard limit never loses its lock before it finishes.
_AUDIT_ONCE_TIMEOUT_S: Final[int] = (
    _AUDIT_HARD_TIME_LIMIT_S + _SOFT_OVER_DEADLINE_S
)

# ``retry_pending_kicks`` / ``prune_audit_runs`` are bounded sweeps:
# one beat tick re-attempts every uncompleted ``PendingKick`` past
# cooldown / deletes every old ``AuditRun`` past retention. A missed
# tick is harmless — the next tick reprocesses the same backlog. The
# 60 s soft -> hard gap mirrors ``audit_orphan_members`` (give user code
# a chance to roll the run to FAILED before the hard kill).
_BOUNDED_SWEEP_SOFT_S: Final[int] = 240
_BOUNDED_SWEEP_HARD_S: Final[int] = (
    _BOUNDED_SWEEP_SOFT_S + _SOFT_OVER_DEADLINE_S
)

# Default cooldown between failed ``PendingKick`` re-handoffs. Matches
# the prune-task cadence so a broker outage produces at most one retry
# per beat tick. Override via the task signature for tighter retry.
_DEFAULT_KICK_COOLDOWN_MIN: Final[int] = 5

# Chunk size for the ``PendingKick`` iterator. Tuned to keep the
# per-tick memory footprint bounded without forcing too many round
# trips on a backlog of a few thousand failed kicks.
_KICK_ITERATOR_CHUNK_SIZE: Final[int] = 200

# Aged runs deleted per ``prune_audit_runs`` batch. A single cascade
# DELETE over a year of findings / invocations can exceed the task's
# hard time limit and be killed mid-delete, emitting no metric and
# leaving no incremental-progress signal. Deleting in fixed-size
# batches that each auto-commit means a hard kill leaves the already
# deleted batches purged and the next tick resumes from the remainder.
_PRUNE_BATCH_SIZE: Final[int] = 100

logger = get_logger(__name__)

# Use AA's QueueOnce + graceful-mode duplicate suppression.
#
# ``_ONCE_KW`` is the plain graceful-only lock the bounded sweeps use:
# their per-row CAS reservation / idempotent deletes make a concurrent
# duplicate harmless, so the default per-arg lock key and default TTL
# are fine.
#
# ``_AUDIT_ONCE_KW`` is the stronger lock the audit task needs:
#   * ``keys=[]`` collapses the lock key so it ignores task args — an
#     ``action=None`` tick and an ``action="strip"`` tick would
#     otherwise lock on different keys and run side by side, defeating
#     the per-guild overlap guard (the per-run row guard protects one
#     run, not the overlap).
#   * ``timeout`` pins the lock TTL above the run's own deadline window
#     so the lock cannot self-expire mid-run (see
#     ``_AUDIT_ONCE_TIMEOUT_S``).
try:
    from allianceauth.services.tasks import QueueOnce

    _BASE = QueueOnce
    _ONCE_KW = {"once": {"graceful": True}}
    _AUDIT_ONCE_KW = {
        "once": {
            "graceful": True,
            "keys": [],
            "timeout": _AUDIT_ONCE_TIMEOUT_S,
        }
    }
except ImportError:  # pragma: no cover
    _BASE = None
    _ONCE_KW = {}
    _AUDIT_ONCE_KW = {}


@shared_task(
    name=f"{APP_LABEL}.audit_orphan_members",
    base=_BASE,
    # Audit work is not idempotent on redelivery (apply_plan strips
    # roles, kicks users). The hourly beat re-run gives the eventual-
    # completion guarantee we want without exposing a redelivery
    # double-action surface; explicit max_retries=0 + empty
    # autoretry_for documents that intent.
    max_retries=0,
    autoretry_for=(),
    # ``SoftTimeLimitExceeded`` is a ``BaseException``, so apply_plan's
    # per-finding ``except Exception`` does not catch it. The task body
    # catches it explicitly and flips the in-flight run to INTERRUPTED
    # (see ``audit_orphan_members``). The hard ``time_limit`` sits one
    # grace period above the soft limit so that catch-and-flip has time
    # to run before a wedged worker is killed outright.
    soft_time_limit=_AUDIT_SOFT_TIME_LIMIT_S,
    time_limit=_AUDIT_HARD_TIME_LIMIT_S,
    **_AUDIT_ONCE_KW,
)
def audit_orphan_members(
    action: str | None = None, notify: bool = True
) -> int:
    """
    Run an audit. Returns the resulting ``AuditRun.pk`` for tracing.

    Returns ``0`` when the min-interval backstop
    (``AA_DISCORD_AUDIT_BEAT_MIN_INTERVAL_MINUTES``) skips the tick — no
    run is created. A redelivered task within the current idempotency
    window returns the existing run's pk (the dedup target) without
    building a second run. Otherwise records a system-actor
    ``AuditInvocation`` (``triggered_by=BEAT``) so the unattended path
    appears in the audit-the-auditor trail like CLI runs do.

    ``notify`` controls the post-run fan-out to AA-notify and the
    Discord webhook (mirrors ``audit_discord_roles --no-notify``).
    Default ``True`` so beat-driven daily audits surface their
    summaries the same way CLI runs do; pass ``False`` to keep the
    run silent (e.g. canary audits).

    Before building a new plan, the tick reconciles any run a previous
    worker stranded by a hard kill / crash (older than the hard time
    limit, still PENDING/RUNNING) into the resumable INTERRUPTED state.
    A Celery soft-time-limit hit during this tick's own run is caught
    and flips that run to INTERRUPTED before re-raising, so the worker's
    forcible interruption never leaves the row stuck RUNNING.
    """
    from .app_settings import AA_DISCORD_AUDIT_BEAT_MIN_INTERVAL_MINUTES
    from .audit import IdempotencyConflictError, apply_plan, build_plan
    from .client import make_audited_client
    from .invocation import record_beat_run
    from .models import (
        AuditInvocation,
        AuditRun,
        AuditRunState,
        OrphanAction,
        RunTrigger,
    )
    from .persistence import transition_run_state

    # Self-heal first: a previous tick killed mid-run leaves no handler
    # to roll its row back. Anything still active past the hard limit is
    # a crashed orphan, never a healthy in-flight run.
    reconcile_stuck_runs(
        timezone.now() - timedelta(seconds=_AUDIT_HARD_TIME_LIMIT_S)
    )

    # Coarse global min-interval backstop: skip the tick if another beat
    # run already started within the configured floor. QueueOnce only
    # dedupes concurrent ticks; this bounds back-to-back ones on a
    # misconfigured fast schedule. Checked before any work so a skipped
    # tick is cheap.
    if AA_DISCORD_AUDIT_BEAT_MIN_INTERVAL_MINUTES > 0:
        floor = timezone.now() - timedelta(
            minutes=AA_DISCORD_AUDIT_BEAT_MIN_INTERVAL_MINUTES
        )
        if AuditInvocation.objects.filter(
            triggered_by=RunTrigger.BEAT,
            accepted=True,
            started_at__gte=floor,
        ).exists():
            logger.warning(
                "Beat audit skipped: a beat run started within the "
                "%s-minute minimum interval "
                "(AA_DISCORD_AUDIT_BEAT_MIN_INTERVAL_MINUTES).",
                AA_DISCORD_AUDIT_BEAT_MIN_INTERVAL_MINUTES,
            )
            return _BEAT_SKIPPED_RUN_ID

    client = make_audited_client()

    cli_override = OrphanAction(action) if action else None
    cli_argv = [f"--action={action}"] if action else []

    # Idempotency key so a redelivered beat task cannot create a
    # duplicate (and possibly destructive) run. Bucket wall-clock time
    # into hard-limit-sized windows: within one window a redelivery
    # dedupes to the in-flight / just-finished run (``build_plan`` raises
    # ``IdempotencyConflictError`` on the duplicate key); across windows
    # a fresh key lets a new run proceed. Tying the bucket to the run
    # duration means a sane beat interval (at least the run deadline) is
    # never throttled by this, while a redelivery storm within one run's
    # lifetime collapses to a single run.
    window = int(timezone.now().timestamp() // _AUDIT_HARD_TIME_LIMIT_S)
    idempotency_key = f"beat-{window}"

    try:
        plan = build_plan(
            client,
            triggered_by=RunTrigger.BEAT,
            triggered_by_user=None,
            cli_argv=cli_argv,
            was_confirmation_bypassed=True,
            cli_override=cli_override,
            idempotency_key=idempotency_key,
        )
    except IdempotencyConflictError as exc:
        # A redelivery within the current window: the existing run owns
        # the key. This is a dedup, not an error — log at INFO and hand
        # back the existing run as the answer rather than building a
        # second run. ``build_plan`` raises before creating any row, so
        # there is nothing to clean up here.
        logger.info(
            "Beat audit deduped to existing run %s (state=%s): a "
            "redelivered task hit the idempotency key for this window.",
            exc.run_id,
            exc.state,
        )
        return exc.run_id

    try:
        # Write the audit-the-auditor trail BEFORE apply: the beat run is
        # accepted/started even if the apply phase later fails or is
        # interrupted, and the row carries the system-actor attribution
        # the unattended path otherwise lacks.
        record_beat_run(run_id=plan.run_id, argv=cli_argv)
        result = apply_plan(plan, client)
    except SoftTimeLimitExceeded:
        # The worker is still alive here (the hard kill is one grace
        # period away), so flip this tick's run resumable before the
        # exception propagates. A lost CAS (run already terminal) is a
        # no-op by design.
        stuck_run = AuditRun.objects.get(pk=plan.run_id)
        transition_run_state(
            stuck_run,
            AuditRunState.INTERRUPTED,
            expected=(AuditRunState.PENDING, AuditRunState.RUNNING),
            error_message=_SOFT_LIMIT_INTERRUPT_MESSAGE,
        )
        raise

    # Surface the run's terminal state: ``apply_plan`` rolls the run to
    # FAILED / BUDGET_EXCEEDED / INTERRUPTED without raising, so without
    # this inspection a bad outcome would look like success to Celery and
    # the operator. FAILED is an error; the resumable states are warnings.
    if result.state == AuditRunState.FAILED:
        logger.error(
            "Beat audit run %s finished in state %s.",
            result.run_id,
            result.state,
        )
    elif result.state in (
        AuditRunState.BUDGET_EXCEEDED,
        AuditRunState.INTERRUPTED,
    ):
        # apply_plan returns BUDGET_EXCEEDED on a deadline hit; INTERRUPTED
        # is defensive — apply_plan does not return it today (a racing
        # --abandon/signal flip is observed via the DB row, not this
        # result), but listing it keeps the warning correct if that ever
        # changes.
        logger.warning(
            "Beat audit run %s finished in state %s.",
            result.run_id,
            result.state,
        )

    if notify:
        # Mirror the CLI fan-out so beat-driven daily audits produce
        # the same admin/webhook summary CLI runs do.
        from .notifications import fan_out_for_run

        fan_out_for_run(plan.run_id, source="beat audit")

    return plan.run_id


@shared_task(
    name=f"{APP_LABEL}.process_pending_run",
    base=_BASE,
    # Apply has Discord side-effects (role strip, kick). Redelivery
    # is risky, so non-retrying: an exception in build/apply flips
    # the run to FAILED and the operator re-launches manually.
    # Unlike the beat task, a soft-time-limit hit is NOT caught here
    # (no resumability story for web-launched runs yet); it propagates
    # as BaseException and Celery kills the worker, leaving the run
    # in whatever state the signal interrupted.
    max_retries=0,
    autoretry_for=(),
    soft_time_limit=_AUDIT_SOFT_TIME_LIMIT_S,
    time_limit=_AUDIT_HARD_TIME_LIMIT_S,
)
def process_pending_run(run_id: int) -> int:
    """
    Drive a pre-created PENDING ``AuditRun`` to a terminal state.

    The row is created synchronously by the caller (so the pk is
    known immediately and the DB unique-key on ``idempotency_key``
    enforces double-submit dedup at INSERT time); this task then
    reuses it via ``build_plan(existing_run=run)`` and ``apply_plan``.

    Task-layer idempotency: the row is locked with
    ``select_for_update`` and the state is verified PENDING before
    any work. A redelivered task that arrives after the row already
    transitioned exits cleanly. A missing row (operator delete,
    post-prune tombstone) logs and returns ``0``.

    Non-retrying: an exception in ``build_plan`` / ``apply_plan``
    flips the run to FAILED with the traceback captured, then
    re-raises so Celery surfaces the failure.
    """
    import traceback

    from django.db import transaction

    from .audit import apply_plan, build_plan
    from .client import make_audited_client
    from .models import AuditRun, AuditRunState, RunTrigger
    from .persistence import force_transition_to_failed

    with transaction.atomic():
        try:
            run = AuditRun.objects.select_for_update().get(pk=run_id)
        except AuditRun.DoesNotExist:
            logger.warning(
                "process_pending_run: run %s missing; nothing to do.",
                run_id,
            )
            return 0
        if run.state != AuditRunState.PENDING:
            logger.info(
                "process_pending_run: run %s state=%s; not PENDING, "
                "exiting (task-layer idempotency).",
                run_id,
                run.state,
            )
            return int(run.pk)

    client = make_audited_client()
    try:
        plan = build_plan(
            client,
            triggered_by=RunTrigger(run.triggered_by),
            triggered_by_user=run.triggered_by_user,
            was_confirmation_bypassed=run.was_confirmation_bypassed,
            existing_run=run,
        )
        apply_plan(plan, client)
    except Exception as exc:
        # ``scrub_exception`` walks ``__cause__`` / ``__context__`` and
        # masks the URL in place on every exception in the chain, so
        # the traceback built below is sourced from already-scrubbed
        # ``args`` / ``response.url`` values. ``redact_str`` over the
        # formatted string stays as defense-in-depth against inline
        # literals not held on the exception (e.g. a third-party
        # repr() that bakes the URL into its message).
        # ``scrub_exception`` is idempotent — ``log_redacted_exception``
        # below will call it again without effect.
        scrub_exception(exc)
        tb = redact_str(traceback.format_exc())
        flipped = force_transition_to_failed(run, error_message=tb)
        # ``log_redacted_exception`` scrubs the exception chain before
        # ``logger.error(exc_info=exc)``; plain ``logger.exception``
        # would emit the unscrubbed traceback to any downstream
        # aggregator (Sentry, syslog forwarder), even though tb above
        # is already redacted for the DB row.
        if flipped:
            log_redacted_exception(
                logger,
                "process_pending_run: run %s failed; flipped to FAILED.",
                run_id,
                exc=exc,
            )
        else:
            # CAS lost — a concurrent --abandon (or other terminal
            # flip) won the race. The run is terminal in some other
            # state; not an error condition, just a logging accuracy
            # concern.
            log_redacted_exception(
                logger,
                "process_pending_run: run %s failed; FAILED flip lost "
                "CAS (run is terminal in another state — see DB).",
                run_id,
                exc=exc,
            )
        raise

    return int(run.pk)


@shared_task(
    name=f"{APP_LABEL}.retry_pending_kicks",
    base=_BASE,
    max_retries=0,
    autoretry_for=(),
    # Retry sweep is bounded; one beat tick re-attempts every
    # uncompleted PendingKick row past the cooldown. A missed tick
    # is harmless — the next tick reprocesses the same backlog.
    soft_time_limit=_BOUNDED_SWEEP_SOFT_S,
    time_limit=_BOUNDED_SWEEP_HARD_S,
    **_ONCE_KW,
)
def retry_pending_kicks(
    cooldown_minutes: int = _DEFAULT_KICK_COOLDOWN_MIN,
) -> int:
    """
    Re-enqueue ``PendingKick`` rows whose previous handoff failed.

    Returns the count of kicks re-dispatched this tick (broker
    handoffs sent). Rows untouched within ``cooldown_minutes`` of
    their last attempt are skipped so a broker that is still down
    does not get hammered every tick. Operators wire this to their
    Celery beat schedule as needed; default 5-minute cooldown
    matches the prune-task cadence.
    """
    from allianceauth.services.modules.discord.tasks import delete_user

    from .models import PendingKick

    now = timezone.now()
    cutoff = now - timedelta(minutes=cooldown_minutes)
    pending = (
        PendingKick.objects.filter(completed_at__isnull=True)
        .exclude(last_attempt_at__gt=cutoff)
        .order_by("created_at")
        .iterator(chunk_size=_KICK_ITERATOR_CHUNK_SIZE)
    )

    redispatched = 0
    for kick in pending:
        # CAS reserve the row BEFORE the broker handoff so a parallel
        # CLI trigger of this same task cannot also fire
        # ``delete_user.delay`` for the same row. The cooldown filter
        # on the iterator above is advisory (the row may have been
        # touched between SELECT and now); this CAS is the actual
        # gate. Reservation also bumps ``last_attempt_at`` so any
        # concurrent worker sees the row as past-cooldown and skips.
        reserved = (
            PendingKick.objects.filter(pk=kick.pk, completed_at__isnull=True)
            .exclude(last_attempt_at__gt=cutoff)
            .update(last_attempt_at=now)
        )
        if reserved == 0:
            # Lost the reservation race — another worker is handling
            # this row. Move on without bumping our success counter.
            continue
        try:
            delete_user.delay(kick.aa_user_id)  # pyright: ignore[reportCallIssue]
        except Exception as exc:  # noqa: BLE001 — broker drivers (kombu / redis / AMQP) raise unrelated exception classes; record any raise verbatim into ``last_error`` for the next retry sweep.
            log_redacted_exception(
                logger,
                "retry_pending_kicks: handoff failed for "
                "PendingKick pk=%s uid=%s",
                kick.pk,
                kick.uid,
                exc=exc,
            )
            # ``last_attempt_at`` was already set by the reservation
            # CAS above — ``record_failure`` increments attempts +
            # stamps ``last_error`` only.
            PendingKick.record_failure(kick.pk, exc)
            continue
        # The handoff is already on the broker by the time we get here,
        # so the re-dispatch counts regardless of what ``record_success``
        # returns. ``record_success`` filters on
        # ``completed_at__isnull=True`` symmetrically with the on_commit
        # handoff in ``_arrange_kick_handoff``: a manual admin write or a
        # racing on_commit closure could have flipped the row between the
        # reservation and now, so this CAS may lose harmlessly. We mark
        # the row completed + clear any stale ``last_error`` on a win, but
        # count the DISPATCH — not the CAS — because counting the CAS
        # would undercount a genuine re-dispatch whose row a racer
        # completed first. ``delete_user`` is idempotent, so a rare
        # double-dispatch on a lost CAS is harmless.
        PendingKick.record_success(kick.pk)
        redispatched += 1
    return redispatched


@shared_task(
    name=f"{APP_LABEL}.prune_audit_runs",
    base=_BASE,
    max_retries=0,
    autoretry_for=(),
    # Retention pruning is bounded by ``AA_DISCORD_AUDIT_RUN_RETENTION_DAYS``;
    # a missed run is harmless (next beat picks up the same backlog).
    # 5-minute hard ceiling is generous for the cascade DELETE on a
    # year-old guild's worth of findings; raise the soft limit if your
    # operator hits it routinely and the cascade itself is the bottleneck.
    soft_time_limit=_BOUNDED_SWEEP_SOFT_S,
    time_limit=_BOUNDED_SWEEP_HARD_S,
    **_ONCE_KW,
)
def prune_audit_runs() -> int:
    """
    Two-phase retention sweep: expire idempotency keys, then delete aged rows.

    Mirrors ``prune_audit_runs`` management command's two phases so an
    operator wiring this beat task (and forgetting to also cron the
    management command) still gets idempotency-key expiry, not just
    row deletion. Phase 1 is gated on
    ``AA_DISCORD_AUDIT_IDEMPOTENCY_KEY_TTL_DAYS`` (sentinel ``0`` opts
    out); phase 2 on ``AA_DISCORD_AUDIT_RUN_RETENTION_DAYS``.

    Returns the count of deleted rows from phase 2.
    """
    from .app_settings import AA_DISCORD_AUDIT_IDEMPOTENCY_KEY_TTL_DAYS
    from .models import AuditRun
    from .persistence import expire_idempotency_keys

    now = timezone.now()

    # Phase 1: idempotency-key expiry. ``expire_idempotency_keys``
    # is a no-op when the TTL setting is ``0`` (sentinel for "never
    # expire"); the call itself is cheap so the gate lives inside
    # the helper and the beat task stays simple.
    if AA_DISCORD_AUDIT_IDEMPOTENCY_KEY_TTL_DAYS:
        keys_cutoff = now - timedelta(
            days=AA_DISCORD_AUDIT_IDEMPOTENCY_KEY_TTL_DAYS
        )
        expire_idempotency_keys(keys_cutoff)

    # Phase 2: row deletion. Same opt-out sentinel.
    if AA_DISCORD_AUDIT_RUN_RETENTION_DAYS == _RETENTION_DISABLED:
        return 0
    cutoff = now - timedelta(days=AA_DISCORD_AUDIT_RUN_RETENTION_DAYS)

    # Delete in fixed-size batches rather than one cascade DELETE: a
    # year-old backlog of findings / invocations can take the single
    # cascade past the task's hard time limit and be killed mid-delete,
    # emitting no metric. Each batch's ``.delete()`` auto-commits, so a
    # hard kill leaves the already-deleted batches purged (incremental
    # progress) and the next tick resumes from the remaining aged rows.
    total_deleted = 0
    while True:
        # Re-query each iteration: the previous batch was deleted, so the
        # next slice of PKs is a fresh window over the shrinking backlog.
        chunk = list(
            AuditRun.objects.filter(started_at__lt=cutoff).values_list(
                "pk", flat=True
            )[:_PRUNE_BATCH_SIZE]
        )
        if not chunk:
            break
        batch = AuditRun.objects.filter(pk__in=chunk)
        # Forensic preambles before the cascade deletes the batch: a
        # PENDING tombstone (INFO) and a destructive-action breadcrumb
        # (WARNING) for runs that actually stripped / kicked, so the
        # action history survives in the log aggregator after the rows
        # are gone. Neither writes to the doomed rows.
        log_pending_purges(batch)
        log_destructive_purges(batch)
        # ``QuerySet.delete()`` returns ``(total_deleted, {label: count})``;
        # the per-label ``breakdown`` carries the AuditRun-level count.
        # Emit the metric per batch so it tracks incremental progress
        # (parent-level turnover keeps it comparable across deployments
        # with different finding cardinalities) and survives a mid-prune
        # hard kill after some batches have committed.
        deleted, breakdown = batch.delete()
        observe_runs_pruned(breakdown.get(AuditRun._meta.label, 0))
        total_deleted += deleted
    return int(total_deleted)
