"""
AA-notify and Discord-webhook fan-out for audit run summaries.

Three independent notification paths:

- Logs: structured fields per finding, always on.
- AA-notify to permission holders: enabled by
  ``AA_DISCORD_AUDIT_NOTIFY_ADMINS``.
- Discord webhook: enabled iff ``AA_DISCORD_AUDIT_WEBHOOK_URL`` is
  set.

Webhook URL hygiene is best-effort by design: a logging filter +
custom exception handler cover the well-known leakage paths, but
operators are warned in README to treat the URL as a credential.
"""

from __future__ import annotations

import contextlib
import logging
import random
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final, Literal

import requests
from django.db.models import Q

from ._redaction import (
    MASK as _WEBHOOK_MASK,
)
from ._redaction import (
    WEBHOOK_URL_RE as _WEBHOOK_URL_RE,
)
from ._redaction import (
    scrub_exception as _scrub_exception_url,
)
from ._redaction import (
    scrub_exception_args as _scrub_args,
)
from .app_settings import (
    AA_DISCORD_AUDIT_NOTIFY_ADMINS,
    AA_DISCORD_AUDIT_WEBHOOK_MAX_RETRIES,
    AA_DISCORD_AUDIT_WEBHOOK_TIMEOUT,
    AA_DISCORD_AUDIT_WEBHOOK_URL,
)
from .models import AuditRunState
from .permissions import VIEW_AUDITRUN

# Summary keys for idempotency markers. The audit's ``AuditRun.summary``
# JSONField is in the AppendOnlyModelMixin update allow-list, so we can
# stamp these without breaking append-only invariants.
_SUMMARY_NOTIFIED_ADMINS: Final[str] = "admins_notified_at"
_SUMMARY_WEBHOOK_POSTED: Final[str] = "webhook_posted_at"

# Retry-able HTTP statuses (Discord rate-limit 429 + the transient
# 5xx family). Anything else is terminal — re-raise instead of retry.
_RETRYABLE_STATUSES: Final[frozenset[int]] = frozenset({429, 502, 503, 504})

# Maximum honored ``Retry-After`` value in seconds. A buggy gateway
# could ask for hours; we cap so the worker is not pinned indefinitely.
_RETRY_AFTER_CEILING_SECONDS: Final[float] = 60.0

# Jitter window added to exponential backoff. Two workers hitting the
# same 429 simultaneously must NOT sleep identical durations and
# re-stampede Discord on the next attempt; a small uniform jitter
# decorrelates retry timing without materially extending the wait.
_RETRY_JITTER_SECONDS: Final[float] = 0.25


@dataclass(frozen=True)
class _AttemptOutcome:
    """Discriminated outcome of a single ``post_webhook`` attempt."""

    kind: Literal["sent", "retry", "failed"]
    sleep_for: float = 0.0  # only consulted when ``kind == "retry"``


def _compute_backoff(attempt: int) -> float:
    """Exponential delay with jitter for webhook POST retries (1-based)."""
    # 0.5 * 2^(attempt-1) doubles each step (0.5s, 1s, 2s, ...);
    # the uniform jitter decorrelates concurrent workers retrying
    # the same 429 so they do not re-stampede in lock-step. The
    # randomness is purely for load spreading; ``random`` is fine
    # and ``secrets.SystemRandom`` would be overkill (extra syscall
    # per retry for zero security benefit).
    base = 0.5 * (2.0 ** (attempt - 1))
    jitter = random.uniform(0, _RETRY_JITTER_SECONDS)  # nosec B311
    return base + jitter


if TYPE_CHECKING:
    from django.contrib.auth.models import Permission

    from .models import AuditRun


NotifyLevel = Literal["info", "warning", "error"]

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


class RedactWebhookFilter(logging.Filter):
    """
    Mask Discord webhook URLs in log records.

    Attached to the audit's module logger and to ``requests``
    library logs during the POST window. Best-effort: doesn't
    cover ``urllib3.MaxRetryError.__str__``,
    ``traceback.format_exc()`` strings logged outside the
    audit's logger, or third-party Sentry / structlog payloads.
    """

    _MASK = _WEBHOOK_MASK

    def filter(self, record: logging.LogRecord) -> bool:
        """Mutate ``record`` in place to mask webhook URLs; always allow."""
        if isinstance(record.msg, str) and _WEBHOOK_URL_RE.search(record.msg):
            record.msg = _WEBHOOK_URL_RE.sub(self._MASK, record.msg)
        if record.args:
            # Never let the filter itself drop a record.
            with contextlib.suppress(Exception):
                args_iter = (
                    record.args
                    if isinstance(record.args, tuple)
                    else (record.args,)
                )
                record.args = _scrub_args(args_iter, self._MASK)
        # ``exc_text`` is populated by ``Formatter.formatException``
        # the first time the record is rendered; subsequent handlers
        # read the cached string directly. Mask it in place so an
        # upstream Sentry / structlog pipeline that consumes the
        # already-formatted ``exc_text`` cannot leak the URL even
        # when the live ``exc_info`` exception was scrubbed.
        if record.exc_text and _WEBHOOK_URL_RE.search(record.exc_text):
            record.exc_text = _WEBHOOK_URL_RE.sub(self._MASK, record.exc_text)
        return True


