"""
ContextVar-based actor capture for ``ConfigChangeLog`` signal handlers.

Django's request/response cycle does not naturally pass
``request.user`` into model signals. The audit's
``ConfigChangeLog`` signal needs the actor to attribute admin /
shell / management-command edits to a real user — without that
attribution, the audit trail records every change as ``<system>``
and the threat model's "audit-the-auditor" guarantee collapses.

This module provides three integration points:

- ``CurrentUserMiddleware``: Django middleware that stores
  ``request.user`` in a ``ContextVar`` for the duration of the
  request. Operators MUST register this in ``settings.MIDDLEWARE``;
  ``apps.ready()`` validates the registration and refuses to start
  if it is missing.
- ``set_command_user(user_or_username)``: explicit setter for code
  paths that have no HTTP request (Celery tasks, management
  commands, shell sessions). Either pass an authenticated ``User``
  instance or just a username string.
- ``get_current_user()``: signal-side reader that returns the
  current actor or ``None`` if no context was set.

When ``get_current_user()`` returns ``None`` the signal handler
falls back to ``actor_username='<system>'`` and enriches
``payload['_origin']`` with whatever process-level signal it can
find (CELERY_TASK_ID, ``sys.argv[0]``, shell sentinel) so a
``<system>`` row remains traceable post-mortem.

Implementation note: a ``ContextVar`` (PEP 567) — not
``threading.local`` — is the load-bearing primitive here. Django's
sync→async adapter (``asgiref.sync.sync_to_async``) hops thread
pools per call, and a ``threading.local`` set in the middleware's
sync-callable thread is invisible to a signal that fires inside a
different thread later in the request. ``ContextVar`` is copied
into every async / thread-pool context Django spins up, so the
attribution survives the boundary.
"""

from collections.abc import Awaitable, Callable, Generator
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from typing import ClassVar, TypeAlias, cast

from asgiref.sync import iscoroutinefunction, markcoroutinefunction
from django.contrib.auth.models import AbstractUser, AnonymousUser
from django.http import HttpRequest, HttpResponse

# Union of types that can flow into the ``ContextVar``: an
# authenticated Django user (concrete subclass of
# ``AbstractUser``), an ``AnonymousUser`` (which
# ``get_current_user`` filters out), our ``_UnresolvedUsername``
# sentinel for shell-set strings that didn't match a real user, or
# ``None`` when the middleware reset / set_command_user(None) ran.
UserLike: TypeAlias = (
    "AbstractUser | AnonymousUser | _UnresolvedUsername | None"
)
ActorLike: TypeAlias = "AbstractUser | _UnresolvedUsername | None"

_state: ContextVar[UserLike] = ContextVar(
    "aa_discord_audit_actor", default=None
)
# Distinct ContextVar for the origin tag so a non-HTTP code path
# that called ``set_command_user`` is not mis-labelled ``"http"`` in
# ConfigChangeLog rows. The signal handler reads this directly and
# uses ``_origin_payload()`` only as a fallback when no explicit
# origin was set.
_origin_state: ContextVar["str | None"] = ContextVar(
    "aa_discord_audit_origin", default=None
)


def get_current_user() -> ActorLike:
    """
    Return the actor associated with the current request / task.

    Returns ``None`` when no context has been set (Celery tasks,
    migrations, shell sessions, fixture loaders). Anonymous users
    also return ``None`` because the audit's tracked-model writes
    require authentication; an anonymous write reaching this
    helper indicates a misconfiguration and is logged so SOC-style
    monitoring can pick it up rather than being silently merged
    with the ``<system>`` fallback.
    """
    user = _state.get()
    if user is None:
        return None
    if isinstance(user, AnonymousUser) or not getattr(
        user, "is_authenticated", False
    ):
        # Lazy import: logging at module top would import at app-
        # load time before logging is configured.
        import logging

        logging.getLogger(f"extensions.{__name__}").warning(
            "Anonymous user reached get_current_user; falling back "
            "to <system>. Investigate the request path that bypassed "
            "auth (csrf_exempt admin view, public mutating endpoint?)."
        )
        return None
    return user


