"""
Celery tasks for the Discord audit module.

Two 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. ``soft_time_limit`` is set to
  ``AA_DISCORD_AUDIT_RUN_DEADLINE_MINUTES * 60 + 60`` so the audit's
  own deadline is authoritative.
- ``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.
"""

from __future__ import annotations

import logging
from datetime import timedelta
from typing import Final

from celery import shared_task
from django.db.models import F
from django.utils import timezone

from ._redaction import 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_pending_purges

# 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 must be > deadline so user code (apply_plan's per-finding
# try/except) sees ``SoftTimeLimitExceeded`` and rolls the run to
# FAILED; hard kills a wedged worker. ``_HARD_OVER_SOFT_MULTIPLIER``
# keeps the soft→hard window equal to the deadline→soft window.
_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

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

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

# Use AA's QueueOnce + graceful-mode duplicate suppression.
try:
    from allianceauth.services.tasks import QueueOnce

    _BASE = QueueOnce
    _ONCE_KW = {"once": {"graceful": True}}
except ImportError:  # pragma: no cover
    _BASE = None
    _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 can be caught and ignored by user code
    # (in particular by ``requests`` calls inside try/except). The
    # hard ``time_limit`` is the only guarantee a wedged worker is
    # eventually killed; pin it 60 s above the soft limit so user
    # code has a chance to gracefully roll the run to FAILED.
    soft_time_limit=(
        AA_DISCORD_AUDIT_RUN_DEADLINE_MINUTES * _SECONDS_PER_MINUTE
        + _SOFT_OVER_DEADLINE_S
    ),
    time_limit=(
        AA_DISCORD_AUDIT_RUN_DEADLINE_MINUTES * _SECONDS_PER_MINUTE
        + _HARD_OVER_SOFT_MULTIPLIER * _SOFT_OVER_DEADLINE_S
    ),
    **_ONCE_KW,
)
def audit_orphan_members(
    action: str | None = None, notify: bool = True
) -> int:
    """
    Run an audit. Returns the resulting ``AuditRun.pk`` for tracing.

    ``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).
    """
    from .audit import apply_plan, build_plan
    from .client import make_audited_client
    from .models import OrphanAction, RunTrigger

    client = make_audited_client()

    cli_override = OrphanAction(action) if action else None

    plan = build_plan(
        client,
        triggered_by=RunTrigger.BEAT,
        triggered_by_user=None,
        cli_argv=[f"--action={action}"] if action else [],
        confirmation_bypassed=True,
        cli_override=cli_override,
    )
    apply_plan(plan, client)

    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}.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 rows successfully handed off this tick.
    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)
    )

    succeeded = 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:
            # ``scrub_exception`` BEFORE ``logger.exception`` so the
            # formatted traceback in the ERROR log line cannot leak
            # the webhook URL via a nested broker exception chain.
            scrub_exception(exc)
            logger.exception(
                "retry_pending_kicks: handoff failed for "
                "PendingKick pk=%s uid=%s",
                kick.pk,
                kick.uid,
            )
            # ``last_attempt_at`` was already set by the reservation
            # CAS above — only ``attempts`` and ``last_error`` need
            # the failure bump here.
            PendingKick.objects.filter(pk=kick.pk).update(
                attempts=F("attempts") + 1,
                last_error=redact_str(str(exc)),
            )
            continue
        # ``completed_at__isnull=True`` is defensive: a manual admin
        # write or other out-of-band path could have flipped it
        # between the reservation and now. Keeps ``succeeded`` honest.
        # ``last_error=""`` clears the prior failure message so the
        # admin / forensics view does not show a stale error on a row
        # that recovered.
        flipped = PendingKick.objects.filter(
            pk=kick.pk, completed_at__isnull=True
        ).update(
            completed_at=now,
            attempts=F("attempts") + 1,
            last_error="",
        )
        if flipped:
            succeeded += 1
    return succeeded


@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:
    """
    Delete ``AuditRun`` rows older than retention.

    Returns the count of deleted rows.
    """
    from .models import AuditRun

    if AA_DISCORD_AUDIT_RUN_RETENTION_DAYS == _RETENTION_DISABLED:
        return 0
    cutoff = timezone.now() - timedelta(
        days=AA_DISCORD_AUDIT_RUN_RETENTION_DAYS
    )
    queryset = AuditRun.objects.filter(started_at__lt=cutoff)
    # PENDING tombstone preamble: no DB write on a doomed row.
    log_pending_purges(queryset)
    deleted, _ = queryset.delete()
    return int(deleted)