def _build_summary_text(run: AuditRun) -> str:
    """Compose the summary text shared by AA-notify and webhook channels."""
    return (
        f"Audit run #{run.pk}: state={run.state}, "
        f"members_scanned={run.members_scanned}, "
        f"findings_count={run.findings_count}, "
        f"actions_applied={run.actions_applied}, "
        f"actions_skipped={run.actions_skipped}"
    )


def notify_admins(run: AuditRun) -> int:
    """
    Write one AA-notify Notification per ``view_auditrun`` perm holder.

    Returns the number of notifications written. No-op when
    ``AA_DISCORD_AUDIT_NOTIFY_ADMINS=False`` or when this run was
    already notified (idempotency-marker stamped on
    ``run.summary[_SUMMARY_NOTIFIED_ADMINS]``).

    Per-recipient errors are caught and logged at WARNING so a
    single broken email backend does not skip subsequent
    recipients.
    """
    if not AA_DISCORD_AUDIT_NOTIFY_ADMINS:
        return 0

    # Idempotency: refuse to re-fan-out if a previous successful
    # notify_admins already stamped the run. Caller (the management
    # command) can still force re-send by clearing the summary key
    # via admin if desired.
    summary_dict = run.summary or {}
    if summary_dict.get(_SUMMARY_NOTIFIED_ADMINS):
        logger.info(
            "notify_admins: run #%s already notified at %s; skipping",
            run.pk,
            summary_dict[_SUMMARY_NOTIFIED_ADMINS],
        )
        return 0

    # Lazy import — AA's notifications module pulls in templates etc.
    from allianceauth.notifications import notify
    from django.contrib.auth import get_user_model
    from django.contrib.auth.models import Permission
    from django.utils import timezone

    User = get_user_model()  # noqa: N806 — Django convention
    app_label, codename = VIEW_AUDITRUN.split(".")
    perm = Permission.objects.filter(
        content_type__app_label=app_label,
        codename=codename,
    ).first()
    if perm is None:
        logger.warning("notify_admins: %s permission not found", VIEW_AUDITRUN)
        return 0

    holders = User.objects.filter(models_filter_for_perm(perm)).distinct()
    summary = _build_summary_text(run)
    count = 0
    level: NotifyLevel = (
        "info" if run.state == AuditRunState.SUCCEEDED.value else "warning"
    )
    for user in holders:
        try:
            notify(
                user=user,
                title=f"Discord audit run #{run.pk}",
                message=summary,
                level=level,
            )
            count += 1
        except Exception:  # noqa: PERF203 -- per-user fan-out must isolate one user's notification failure from the rest of the loop; relocating the try/except outside the loop would lose all subsequent recipients on a single AA-internal raise
            logger.exception(
                "notify_admins: notification to user.pk=%s failed", user.pk
            )

    # Stamp the idempotency marker even if some per-user notifications
    # failed: rerunning would deliver dupes to the recipients that DID
    # receive on this attempt, which is worse than leaving the failed
    # ones to be re-driven manually from the admin.
    run.summary = {
        **summary_dict,
        _SUMMARY_NOTIFIED_ADMINS: timezone.now().isoformat(),
    }
    run.save(update_fields=["summary"])
    return count


def models_filter_for_perm(perm: Permission) -> Q:
    """Return a Q expression for users holding ``perm`` direct or via group."""
    return (
        Q(user_permissions=perm)
        | Q(groups__permissions=perm)
        | Q(is_superuser=True)
    )


def _build_run_embed(run: AuditRun) -> dict[str, object]:
    """Compose the Discord embed dict for ``run``."""
    return {
        "title": f"Discord audit run #{run.pk}",
        "description": _build_summary_text(run),
        "fields": [
            {"name": "state", "value": run.state, "inline": True},
            {
                "name": "scanned",
                "value": str(run.members_scanned),
                "inline": True,
            },
            {
                "name": "findings",
                "value": str(run.findings_count),
                "inline": True,
            },
            {
                "name": "applied",
                "value": str(run.actions_applied),
                "inline": True,
            },
            {
                "name": "skipped",
                "value": str(run.actions_skipped),
                "inline": True,
            },
        ],
    }


