"""
Vendored Discord client extensions.

``AuditedDiscordClient`` subclasses AA core's ``DiscordClient`` to
add two capabilities the upstream client does not expose:

- ``X-Audit-Log-Reason`` header threading via a ``ContextVar``
  (operators see the audit's actions in Discord's own audit log).
- ``guild_members(...)`` paginated enumeration via ``_api_request``
  with a ``params=`` kwarg.

The ``_api_request`` override replicates a small slice of
upstream's HTTP body (URL building, headers, backoff) for the
reason-and/or-params branch; the no-extras branch delegates to
``super()`` so most calls inherit upstream behavior unchanged. The
``inspect.signature``-based test in ``tests/test_client.py``
catches AA-side signature drift on minor bumps.
"""

from __future__ import annotations

from contextlib import contextmanager
from contextvars import ContextVar
from http import HTTPStatus
from typing import TYPE_CHECKING, cast
from urllib.parse import urljoin
from uuid import uuid1

import requests
from allianceauth import __title_useragent__, __url__, __version__
from allianceauth.services.modules.discord.discord_client import DiscordClient
from allianceauth.services.modules.discord.discord_client.app_settings import (
    DISCORD_API_BASE_URL,
    DISCORD_API_TIMEOUT_CONNECT,
    DISCORD_API_TIMEOUT_READ,
)

from ._log import get_logger
from ._redaction import log_redacted_exception, redact_str
from .constants import ALLOWED_HTTP_METHODS, MAX_GUILD_MEMBER_PAGES, PAGE_SIZE
from .reasons import safe_audit_reason

if TYPE_CHECKING:  # pragma: no cover
    from collections.abc import Generator

# Discord API request bodies are JSON dicts with str keys and arbitrary
# JSON-typed values; without django-stubs / requests-stubs the inner
# value is ``object``.
JsonBody = dict[str, object]
# Query-string params on the Discord API are always str / int.
QueryParams = dict[str, str | int]
# Discord member-list page entries arrive as nested JSON dicts.
GuildMemberDict = dict[str, object]


def extract_member_user_id(raw_member: GuildMemberDict) -> int | None:
    """
    Pull the ``user.id`` from a guild-member JSON dict.

    Centralises the two-step narrowing (``isinstance(user_block, dict)``
    + ``int(user_block["id"])``) that was previously duplicated between
    the pagination cursor in ``guild_members`` and the ``user_id_filter``
    branch in ``audit.build_plan``.

    Returns ``None`` for any shape that is not a guild-member dict
    with a coercible ``user.id`` snowflake — callers interpret that
    as "skip / stop pagination", not as an error. The defensive path
    covers Discord shape drift, proxy responses, and intentionally
    malformed values used in fuzz testing.
    """
    # ``isinstance(raw_member, dict)`` is runtime-required: this
    # function defends against Discord shape drift, proxy responses,
    # and intentionally malformed fuzz inputs. pyright narrows the
    # static type via ``GuildMemberDict`` and reads the check as
    # unnecessary, but the contract is that callers can hand us
    # anything ``json.loads`` produced.
    if not isinstance(raw_member, dict):  # pyright: ignore[reportUnnecessaryIsInstance]
        return None
    user_block = raw_member.get("user")
    if not isinstance(user_block, dict):
        return None
    user_id = user_block.get("id")
    if user_id is None:
        return None
    try:
        parsed = int(str(user_id))
    except (ValueError, TypeError):
        return None
    # Defend against malformed payloads (buggy proxies, fuzz inputs)
    # whose ``id`` overflows the Discord snowflake range. ``parsed`` is
    # an unbounded Python int — a 22-digit numeric string parses cleanly
    # here and then explodes inside ``bulk_create`` when the ORM hits
    # ``PositiveBigIntegerField`` (max 2^63-1). Surfacing the malformed
    # shape at the pagination boundary keeps the bad row out of the
    # build_plan dataset entirely.
    if parsed < 1 or parsed >= 1 << 63:
        return None
    return parsed


logger = get_logger(__name__)

