"""
Signal receivers for the Discord audit module.

Two signal flows live here:

1. ``record_historical_identity``: ``post_delete`` on
   ``discord.DiscordUser`` writes a ``DiscordIdentity`` row with
   ``source=HISTORICAL`` so the audit retains the uid → user
   mapping after the active service contract is gone. Defers via
   ``transaction.on_commit`` to avoid same-transaction visibility
   quirks under the cascade-from-User scenario.

2. ``ConfigChangeLog`` signal handlers: ``post_save`` and
   ``post_delete`` on ``ManagedRoleException``,
   ``ProtectedDiscordMember``, ``BotAccountUid`` write change-log
   rows attributed to the current actor. Actor capture goes
   through ``current_user.get_current_user()``; non-HTTP code
   paths fall back to ``<system>`` enriched with origin context.
"""

from __future__ import annotations

import json
import os
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Final, Literal, TypedDict, cast

from django.contrib.auth import get_user_model
from django.db import models, transaction
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver

from ._log import get_logger
from ._redaction import redact_str
from .current_user import get_current_origin, get_current_user
from .models import (
    BotAccountUid,
    ConfigAction,
    ConfigChangeLog,
    DiscordIdentity,
    FindingActionOverride,
    IdentitySource,
    ManagedRoleException,
    ProtectedDiscordMember,
)
from .permissions import APP_LABEL

if TYPE_CHECKING:  # pragma: no cover
    from collections.abc import Callable, Iterable

    from allianceauth.services.modules.discord.models import DiscordUser


# Origin payload shape used by the ConfigChangeLog signal handler.
# Every key is optional because the actor-attribution path picks one
# at runtime (HTTP request, Celery task, management command, shell,
# or fallback ``system`` sentinel).
#
# ``OriginLiteral`` + ``_ORIGIN_LITERALS`` share a single source of
# truth for the four legal origin tags: the TypedDict annotation, the
# runtime membership check in ``_write_change_log``, and the cast that
# narrows the explicit-origin string to the literal. Extending the set
# is one edit instead of three drifting copies.
OriginLiteral = Literal["http", "celery", "management_command", "system"]
_ORIGIN_LITERALS: Final[frozenset[str]] = frozenset(
    {"http", "celery", "management_command", "system"}
)


class OriginPayload(TypedDict, total=False):
    """Origin context fields recorded on ``ConfigChangeLog.payload``."""

    _origin: OriginLiteral
    _celery_task_id: str
    _argv0: str
    fields_changed: list[str]
    snapshot: dict[str, object]


SignalAction = ConfigAction

logger = get_logger(__name__)

User = get_user_model()


# --- DiscordUser cascade signal ---


@receiver(
    post_delete,
    sender="discord.DiscordUser",
    dispatch_uid=f"{APP_LABEL}.historical",
)
def record_historical_identity(
    sender: type[DiscordUser], instance: DiscordUser, **kwargs: object
) -> None:
    """
    Write a HISTORICAL DiscordIdentity row when a DiscordUser is deleted.

    Defers the write to ``transaction.on_commit`` so:

    - A rolled-back delete does not leave a ghost row.
    - A same-transaction cascade-from-User (User deletion -> DiscordUser
      cascade -> this signal) sees the parent User as actually gone by
      the time the on-commit hook fires; the existence check rejects
      the write.
    """
    # Load-bearing for the symmetric unique constraint
    # ``uniq_uid_user`` on DiscordIdentity (declared on
    # ``(uid, user)`` without a partial condition). All major DB
    # engines treat NULL as distinct in unique indexes, so an
    # ``update_or_create(uid=…, user_id=None)`` below would silently
    # produce duplicate gravestone rows even though the constraint
    # is symmetric. The short-circuit here keeps user_id non-NULL
    # on every write path into DiscordIdentity from this signal,
    # so duplicate-prevention rests on the constraint and this
    # filter together — neither alone is sufficient.
    if (user_id := instance.user_id) is None:
        return
    uid = instance.uid
    # Capture mutable Discord-side metadata before instance goes out of
    # scope. AA core's DiscordUser stores ``username`` but not the
    # post-2023 ``global_name``; ``display_name_snapshot`` stays blank
    # here and is meant to be filled by audit-time enrichment.
    username_snapshot = getattr(instance, "username", "") or ""

    def _write() -> None:
        if not User.objects.filter(pk=user_id).exists():
            return
        # ``get_or_create`` not ``update_or_create``: the source-of-
        # truth precedence is OPERATOR > HISTORICAL. If an operator
        # has already manually attributed this (uid, user) pair via
        # the admin (source=OPERATOR), the cascade from a
        # later-deleted DiscordUser must NOT downgrade the row to
        # HISTORICAL — that would erase the curator's intent. The
        # ``defaults`` block is consulted only on creation, so an
        # OPERATOR row stays OPERATOR while a missing row gets
        # populated as HISTORICAL.
        #
        # The ``exists()`` + ``get_or_create()`` pair is NOT atomic
        # against each other: a User created between the two calls
        # would still produce a HISTORICAL row. Duplicate-prevention
        # rests on ``DiscordIdentity``'s unique constraint on
        # ``(uid, user_id)``, not on the existence check — the latter
        # is just an early-exit for the common case (User already
        # deleted, no point in even attempting the write).
        DiscordIdentity.objects.get_or_create(
            uid=uid,
            user_id=user_id,
            defaults={
                "source": IdentitySource.HISTORICAL,
                "username_snapshot": username_snapshot,
            },
        )

    transaction.on_commit(_write)