def set_command_user(
    user_or_username: AbstractUser | str | None,
    *,
    origin: str | None = None,
) -> None:
    """
    Manually populate the actor for non-HTTP code paths.

    Accepts either an authenticated ``User`` instance (preferred)
    or a username string (used by ad-hoc shell sessions where the
    operator wants their own attribution). For string input the
    helper attempts to resolve the username to a real ``User``;
    if no match exists the value is recorded as the username
    sentinel only and ``actor_user_id`` will be ``0``.

    ``origin`` lets the caller explicitly tag the runtime origin
    so the signal handler does not have to guess or default to
    ``"http"`` for any present user. Pass ``"management_command"``
    from a CLI command, ``"celery"`` from a task, etc. ``None``
    leaves the origin unset and the signal falls back to its
    process-level inference (``CELERY_TASK_ID`` env, ``sys.argv``).
    """
    if user_or_username is None:
        _state.set(None)
        _origin_state.set(None)
        return
    if origin is not None:
        _origin_state.set(origin)
    if not isinstance(user_or_username, str):
        # AbstractUser instance — store directly.
        _state.set(user_or_username)
        return
    # String input: try to resolve to a User row.
    from django.contrib.auth import get_user_model

    User = get_user_model()  # noqa: N806 — Django convention
    # Catch MultipleObjectsReturned alongside DoesNotExist: a custom
    # User model with a non-unique username (or a case-insensitive
    # collation hitting two rows) would otherwise propagate the
    # exception out of the management-command startup path and
    # crash the run with no actor recorded. Treat both as
    # "unresolved" so the command can still proceed under the
    # ``_UnresolvedUsername`` sentinel.
    try:
        resolved = cast(
            "AbstractUser", User.objects.get(username=user_or_username)
        )
        _state.set(resolved)
    except (User.DoesNotExist, User.MultipleObjectsReturned):
        _state.set(_UnresolvedUsername(user_or_username))


def get_current_origin() -> str | None:
    """Return the explicit origin tag set by middleware / set_command_user."""
    return _origin_state.get()


@dataclass(frozen=True, slots=True)
class _UnresolvedUsername:
    """Sentinel returned by ``get_current_user`` for unresolved usernames."""

    username: str
    is_authenticated: ClassVar[bool] = True
    pk: ClassVar[int] = 0


# Type aliases for the middleware's sync / async response callables.
SyncResponse: TypeAlias = Callable[[HttpRequest], HttpResponse]
AsyncResponse: TypeAlias = Callable[[HttpRequest], Awaitable[HttpResponse]]


class CurrentUserMiddleware:
    """
    Store ``request.user`` in a ``ContextVar`` for the request lifetime.

    Required Django middleware; ``apps.ready()`` raises
    ``ImproperlyConfigured`` if this class is not in
    ``settings.MIDDLEWARE``.

    Declares ``sync_capable`` and ``async_capable`` so Django adapts
    the chain at runtime: under ASGI the ``__acall__`` branch runs;
    under WSGI the ``__call__`` branch runs. Both branches use
    ``ContextVar.set`` / ``ContextVar.reset`` symmetrically so the
    attribution survives ``sync_to_async`` thread-pool hops.
    """

    sync_capable: ClassVar[bool] = True
    async_capable: ClassVar[bool] = True

    def __init__(self, get_response: SyncResponse | AsyncResponse) -> None:
        """Capture the next-handler callable for the middleware chain."""
        self.get_response = get_response
        if iscoroutinefunction(get_response):  # pyright: ignore[reportDeprecated]
            # Django's middleware adaptor inspects this attribute to
            # decide whether to await the call. Setting it explicitly
            # avoids a sync_to_async wrapper around our ContextVar
            # plumbing.
            markcoroutinefunction(self)

    def __call__(self, request: HttpRequest) -> HttpResponse:
        """Sync path: set ContextVar around the wrapped get_response call."""
        with _bind_request_actor(request):
            return cast("SyncResponse", self.get_response)(request)

    async def __acall__(self, request: HttpRequest) -> HttpResponse:
        """Async path: identical contract using ContextVar across awaits."""
        with _bind_request_actor(request):
            return await cast("AsyncResponse", self.get_response)(request)


@contextmanager
def _bind_request_actor(request: HttpRequest) -> Generator[None, None, None]:
    """Set request actor + origin ContextVars; reset on exit."""
    # Single source of truth for the sync / async middleware paths;
    # both delegate here so a future ContextVar (request id, etc.)
    # only needs adding once.
    token = _state.set(getattr(request, "user", None))
    origin_token = _origin_state.set("http")
    try:
        yield
    finally:
        _state.reset(token)
        _origin_state.reset(origin_token)