# Per-thread / per-task reason carrier consumed by the ``_api_request``
# override. ``with_reason()`` is the canonical setter; direct mutation
# is discouraged.
_REASON_CTX: ContextVar[str | None] = ContextVar(
    "aa_discord_audit_reason",
    default=None,
)

# Static request headers — the User-Agent / accept / precision triple
# does not vary per call. Hot path: ``guild_members`` issues one
# ``_api_request`` per 1000-member page (50 calls for a 50k guild),
# so caching the dict at module load avoids rebuilding it per call.
# ``authorization`` and the optional ``content-type`` /
# ``X-Audit-Log-Reason`` headers are layered on top per request.
_BASE_HEADERS: dict[str, str] = {
    "User-Agent": f"{__title_useragent__}/{__version__} (+{__url__})",
    "accept": "application/json",
    "X-RateLimit-Precision": "millisecond",
}


@contextmanager
def with_reason(reason: str) -> Generator[str, None, None]:
    """
    Thread an ``X-Audit-Log-Reason`` for the duration of the block.

    The ``finally`` reset is unconditional so an exception inside
    the block does not leak the reason into subsequent unrelated
    API calls in the same thread / task.
    """
    sanitized = safe_audit_reason(reason)
    token = _REASON_CTX.set(sanitized)
    try:
        yield sanitized
    finally:
        _REASON_CTX.reset(token)