def _normalize_retry_after(value: float) -> float:
    """Clamp a ``Retry-After`` seconds value to ``[0, ceiling]``."""
    import math

    # NaN collapses to 0; otherwise clamp to the bounded retry window.
    if math.isnan(value):
        return 0.0
    return min(max(value, 0.0), _RETRY_AFTER_CEILING_SECONDS)


def _retry_after_from_response(response: requests.Response | None) -> float:
    """Read ``Retry-After`` (seconds form) from a response, capped."""
    if response is None:
        return 0.0
    raw = response.headers.get("Retry-After")
    if not raw:
        # Discord also sends a JSON body with a float ``retry_after``
        # for global rate-limit responses; use it as a fallback.
        # Guard ``isinstance(decoded, dict)``: a malformed gateway
        # could legitimately return a JSON list / scalar / null;
        # ``[].get(...)`` would AttributeError straight out of the
        # retry helper into the surrounding ``except`` (which only
        # catches ``requests.exceptions.*``), crashing the worker
        # without stamping the idempotency marker on ``summary``.
        try:
            decoded = response.json()
        except (ValueError, requests.exceptions.JSONDecodeError):
            return 0.0
        if not isinstance(decoded, dict):
            return 0.0
        try:
            return _normalize_retry_after(float(decoded.get("retry_after", 0)))
        except (TypeError, ValueError):
            return 0.0
    try:
        return _normalize_retry_after(float(raw))
    except ValueError:
        # HTTP-date form is permitted by RFC; we don't currently
        # parse it and fall back to bounded backoff.
        return 0.0


def _attempt_post(
    url: str,
    embed: dict[str, object],
    *,
    attempt: int,
    total_attempts: int,
) -> _AttemptOutcome:
    """Single ``requests.post`` attempt; classify into sent/retry/failed."""
    # Centralises the per-attempt classifier so ``post_webhook``'s
    # outer loop reads as one-line dispatch on the outcome. ``retry``
    # carries the sleep-for delay (max of Retry-After and the
    # exponential-with-jitter backoff). ``failed`` is terminal — the
    # caller returns immediately.
    try:
        response = requests.post(
            url,
            json={"embeds": [embed]},
            timeout=AA_DISCORD_AUDIT_WEBHOOK_TIMEOUT,
        )
        if response.status_code in _RETRYABLE_STATUSES:
            retry_after = _retry_after_from_response(response)
            if attempt < total_attempts:
                sleep_for = max(retry_after, _compute_backoff(attempt))
                logger.warning(
                    "webhook POST got HTTP %s; retrying in %.2fs "
                    "(attempt %d/%d)",
                    response.status_code,
                    sleep_for,
                    attempt,
                    total_attempts,
                )
                return _AttemptOutcome("retry", sleep_for)
            logger.error(
                "webhook POST exhausted retries on HTTP %s",
                response.status_code,
            )
            return _AttemptOutcome("failed")
        response.raise_for_status()
    except requests.exceptions.HTTPError as exc:
        _scrub_exception_url(exc)
        logger.exception("webhook POST failed (URL redacted)")
        return _AttemptOutcome("failed")
    except requests.exceptions.RequestException as exc:
        _scrub_exception_url(exc)
        if attempt < total_attempts:
            sleep_for = _compute_backoff(attempt)
            logger.warning(
                "webhook POST raised %s; retrying in %.2fs (attempt %d/%d)",
                type(exc).__name__,
                sleep_for,
                attempt,
                total_attempts,
            )
            return _AttemptOutcome("retry", sleep_for)
        logger.exception("webhook POST failed after retries (URL redacted)")
        return _AttemptOutcome("failed")
    return _AttemptOutcome("sent")