# --- ConfigChangeLog signal handlers ---

_TRACKED_MODELS: Final[tuple[type[models.Model], ...]] = (
    ManagedRoleException,
    ProtectedDiscordMember,
    BotAccountUid,
    FindingActionOverride,
)
_TIMESTAMP_FIELDS: Final[frozenset[str]] = frozenset(
    {"created_at", "added_at", "acknowledged_at"}
)


def _current_celery_task_id() -> str | None:
    """
    Return the active Celery task id, or ``None`` outside a worker.

    Prefers ``celery.current_task.request.id`` over a raw env-var read
    because the proxy is set by the Celery worker on task dispatch and
    cannot be spoofed by the caller's environment. The
    ``CELERY_TASK_ID`` env var is not exported by any stock Celery
    invocation path; ``_origin_payload`` still falls back to it as a
    narrow override for custom worker setups, so the spoof vector is
    narrowed here rather than eliminated at the module level.

    Outside a worker the ``LocalProxy`` either has no bound task
    (``request.id is None``) or raises; both are coerced to ``None``.
    """
    try:
        from celery import current_task
    except ImportError:
        return None
    try:
        task_id = current_task.request.id
    except (AttributeError, RuntimeError):
        return None
    return task_id if isinstance(task_id, str) and task_id else None


def _origin_payload() -> OriginPayload:
    """Capture origin context for non-HTTP code paths."""
    # Fallback only — middleware / set_command_user already tag the
    # authoritative origin via ``_origin_state`` whenever they know
    # it. The previous ``sys.stdin.isatty()`` branch produced a
    # ``"shell"`` tag whose meaning was unreliable: pytest's stdin
    # capture and Celery / gunicorn / systemd stdin redirection all
    # collapse the check to False, so the tag flagged neither real
    # shells nor the absence of one. Folding it into ``"system"``
    # tells operators "no caller stamped this; we don't know".
    #
    # Prefer ``celery.current_task`` over the ``CELERY_TASK_ID`` env
    # var; the env-var path stays as a narrow override for custom
    # worker setups that surface the id via env (the proxy read is
    # always tried first so a spoofed env var cannot win when an
    # actual task is running).
    task_id = _current_celery_task_id() or os.environ.get("CELERY_TASK_ID")
    if task_id:
        return {"_origin": "celery", "_celery_task_id": task_id}
    if sys.argv and sys.argv[0].endswith("manage.py"):
        # ``basename`` only — the full argv[0] path reveals the
        # operator's filesystem layout (``/home/<user>/...``) to any
        # holder of ``view_configchangelog`` without security benefit;
        # the entrypoint name is the load-bearing forensic signal.
        # Read per-call (not module-cached) so test harnesses that
        # mutate ``sys.argv`` see the live value; the per-write cost
        # is one ``Path(...).name`` call and not on any hot path.
        return {
            "_origin": "management_command",
            "_argv0": Path(sys.argv[0]).name,
        }
    return {"_origin": "system"}


def _redact_snapshot_value(value: object) -> object:
    """
    Recursively mask webhook URLs in a snapshot value tree.

    Tracked-model free-text fields (``reason``, ``notes``) accept any
    operator-pasted content, including incident notes that quote a
    webhook URL. Without this pass the URL would land verbatim in
    ``ConfigChangeLog.payload`` and surface to any holder of
    ``view_configchangelog`` — a perm that is intentionally weaker
    than the ``manage_*`` perms gating webhook configuration.
    Recursion covers JSONField values (``list`` / ``dict`` / ``tuple``)
    so a future field that nests strings stays covered.
    """
    if isinstance(value, str):
        return redact_str(value)
    if isinstance(value, list):
        return [_redact_snapshot_value(v) for v in value]
    if isinstance(value, tuple):
        return tuple(_redact_snapshot_value(v) for v in value)
    if isinstance(value, dict):
        return {k: _redact_snapshot_value(v) for k, v in value.items()}
    return value