class AuditedDiscordClient(DiscordClient):
    """
    Subclass of AA core ``DiscordClient`` with audit-specific extensions.

    The override pattern:

    - When neither ``params`` nor a reason is set, delegate to
      ``super()._api_request(...)`` so we inherit upstream's full
      HTTP body, error handling, and rate-limit backoff with no
      drift surface.
    - When either is set, replicate the relevant subset of
      upstream's HTTP body. KEEP IN SYNC with
      ``allianceauth.services.modules.discord.discord_client.client.DiscordClient._api_request``
      (verified against allianceauth==4.6.x); the
      ``inspect.signature`` test in ``tests/test_client.py``
      raises if AA core gains a kwarg this branch doesn't pass.
    """

    def _api_request(
        self,
        method: str,
        route: str,
        data: JsonBody | None = None,
        authorization: str | None = None,
        raise_for_status: bool = True,
        params: QueryParams | None = None,
    ) -> requests.Response:
        reason = _REASON_CTX.get()
        if reason is None and params is None:
            # mypy and basedpyright disagree about ``super()._api_request``:
            # mypy infers ``Any`` (no AA-core stubs) and ``warn_return_any``
            # rejects the propagation; basedpyright reads the upstream
            # source and sees ``requests.Response``, treating a ``cast``
            # as ``reportUnnecessaryCast``. The targeted ``no-any-return``
            # ignore satisfies mypy without inserting a cast basedpyright
            # would warn about — and ``warn_unused_ignores`` will catch
            # the day mypy gains the stubs.
            return super()._api_request(  # type: ignore[no-any-return]
                method,
                route,
                data=data,
                authorization=authorization,
                raise_for_status=raise_for_status,
            )
        return self._audited_api_request_body(
            method,
            route,
            data=data,
            authorization=authorization,
            raise_for_status=raise_for_status,
            params=params,
            reason=reason,
        )

    def _audited_api_request_body(
        self,
        method: str,
        route: str,
        *,
        data: JsonBody | None,
        authorization: str | None,
        raise_for_status: bool,
        params: QueryParams | None,
        reason: str | None,
    ) -> requests.Response:
        """
        Replicated subset of upstream ``_api_request`` for the
        reason-and/or-params path.

        Mirrors AA 4.6.x's body. KEEP IN SYNC with
        ``allianceauth.services.modules.discord.discord_client.client.DiscordClient._api_request``;
        the ``inspect.signature`` test in ``tests/test_client.py``
        raises if AA core gains a kwarg this branch doesn't pass.
        Bounding the replicated body to one helper bounds the
        review surface of the AA-core sync check.
        """
        uid = uuid1().hex
        if method not in ALLOWED_HTTP_METHODS:
            raise ValueError(f"Invalid method: {method}")
        if not authorization:
            authorization = f"Bot {self.access_token}"

        self._handle_ongoing_api_backoff(uid)
        if self.is_rate_limited:
            self._ensure_rate_limed_not_exhausted(uid)

        headers = {**_BASE_HEADERS, "authorization": str(authorization)}
        if data:
            headers["content-type"] = "application/json"
        if reason is not None:
            headers["X-Audit-Log-Reason"] = reason

        url = urljoin(DISCORD_API_BASE_URL, route)
        # NOTE: kept as ``dict[str, object]`` rather than a TypedDict because
        # ``DISCORD_API_TIMEOUT_*`` arrives as ``object`` through AA core's
        # ``clean_setting``; constraining the timeout slot to ``tuple[float,
        # float]`` would require a cast at every call and lose the static
        # benefit. The keys here are stable; the inner shapes flow through
        # ``requests`` (which has no stubs in the project).
        args: dict[str, object] = {
            "url": url,
            "headers": headers,
            "timeout": (DISCORD_API_TIMEOUT_CONNECT, DISCORD_API_TIMEOUT_READ),
        }
        if data:
            args["json"] = data
        if params is not None:
            args["params"] = params

        # ``redact_str`` is a no-op on the regular Discord-API URL
        # (its host is ``discord.com/api/...``, not ``/api/webhooks``),
        # but defends in depth: a misconfigured ``DISCORD_API_BASE_URL``
        # pointing at a webhook host, or a future ``route`` value that
        # ``urljoin`` resolves to an absolute URL, would otherwise
        # surface the secret-bearing URL on this INFO line ahead of
        # the redact-on-error path below.
        logger.info(
            "%s: sending %s request to url '%s' (with reason=%s, params=%s)",
            uid,
            method.upper(),
            redact_str(url),
            reason is not None,
            params is not None,
        )
        try:
            # ``getattr(requests, method)`` returns ``Any`` because the
            # attribute name is computed at runtime; narrow once here so
            # downstream attribute access (``r.text``, ``r.status_code``)
            # and the eventual ``return r`` type-check against
            # ``requests.Response``.
            r: requests.Response = cast(
                "requests.Response", getattr(requests, method)(**args)
            )
        except requests.exceptions.RequestException as exc:
            # Mirror upstream behavior: on a network-level error
            # before we have a response, the rate-limiter cannot be
            # updated. Log with the request id (uid) and re-raise so
            # the caller's classifier (executor.http_error_or_auth)
            # picks the failure up. ``r.text`` is unavailable here;
            # the upstream message format is preserved minus that.
            #
            # The audit's Discord-API URL does not carry a bot token
            # (auth lives in headers), but a shared ``requests.Session``
            # whose connection pool previously touched a webhook URL
            # can surface that URL through urllib3's exception chain.
            # ``log_redacted_exception`` couples the scrub + log so
            # the order cannot drift.
            log_redacted_exception(
                logger,
                "%s: Discord API request raised before any response "
                "was received (URL is in the request log line above)",
                uid,
                exc=exc,
            )
            raise
        if not r.ok:
            # Truncate ``r.text`` so a 4xx body containing a Discord
            # error JSON with the offending nickname/operator data
            # does not flood log aggregators or leak nicknames into
            # secondary index pipelines. 200 chars covers the
            # standard ``{"code": ..., "message": "..."}`` shape.
            # ``redact_str`` covers the unlikely-but-cheap case of
            # Discord echoing a request URL back (e.g., via a future
            # ``X-Audit-Log-Reason``-like header surfaced in the body).
            body_excerpt = redact_str((r.text or "")[:200])
            logger.error(
                "%s: Discord API returned error code %d for route "
                "'%s' (body excerpt, may be truncated): %s",
                uid,
                r.status_code,
                route,
                body_excerpt,
            )
        if r.status_code == HTTPStatus.TOO_MANY_REQUESTS:
            self._handle_new_api_backoff(r, uid)
        self._report_rate_limit_from_api(r, uid)
        if raise_for_status:
            r.raise_for_status()
        return r

    def guild_members(
        self,
        guild_id: int,
        *,
        limit: int = PAGE_SIZE,
        after: int | None = None,
    ) -> Generator[list[GuildMemberDict], None, None]:
        """
        Yield pages of guild members until a short page terminates.

        Each yielded value is a list of ``GuildMember``-shaped dicts
        as Discord returns them. Callers translate each page via
        ``GuildMember.from_dict`` (see ``audit.build_plan``).

        Defensive against Discord shape drift: a non-list response
        (e.g. an error envelope returned with HTTP 200 by a buggy
        proxy) and JSON decode failures both terminate pagination
        cleanly with a logged warning instead of raising past the
        plan-build boundary. A hard cap of ``MAX_GUILD_MEMBER_PAGES``
        iterations protects against an upstream that ignores
        ``limit``.
        """
        cursor = after
        for _ in range(MAX_GUILD_MEMBER_PAGES):
            params: QueryParams = {"limit": limit}
            if cursor is not None:
                params["after"] = str(cursor)
            response = self._api_request(
                "get",
                f"guilds/{int(guild_id)}/members",
                params=params,
            )
            page = _decode_member_page(response, cursor=cursor)
            if page is None:
                return
            yield page
            if len(page) < limit:
                return
            next_cursor = extract_member_user_id(page[-1])
            if next_cursor is None:
                return
            cursor = next_cursor
        # Falling off the loop after ``MAX_GUILD_MEMBER_PAGES`` is the
        # silent-truncation path: the generator returns cleanly so
        # ``build_plan``'s ``members_scanned`` reflects what was
        # actually fetched, but callers SHOULD treat this as an
        # incomplete enumeration. The README documents 5k-50k as the
        # design range; reaching 1M (1000 pages x 1000 members) implies
        # either a Discord upstream regression ignoring ``limit`` or
        # a guild far outside design scope.
        logger.error(
            "guild_members: hit max_pages=%d safety cap after "
            "fetching ~%d members; the audit pass will run against "
            "the truncated enumeration. Either Discord is ignoring "
            "the ``limit`` parameter or this guild is outside the "
            "design range — verify the source of truth.",
            MAX_GUILD_MEMBER_PAGES,
            MAX_GUILD_MEMBER_PAGES * PAGE_SIZE,
        )


