"""
Webhook-URL redaction helpers used across log records, exception
chains, and persisted error strings.

The audit treats the operator's Discord webhook URL as a credential:
the URL contains a per-channel token in its path component
(``/api/webhooks/{channel_id}/{TOKEN}``) which is sufficient to post
arbitrary messages to that channel. Any place that captures
``str(exc)`` from a ``requests`` call — webhook (``notifications``)
or audit Discord-API (``audit/_finding``, ``audit/_apply``,
``tasks``) — must run the resulting string through ``redact_str``
before persisting or surfacing it. The webhook-URL path can leak via
nested exceptions (``MaxRetryError`` wraps a request URL in its
``__str__``), proxy/CDN exception messages that embed the upstream
URL, or ``RequestException.response.url`` references.

The audit Discord-API URL does not contain the bot token (Discord's
bot auth lives in the ``Authorization`` header), so the only
secret-bearing URL substring this module guards is the webhook one.
The same regex covers staging mirrors (canary, ptb).
"""

from __future__ import annotations

import contextlib
import re
from typing import TYPE_CHECKING, Final

if TYPE_CHECKING:  # pragma: no cover
    import logging

# Match Discord webhook URLs for any subdomain (canary, ptb, staging
# mirrors). Documented best-effort — we cannot mask URLs that have
# already been embedded into opaque binary fields by third-party
# loggers that serialise via ``repr`` or custom encoders.
#
# Char class excludes whitespace AND common embedding delimiters
# (``"'<>``) so the redaction does not eat the trailing quote of a
# JSON value (``"webhook":"…/abc"`` would otherwise lose its closing
# ``"``, breaking downstream JSON parsing) or the closing ``>`` of
# an HTML / log-format ``<…>``-wrapped URL. The secret IS scrubbed
# either way; tightening the class is purely a cosmetic-correctness
# fix for log-aggregator pipelines.
#
# ``()`` / ``[]`` are deliberately NOT excluded. ``MASK`` ends in
# ``[REDACTED]`` so excluding ``[`` would break the idempotency
# invariant — a second pass over the already-masked text would find
# an empty token after ``/webhooks/`` and append another ``MASK``.
# Markdown / paren-wrapped log lines losing their closing delimiter
# is the acknowledged trade-off; the secret IS scrubbed in every
# case.
#
# Optional ``:443`` and ``re.IGNORECASE`` so the regex accepts every
# form the settings validator (``aa_discord_audit.app_settings``)
# accepts. urllib3 normally strips the default port, but
# ``MaxRetryError`` / ``HTTPSConnectionPool`` messages can preserve
# it verbatim, and mixed-case hosts (``Discord.com``) occasionally
# surface in wrapper-exception strings.
#
# Each ``/`` in the URL path tolerates an optional leading backslash
# so JSON-escaped forms (``https:\/\/discord.com\/api\/webhooks\/1
# \/TOKEN``) emitted by structured-log shippers and JS/Java loggers
# still match. Without this the secret survives verbatim in any
# record that round-trips through a JSON-aware pipeline before
# reaching the redactor.
_SLASH = r"(?:\\?/)"
WEBHOOK_URL_RE: Final[re.Pattern[str]] = re.compile(
    rf"https:{_SLASH}{_SLASH}(?:[a-z]+\.)?(?:discord|discordapp)\.com"
    rf"(?::443)?{_SLASH}api{_SLASH}webhooks{_SLASH}[^\s\"'<>]*",
    re.IGNORECASE,
)

# Replacement form chosen to remain a syntactically valid (if dead)
# webhook URL so log post-processors that key off the URL pattern
# still see a record.
MASK: Final[str] = "https://discord.com/api/webhooks/[REDACTED]"


def redact_str(text: str) -> str:
    """Return ``text`` with every embedded webhook URL replaced by ``MASK``."""
    return WEBHOOK_URL_RE.sub(MASK, text)