def _snapshot(instance: models.Model) -> dict[str, object]:
    """
    Pre-redaction model snapshot, excluding pk + auto-managed timestamps.

    ``json.dumps`` is used as a serialisability probe — if the field
    value round-trips through JSON, ``_redact_snapshot_value`` keeps
    its shape (incl. JSONField nesting); otherwise it falls through to
    ``str(value)`` + redaction. The four tracked models declare only
    ``TextField`` / ``CharField`` / ``PositiveBigIntegerField`` /
    ``DateTimeField`` field types, all of which stringify acceptably.
    A future tracked model adding a ``BinaryField`` would land here
    as a base64-less repr — explicit handling would be needed then.
    The probe is intentionally redundant with the redaction call: the
    JSON path is the load-bearing fast path; the TypeError fallback
    keeps the snapshot tolerant of shape drift without raising into
    the signal dispatcher.
    """
    out: dict[str, object] = {}
    for field in instance._meta.concrete_fields:
        name = field.attname
        if name == "id" or name in _TIMESTAMP_FIELDS:
            continue
        value = getattr(instance, name)
        try:
            json.dumps(value)
            out[name] = _redact_snapshot_value(value)
        except TypeError:
            out[name] = redact_str(str(value))
    return out


def _write_change_log(
    instance: models.Model,
    action: SignalAction,
    fields_changed: Iterable[str] | None = None,
) -> None:
    user = get_current_user()
    payload = _origin_payload()
    # Honour the explicit origin tag set by the HTTP middleware or
    # by ``set_command_user(..., origin=...)`` callers; fall back to
    # the process-level inference from ``_origin_payload()`` only
    # when nothing tagged the context. Previously every present
    # user was tagged ``"http"``, mis-labelling management-command
    # actors and any future Celery actor.
    explicit_origin = get_current_origin()
    if explicit_origin in _ORIGIN_LITERALS:
        # Runtime-validated against the TypedDict's literal set; cast
        # tells mypy what the membership check guaranteed.
        payload["_origin"] = cast("OriginLiteral", explicit_origin)
    actor_user_id: int | None
    if user is None:
        actor_user_id = None
        actor_username = "<system>"
        logger.warning(
            "ConfigChangeLog write without HTTP request context; actor "
            "recorded as <system>. model=%s pk=%s origin=%s",
            instance._meta.label,
            instance.pk,
            payload.get("_origin"),
        )
    else:
        # ``or None`` normalises the ``_UnresolvedUsername.pk = 0``
        # sentinel back to NULL on the FK column — pk=0 is the
        # documented "OS-resolved username with no Django user"
        # marker, but storing 0 in actor_user_id would point at a
        # bogus User row. Real Django Users have pk >= 1.
        pk = getattr(user, "pk", None)
        actor_user_id = pk or None
        actor_username = getattr(user, "username", "<unknown>")
    if fields_changed:
        payload["fields_changed"] = list(fields_changed)
    if action != ConfigAction.DELETE:
        payload["snapshot"] = _snapshot(instance)
    ConfigChangeLog.objects.create(
        actor_user_id=actor_user_id,
        actor_username=actor_username,
        model_name=instance._meta.label,
        object_pk=str(instance.pk),
        action=action,
        payload=payload,
    )


def _make_handlers(
    model_cls: type[models.Model],
) -> tuple[Callable[..., None], Callable[..., None]]:
    """Build ``(post_save, post_delete)`` handler pair for tracked model."""
    name = model_cls.__name__

    def on_save(
        sender: type[models.Model],
        instance: models.Model,
        created: bool,
        update_fields: Iterable[str] | None = None,
        **kwargs: object,
    ) -> None:
        _write_change_log(
            instance,
            ConfigAction.CREATE if created else ConfigAction.UPDATE,
            fields_changed=update_fields,
        )

    on_save.__name__ = f"_on_save_{name}"

    def on_delete(
        sender: type[models.Model],
        instance: models.Model,
        **kwargs: object,
    ) -> None:
        _write_change_log(instance, ConfigAction.DELETE)

    on_delete.__name__ = f"_on_delete_{name}"

    return on_save, on_delete


# Connect signals at import time. The module is imported from
# apps.ready(); receivers stay alive in a module-level dict so the
# weak-ref-friendly ``dispatch_uid`` keys keep the connections.
_HANDLERS: dict[str, Callable[..., None]] = {}

for _model in _TRACKED_MODELS:
    _save_handler, _delete_handler = _make_handlers(_model)
    for _signal, _op, _handler in (
        (post_save, "save", _save_handler),
        (post_delete, "delete", _delete_handler),
    ):
        _uid = f"{APP_LABEL}.cclog.{_model.__name__}.{_op}"
        _signal.connect(
            _handler,
            sender=_model,
            dispatch_uid=_uid,
            weak=False,
        )
        _HANDLERS[_uid] = _handler