def post_webhook(run: AuditRun) -> Literal["sent", "skipped", "failed"]:
    """
    POST a counts-only summary embed to the configured webhook URL.

    Returns:
    - ``"sent"``: HTTP 2xx received and the run is stamped with
      the idempotency marker.
    - ``"skipped"``: webhook URL is unset or the run was already
      posted (idempotent no-op).
    - ``"failed"``: all retries exhausted; URL is left unstamped so
      a subsequent call may try again.

    Body intentionally excludes member uids and usernames — operators
    wanting detail click through the admin URL link in the embed.

    Retries on HTTP 429 / 5xx and on transient
    ``requests.RequestException`` up to
    ``AA_DISCORD_AUDIT_WEBHOOK_MAX_RETRIES``, honouring ``Retry-After``
    when supplied. The retry loop intentionally uses ``time.sleep``
    rather than Celery countdown because the caller is already
    inside a worker / management command and we want the sleep to
    keep the audit's run/finding state consistent (no
    self-rescheduling).

    Operator trade-off: ``time.sleep`` pins the worker for up to
    ``MAX_RETRIES * _RETRY_AFTER_CEILING_SECONDS`` (default ≈ 180 s)
    on a sustained webhook outage. Operators running a small Celery
    queue should size for that headroom or accept that a Discord
    rate-limit storm can briefly choke other audit work. Lowering
    ``MAX_RETRIES`` or the ceiling tunes the trade-off.
    """
    if AA_DISCORD_AUDIT_WEBHOOK_URL is None:
        return "skipped"

    summary_dict = run.summary or {}
    if summary_dict.get(_SUMMARY_WEBHOOK_POSTED):
        logger.info(
            "post_webhook: run #%s already posted at %s; skipping",
            run.pk,
            summary_dict[_SUMMARY_WEBHOOK_POSTED],
        )
        return "skipped"

    from django.utils import timezone

    url = AA_DISCORD_AUDIT_WEBHOOK_URL  # narrow Final[str | None] -> str

    redactor = RedactWebhookFilter()
    # Cover every well-known leakage logger: ``urllib3`` (the
    # connection layer that emits the URL on connection errors),
    # ``requests`` (its own bare logger; some installations route
    # exception tracebacks through this name), and our own logger.
    # Belt-and-braces because exception formatters in upstream
    # error reporters (Sentry, Django's request handler) may
    # serialise ``exc.request.url`` from any of these handler
    # chains; ``_scrub_exception_url`` only catches the live
    # exception, not its recorded copy.
    redacted_loggers = (
        logging.getLogger("urllib3"),
        logging.getLogger("requests"),
        logging.getLogger("requests.packages.urllib3"),
        logger,
    )
    for redacted in redacted_loggers:
        redacted.addFilter(redactor)
    try:
        embed = _build_run_embed(run)
        total_attempts = max(1, AA_DISCORD_AUDIT_WEBHOOK_MAX_RETRIES + 1)
        for attempt in range(1, total_attempts + 1):
            outcome = _attempt_post(
                url,
                embed,
                attempt=attempt,
                total_attempts=total_attempts,
            )
            if outcome.kind == "sent":
                # 2xx — stamp the idempotency marker so a retry by
                # the caller (Celery retry, manual re-run) is a no-op.
                run.summary = {
                    **summary_dict,
                    _SUMMARY_WEBHOOK_POSTED: timezone.now().isoformat(),
                }
                run.save(update_fields=["summary"])
                return "sent"
            if outcome.kind == "failed":
                return "failed"
            # outcome.kind == "retry"
            time.sleep(outcome.sleep_for)
        # Unreachable: total_attempts >= 1 guarantees the loop body
        # runs at least once and returns from every branch above.
        return "failed"
    finally:
        for redacted in redacted_loggers:
            redacted.removeFilter(redactor)


def fan_out_for_run(run_id: int, *, source: str) -> None:
    """Load a run by pk and fan out notify_admins + post_webhook."""
    # Centralises the post-audit notification path used by the CLI
    # command and the Celery beat task. Notification failures are
    # logged but never raised: notifications are observability and
    # must not fail the audit (the CLI has its own retry, the Celery
    # task is independently retried). ``source`` distinguishes
    # which caller the failure logs came from.
    # Lazy AuditRun import to keep this module's load-time graph
    # minimal — see the matching deferred import in
    # ``_build_run_embed`` for the same rationale.
    from .models import AuditRun

    try:
        run = AuditRun.objects.get(pk=run_id)
    except AuditRun.DoesNotExist:
        # Distinguish "run was retention-pruned between apply_plan
        # and beat fan-out" from a real notification failure —
        # otherwise the operator chases a phantom delivery error.
        logger.warning(
            "notification fan-out: run #%s not found (purged?) source=%s",
            run_id,
            source,
        )
        return
    try:
        notify_admins(run)
        post_webhook(run)
    except Exception as exc:
        # ``RedactWebhookFilter`` is installed only inside
        # ``post_webhook``'s own scope; an exception that escapes past
        # it reaches this outer logger without a filter installed.
        # Scrub the exception in place so a notify_admins-side raise
        # that somehow captured the URL (e.g. via a shared
        # ``requests.Session`` traceback chain) cannot leak it through
        # this fallback log line.
        _scrub_exception_url(exc)
        logger.exception(
            "notification fan-out failed (%s) run=%s", source, run_id
        )