def scrub_exception_args(
    args: tuple[object, ...], replacement: str = MASK
) -> tuple[object, ...]:
    """
    Return a copy of ``args`` with webhook URLs in string members masked.

    Shallow by design: nested objects (``RuntimeError({"url": ...})``,
    custom exception types holding the URL on a non-arg attribute) are
    NOT recursed. The ``__cause__`` / ``__context__`` walk in
    ``scrub_exception`` covers the common nested-exception case; the
    rare structural-arg case is documented as best-effort.
    """
    return tuple(
        WEBHOOK_URL_RE.sub(replacement, a) if isinstance(a, str) else a
        for a in args
    )


def _scrub_one(exc: BaseException) -> None:
    """Mask the webhook URL on a single exception (no chain walk)."""
    response = getattr(exc, "response", None)
    if response is not None:
        with contextlib.suppress(Exception):
            response.url = MASK
            if response.request is not None:
                response.request.url = MASK
    with contextlib.suppress(Exception):
        exc.args = scrub_exception_args(exc.args)


def scrub_exception(exc: BaseException) -> None:
    """
    Mask any webhook URL embedded in ``exc`` and its cause / context chain.

    In-place mutation by design: we want subsequent ``str(exc)`` calls
    (including those done by Django's ORM error path or upstream
    logging frameworks) to see the masked form too. The ``__cause__``
    / ``__context__`` walk is the load-bearing piece for ``requests``-
    style nested exceptions — ``MaxRetryError(reason=ConnectionError(
    url=...))`` would otherwise leak the URL via "Caused by:" in the
    formatted traceback even after the top-level exception is scrubbed.

    ``contextlib.suppress`` keeps the chain walk best-effort —
    pathological ``__setattr__`` overrides on third-party exception
    types must not break the apply path. ``visited`` guards against
    self-referential cycles (rare but possible if a wrapper exception
    sets ``__cause__`` to itself).
    """
    visited: set[int] = set()
    current: BaseException | None = exc
    while current is not None and id(current) not in visited:
        visited.add(id(current))
        _scrub_one(current)
        # ``__cause__`` is set by ``raise X from Y``; ``__context__``
        # by implicit chaining (``raise`` inside ``except``). Both
        # surface in ``traceback.format_exception``'s output.
        current = current.__cause__ or current.__context__


def log_redacted_exception(
    logger: logging.Logger,
    msg: str,
    *args: object,
    exc: BaseException,
) -> None:
    """
    Scrub ``exc`` then emit an ``ERROR``-level log line with traceback.

    Combines the audit's standard "scrub before log" security
    invariant (the webhook URL must never appear in formatted
    tracebacks) with the ``logger.exception``-style emission. The
    callable couples the two so a future caller cannot accidentally
    log the exception before scrubbing it — the rule is enforced by
    the helper's API, not by per-site discipline.

    Emission shape: ``logger.error(msg, *args, exc_info=exc)``. The
    level is ``ERROR`` (same as ``logger.exception(...)`` inside an
    ``except`` block, but explicit so a static reader does not need
    to infer it). ``exc_info=exc`` (the exception instance, not
    ``True``) survives a refactor that exits the surrounding
    ``except`` block before logging — ``logger.exception``'s implicit
    ``sys.exc_info()`` would silently miss the exception in that
    case. Sentry / structlog-style aggregators that key off
    ``record.exc_info`` see an ``(type, value, traceback)`` tuple
    materialised from ``exc`` and classify the record identically to
    a ``logger.exception(...)`` call.

    ``scrub_exception`` is idempotent; passing an already-scrubbed
    exception is a no-op.
    """
    scrub_exception(exc)
    logger.error(msg, *args, exc_info=exc)


def redact_exception_message(exc: BaseException) -> str:
    """
    Return ``str(exc)`` with embedded webhook URLs masked.

    Persist-side counterpart to ``log_redacted_exception``. Centralises
    the ``redact_str(str(exc))`` pattern used by every site that writes
    an exception message into a DB text field (``AuditFinding`` /
    ``AuditRun.error_message``, ``PendingKick.last_error``). The
    callable couples the ``str()`` cast and ``redact_str`` so a future
    caller cannot accidentally persist an unscrubbed exception message
    — the rule is enforced by the helper's API, not by per-site
    discipline.
    """
    return redact_str(str(exc))
