"""
Webhook POST transport layer: single-attempt classifier + retry policy.

Split out of ``notifications.py`` to isolate the transport concerns
(retry-able status set, exponential backoff with jitter, ``Retry-After``
parsing, the per-attempt try/except dance) from the orchestration
concerns that stay in ``notifications.py`` (``notify_admins``,
``post_webhook`` outer loop, ``fan_out_for_run``, ``RedactWebhookFilter``
install/remove lifecycle).

``_attempt_post`` returns a discriminated outcome (``sent`` / ``retry`` /
``failed``); the caller's loop reads as one-line dispatch on the
outcome kind. ``Retry-After`` is clamped to a bounded ceiling so a
buggy gateway cannot pin a worker indefinitely.

Tests that monkey-patch ``requests.post`` for unit-testing the
classifier should patch ``aa_discord_audit._webhook_transport.requests.post``
— the patch must apply to the module that *imports and calls* ``requests``,
not the ``notifications`` package which now imports the helper through
this submodule.
"""

from __future__ import annotations

import math
import random
import time
from dataclasses import dataclass
from http import HTTPStatus
from typing import Final, TypeAlias

import requests

from ._log import get_logger
from ._metrics import observe_webhook_delivery
from ._redaction import (
    log_redacted_exception,
    scrub_exception,
)
from .app_settings import AA_DISCORD_AUDIT_WEBHOOK_TIMEOUT

# 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(
    {
        HTTPStatus.TOO_MANY_REQUESTS,
        HTTPStatus.BAD_GATEWAY,
        HTTPStatus.SERVICE_UNAVAILABLE,
        HTTPStatus.GATEWAY_TIMEOUT,
    }
)

# 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

# Log under the orchestration module's name so all webhook-flow
# messages (transport + orchestrator) surface under one canonical
# logger. Operators filter by
# ``extensions.aa_discord_audit.notifications`` and see both
# retry/backoff lines (emitted here) and idempotency-stamp lines
# (emitted by ``notifications``) on the same channel. ``get_logger``
# applies the ``extensions.`` prefix exactly once so both sites resolve
# to the same Logger instance without ``notifications`` ↔ transport
# import cycle. A direct ``from .notifications import logger`` would
# cycle (``notifications`` imports ``_attempt_post`` from here).
logger = get_logger("aa_discord_audit.notifications")


@dataclass(frozen=True, slots=True)
class _AttemptSent:
    """Outcome: the POST was accepted (HTTP 2xx)."""


@dataclass(frozen=True, slots=True)
class _AttemptRetry:
    """Outcome: transient error — caller should sleep then retry."""

    sleep_for: float


@dataclass(frozen=True, slots=True)
class _AttemptFailed:
    """Outcome: terminal failure — caller must not retry."""


_AttemptOutcome: TypeAlias = _AttemptSent | _AttemptRetry | _AttemptFailed


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


def _normalize_retry_after(value: float) -> float:
    """Clamp a ``Retry-After`` seconds value to ``[0, ceiling]``."""
    # 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``.
        # ``ValueError`` covers the canonical JSON parse error;
        # ``JSONDecodeError`` is requests' alias for the same hierarchy
        # but listed explicitly because requests' chunked path can
        # raise it without inheriting from ValueError on some upstream
        # versions. ``ContentDecodingError`` (gzip-malformed bodies)
        # is intentionally not caught here — that signals a deeper
        # transport problem the surrounding retry loop will surface
        # via the regular request exception path.
        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,
    attempts_budget: 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.
    #
    # Metric note: ``observe_webhook_delivery`` is called only on
    # terminal outcomes (``sent`` / each ``failed`` flavour). Retry
    # branches do not emit; the caller will reach a terminal outcome
    # eventually and that single attempt is what the histogram counts
    # as the operator-visible delivery latency. The per-attempt clock
    # restarts on each call; the histogram thus measures last-attempt
    # latency, not whole-loop wall-clock — matching how OIDC's BCL
    # delivery histogram is read.
    t0 = time.monotonic()
    try:
        response = requests.post(
            url,
            # ``embed`` flows in as ``dict[str, object]`` (Discord embed
            # schema is structural, not a TypedDict here). Recent
            # ``types-requests`` releases narrow ``json=`` to a recursive
            # ``Mapping[str, JsonType]`` which is incompatible with the
            # covariant ``object`` value type. The ``requests`` runtime
            # serialises via ``json.dumps`` and accepts the dict shape;
            # the ignore is scoped to this single argument so the
            # ``unused-ignore`` warn fires the day stubs accept ``object``.
            json={"embeds": [embed]},  # type: ignore[list-item]
            timeout=AA_DISCORD_AUDIT_WEBHOOK_TIMEOUT,
        )
        if response.status_code in _RETRYABLE_STATUSES:
            retry_after = _retry_after_from_response(response)
            if attempt < attempts_budget:
                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,
                    attempts_budget,
                )
                return _AttemptRetry(sleep_for=sleep_for)
            logger.error(
                "webhook POST exhausted retries on HTTP %s",
                response.status_code,
            )
            observe_webhook_delivery(
                outcome="failed_retryable",
                duration_seconds=time.monotonic() - t0,
            )
            return _AttemptFailed()
        response.raise_for_status()
    except requests.exceptions.HTTPError as exc:
        log_redacted_exception(
            logger, "webhook POST failed (URL redacted)", exc=exc
        )
        observe_webhook_delivery(
            outcome="failed_http",
            duration_seconds=time.monotonic() - t0,
        )
        return _AttemptFailed()
    except requests.exceptions.RequestException as exc:
        scrub_exception(exc)
        if attempt < attempts_budget:
            sleep_for = _compute_backoff(attempt)
            logger.warning(
                "webhook POST raised %s; retrying in %.2fs (attempt %d/%d)",
                type(exc).__name__,
                sleep_for,
                attempt,
                attempts_budget,
            )
            return _AttemptRetry(sleep_for=sleep_for)
        # ``log_redacted_exception`` is idempotent on the already-
        # scrubbed ``exc``; calling it here keeps the helper's
        # contract self-sufficient ("safe to invoke without an
        # outer redacted-loggers context") so a future direct
        # importer of ``_attempt_post`` cannot leak the URL via this
        # exhaustion path.
        log_redacted_exception(
            logger,
            "webhook POST failed after retries (URL redacted)",
            exc=exc,
        )
        observe_webhook_delivery(
            outcome="failed_transport",
            duration_seconds=time.monotonic() - t0,
        )
        return _AttemptFailed()
    observe_webhook_delivery(
        outcome="sent", duration_seconds=time.monotonic() - t0
    )
    return _AttemptSent()
