"""
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 time
from typing import TYPE_CHECKING, Literal, TypeAlias

from ._log import get_logger
from ._redaction import (
    MASK as _WEBHOOK_MASK,
)
from ._redaction import (
    WEBHOOK_URL_RE as _WEBHOOK_URL_RE,
)
from ._redaction import (
    log_redacted_exception,
)
from ._redaction import (
    scrub_exception_args as _scrub_args,
)
from ._webhook_transport import _attempt_post
from .app_settings import (
    AA_DISCORD_AUDIT_NOTIFY_ADMINS,
    AA_DISCORD_AUDIT_WEBHOOK_MAX_RETRIES,
    AA_DISCORD_AUDIT_WEBHOOK_URL,
)
from .models import AuditRunState
from .permissions import VIEW_AUDITRUN, user_has_perm_q
from .persistence import (
    SUMMARY_KEY_NOTIFIED_ADMINS,
    SUMMARY_KEY_WEBHOOK_POSTED,
    get_summary_marker,
    stamp_summary_marker,
)

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

    from .models import AuditRun


NotifyLevel = Literal["info", "warning", "error"]
PostWebhookResult: TypeAlias = Literal["sent", "skipped", "failed"]

logger = get_logger(__name__)


@contextlib.contextmanager
def _with_redacted_loggers() -> Generator[None, None, None]:
    """Install ``RedactWebhookFilter`` on the four leakage loggers."""
    # 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), ``requests.packages.urllib3``
    # (older bundled vendor path still emitted by some installations),
    # 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`` only catches the live exception,
    # not its recorded copy.
    #
    # ``ExitStack`` pairs each ``addFilter`` with a ``removeFilter``
    # callback so a partial-install failure (any ``addFilter`` raising)
    # still rolls back filters already installed.
    redactor = RedactWebhookFilter()
    redacted_loggers = (
        logging.getLogger("urllib3"),
        logging.getLogger("requests"),
        logging.getLogger("requests.packages.urllib3"),
        logger,
    )
    with contextlib.ExitStack() as stack:
        for redacted in redacted_loggers:
            redacted.addFilter(redactor)
            stack.callback(redacted.removeFilter, redactor)
        yield


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 _already_marked(run: AuditRun, key: str, *, op_label: str) -> bool:
    """
    Return ``True`` if ``run.summary[key]`` is set, logging a skip line.

    Centralises the read-side idempotency check shared by
    ``notify_admins`` and ``post_webhook``: both fan-out paths log a
    consistent ``"<op_label>: run #<pk> already marked at <stamp>;
    skipping"`` when the marker is already present so operators can grep
    one shape (``"already marked at"``) across the two channels.
    """
    stamp = get_summary_marker(run, key)
    if stamp is None:
        return False
    logger.info(
        "%s: run #%s already marked at %s; skipping",
        op_label,
        run.pk,
        stamp,
    )
    return True


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 at
    ``run.summary[SUMMARY_KEY_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.
    if _already_marked(
        run, SUMMARY_KEY_NOTIFIED_ADMINS, op_label="notify_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

    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

    # ``active_only=True`` excludes deactivated accounts from the
    # fan-out. AA-notify writes to a per-user inbox table; without the
    # gate, a deactivated insider would still receive (and on later
    # reactivation read) audit-run summaries — and any future
    # email-backed notify-backend would deliver the message
    # externally regardless of the account's active flag.
    #
    # Materialise to a list BEFORE entering the fan-out loop: a DB
    # connection drop mid-iteration would otherwise raise inside the
    # ``for`` loop AFTER some users were notified but BEFORE
    # ``stamp_summary_marker`` records the "notified" mark — leaving
    # the run un-stampted and the next worker would re-notify the
    # already-delivered subset. With pre-materialisation, a DB issue
    # surfaces before any user is notified and the stamp is never
    # written; the next attempt retries cleanly. Permission-holder
    # counts are typically O(tens), so the memory cost is bounded.
    holders = list(
        User.objects.filter(user_has_perm_q(perm, active_only=True)).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 as exc:  # noqa: PERF203, BLE001 -- 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. BLE001: AA's ``notify`` wrapper, email backends, and template renderers raise unrelated classes; the redacted log line is the only forensic surface and must catch any of them.
            # ``log_redacted_exception`` enforces the scrub-before-log
            # invariant at the API level so this site cannot drift away
            # from the discipline if a future AA backend ever surfaces
            # the webhook URL via a shared ``requests.Session``
            # traceback chain.
            log_redacted_exception(
                logger,
                "notify_admins: notification to user.pk=%s failed",
                user.pk,
                exc=exc,
            )

    # 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.
    if not stamp_summary_marker(run, SUMMARY_KEY_NOTIFIED_ADMINS):
        # Pre-check (``_already_marked``) passed but the stamper lock
        # found a concurrent winner — recipients of this fan-out got
        # AA-notify *plus* whatever the other worker already sent. The
        # operator-visible duplicate is benign but worth surfacing so
        # forensic queries on "duplicate notify" hits a logger line and
        # not an inferred-by-counts puzzle.
        logger.warning(
            "notify_admins: lost stamp race for run #%s after fan-out; "
            "concurrent worker also delivered notifications (duplicate "
            "AA-notify may be visible to recipients)",
            run.pk,
        )
    return count


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 post_webhook(run: AuditRun) -> PostWebhookResult:
    """
    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"

    if _already_marked(
        run, SUMMARY_KEY_WEBHOOK_POSTED, op_label="post_webhook"
    ):
        return "skipped"

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

    with _with_redacted_loggers():
        embed = _build_run_embed(run)
        # ``AA_DISCORD_AUDIT_WEBHOOK_MAX_RETRIES`` is validated with
        # ``min_value=0`` in ``app_settings.py`` so ``+ 1`` is already
        # guaranteed to be ≥ 1; no defensive ``max(1, ...)`` needed.
        attempts_budget = AA_DISCORD_AUDIT_WEBHOOK_MAX_RETRIES + 1
        for attempt in range(1, attempts_budget + 1):
            outcome = _attempt_post(
                url,
                embed,
                attempt=attempt,
                attempts_budget=attempts_budget,
            )
            if outcome.kind == "sent":
                # 2xx — stamp the idempotency marker so a retry by
                # the caller (Celery retry, manual re-run) is a no-op.
                if not stamp_summary_marker(run, SUMMARY_KEY_WEBHOOK_POSTED):
                    # Pre-check (``_already_marked``) passed but the
                    # stamper lock found a concurrent winner — Discord
                    # received the webhook twice. The duplicate embed
                    # is benign (operators expect at-least-once for the
                    # summary channel) but worth surfacing.
                    logger.warning(
                        "post_webhook: lost stamp race for run #%s "
                        "after HTTP 2xx; concurrent worker also "
                        "posted the embed (duplicate Discord message "
                        "may be visible)",
                        run.pk,
                    )
                return "sent"
            if outcome.kind == "failed":
                return "failed"
            # outcome.kind == "retry"
            time.sleep(outcome.sleep_for)
    # The loop returns from every "sent" / "failed" branch and only
    # ``sleep`` + iterate on "retry"; ``_attempt_post`` returns "retry"
    # only when ``attempt < attempts_budget``, so on the final iteration
    # it falls through to "sent" / "failed". Reaching here means
    # ``_attempt_post`` violated that contract — guard with an explicit
    # raise rather than a falsy ``return "failed"`` that hides the
    # broken invariant from operator-visible logs.
    msg = (
        "post_webhook: _attempt_post returned 'retry' on attempt "
        "N=attempts_budget — _attempt_post's contract guarantees it "
        "returns 'sent' or 'failed' on the final iteration, never "
        "'retry'. This indicates a bug in _attempt_post itself "
        "(transport-layer invariant violated); inspect "
        "_webhook_transport.attempt_post for the failing exit path."
    )
    raise AssertionError(msg)


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 — the model pulls in Django's app registry, and this
    # module is imported from CLI / Celery / signal paths that may
    # touch ``notifications`` before the registry is ready.
    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:  # noqa: BLE001 — notification fan-out is observability and must not raise back to the audit caller; any AA / requests / DB exception is scrubbed and logged through the fallback path.
        # ``RedactWebhookFilter`` is installed only inside
        # ``post_webhook``'s own scope; an exception that escapes past
        # it reaches this outer logger without a filter installed.
        # ``log_redacted_exception`` couples the scrub + log 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.
        log_redacted_exception(
            logger,
            "notification fan-out failed (%s) run=%s",
            source,
            run_id,
            exc=exc,
        )