def _decode_member_page(
    response: requests.Response, *, cursor: int | None
) -> list[GuildMemberDict] | None:
    """
    Decode one ``guild_members`` page; return ``None`` to terminate.

    Centralises the three defensive shape checks ``guild_members``
    used to do inline (empty body / non-JSON / non-list); each branch
    logs at WARNING with the cursor so an operator can correlate the
    terminating page in the request log.
    """
    if not response.content:
        return None
    try:
        decoded = response.json()
    except (ValueError, requests.exceptions.JSONDecodeError):
        logger.warning(
            "guild_members: non-JSON body from Discord; "
            "terminating pagination after cursor=%s",
            cursor,
        )
        return None
    if not isinstance(decoded, list):
        logger.warning(
            "guild_members: expected list, got %s; "
            "terminating pagination after cursor=%s",
            type(decoded).__name__,
            cursor,
        )
        return None
    return decoded


def make_audited_client() -> AuditedDiscordClient:
    """Build an ``AuditedDiscordClient`` via core's ``create_bot_client``."""
    # Centralises the three-place pattern used by the management
    # command (start + resume) and the Celery task: fetch the upstream
    # ``DiscordClient``, then wrap its access token + rate-limit
    # snapshot in our audit-aware subclass. Lazy import on
    # ``create_bot_client`` because AA core imports it through
    # Django's app registry, which may not be ready at module load.
    from allianceauth.services.modules.discord.core import create_bot_client

    bot = create_bot_client()
    return AuditedDiscordClient(
        access_token=bot.access_token,
        is_rate_limited=bot.is_rate_limited,
    )
