"""
Shared invocation accounting helpers.

Each ``audit_discord_roles`` subcommand has the same preamble:

1. resolve the OS-side actor to a Django user,
2. redact the argv before persisting it,
3. gate the ``run_audit`` permission (write a refusal trail when
   it fails),
4. either rate-limit the call or record an ``accepted=True``
   ``AuditInvocation`` row,
5. once a real ``AuditRun`` has been spawned, link the invocation
   to it so the trail survives an eventual prune.

The functions below are the building blocks. They are free
functions (no ``Command`` dependency) so each subcommand module
can compose only what it needs without dragging in the whole
class skeleton.
"""

from __future__ import annotations

import contextlib
import getpass
import sys
from typing import TYPE_CHECKING, cast

from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.management.base import CommandError

from ...._log import get_logger
from ....argv_redact import redact_argv
from ....current_user import set_command_user
from ....invocation import (
    check_run_perm,
    link_invocation,
    rate_limit_or_record,
    record_acceptance,
    record_refusal,
)
from ....models import (
    AuditInvocation,
    AuditRun,
    AuditRunState,
    InvocationRefusal,
    RunTrigger,
)
from ....permissions import RUN_AUDIT
from ....persistence import diagnose_lost_cas_message, transition_run_state

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

    from django.contrib.auth.models import AbstractUser

    from ..audit_discord_roles import Command

logger = get_logger(__name__)


def emit_lost_cas_diagnostic(
    cmd: Command,
    run: AuditRun,
    *,
    context: str,
    intended: str,
    source: str | None = None,
) -> None:
    """Emit the canonical 'lost the CAS race' diagnostic to stdout + logger."""
    # Single source of truth for the dual-channel race report the four
    # CAS sites (--resume x 3 terminal flips, --abandon) all used to
    # inline: stdout for the live operator + logger.warning for the
    # forensic aggregator. ``source`` keeps the bespoke
    # ``concurrent-flip-after-upfront-check`` wording the abandon path
    # surfaces alongside the default ``concurrent --abandon or
    # signal-flip`` reading every other site shares.
    kwargs: dict[str, str] = {"context": context, "intended": intended}
    if source is not None:
        kwargs["source"] = source
    message = diagnose_lost_cas_message(run, **kwargs)
    cmd.stdout.write(cmd.style.WARNING(message))
    logger.warning("%s", message)


def flip_or_emit(
    cmd: Command,
    run: AuditRun,
    *,
    new_state: AuditRunState,
    expected: AuditRunState | tuple[AuditRunState, ...],
    context: str,
    intended: str,
    error_message: str = "",
) -> bool:
    """Run a CAS terminal flip; emit the standard race diagnostic on loss."""
    # Pairs the ``transition_run_state`` CAS with the lost-race
    # diagnostic so the three ``--resume`` terminal flips
    # (BUDGET_EXCEEDED, FAILED, SUCCEEDED) share one helper instead
    # of three near-copies. Returns ``True`` when the CAS won —
    # callers continue with the post-flip work (observe metric,
    # success message); ``False`` signals the race already lost and
    # the caller bails (no metric emit, no success line).
    # ``error_message=""`` matches ``transition_run_state``'s default
    # so passing it through directly keeps the call type-checked.
    if transition_run_state(
        run,
        new_state,
        expected=expected,
        error_message=error_message,
    ):
        return True
    emit_lost_cas_diagnostic(cmd, run, context=context, intended=intended)
    return False


def safe_os_username() -> str | None:
    """Return ``getpass.getuser()`` or ``None`` on failure; never raises."""
    # Crippled containers without a passwd entry raise ``OSError``;
    # logging at warning level lets operators correlate ``<system>``-
    # attributed ConfigChangeLog rows with the root cause without
    # crashing the management-command path. Public (no underscore)
    # because it has a legitimate cross-module consumer in
    # ``audit_discord_roles.handle`` — the leading underscore was a
    # convention violation, not a private-API signal.
    try:
        return getpass.getuser()
    except OSError as exc:
        logger.warning("could not capture OS-side actor: %s", exc)
        return None


def _resolve_os_user() -> AbstractUser | None:
    """
    Best-effort: map the OS-side username to a Django ``User``.

    The management-command path has no HTTP request, so the caller
    relies on the matching OS-username convention. Returns ``None``
    when the lookup fails for any reason (no ``$USER``, DB error,
    missing migrations, etc.) and lets the ``run_audit`` perm check
    decide whether the unauthenticated path is allowed. The cast
    boundary is contained to this helper so call sites stay simple.
    """
    from django.contrib.auth import get_user_model

    username = safe_os_username()
    if username is None:
        return None
    User = get_user_model()
    try:
        return cast(
            "AbstractUser | None",
            User.objects.filter(username=username).first(),
        )
    except Exception:  # noqa: BLE001 — DB / migrations can fail in any number of ways; fall back to None.
        logger.warning(
            "OS-side actor resolution failed; falling back to None",
            exc_info=True,
        )
        return None


def resolve_cli_actor() -> AbstractUser | None:
    """
    Resolve the acting CLI user, honoring ``AA_DISCORD_AUDIT_CLI_ACTOR``.

    The single resolution point shared by BOTH the ``run_audit`` perm
    gate AND the ``ConfigChangeLog`` attribution path. Having one
    resolver ensures the gate and the audit trail always name the same
    actor — resolving separately in each path would let them diverge
    (e.g. gate checks user X while trail attributes OS-login Y). Returns
    a validated ``AbstractUser`` object (not a username string — passing
    a string to ``set_command_user`` would re-resolve independently
    without the ``is_active`` / perm checks and downgrade
    ``MultipleObjectsReturned`` to a sentinel).

    Resolution precedence:

    * ``AA_DISCORD_AUDIT_CLI_ACTOR`` set and non-empty -> resolve THAT
      username. The user must exist and be active (``is_active=True``);
      deactivation is the canonical access revocation, mirroring the
      ``active_only`` semantics in ``permissions.py``. A missing,
      inactive, or ambiguous (``MultipleObjectsReturned``) configured
      actor raises ``CommandError`` naming the setting — there is NO
      fallback to the OS login, because the setting must not *widen*
      the actor set.
    * setting unset / blank -> the existing OS-login resolution
      (``safe_os_username`` -> ``_resolve_os_user``); full back-compat,
      and the ``run_audit`` perm gate still decides whether the
      unauthenticated path is allowed.

    The setting is read PER CALL via ``getattr(settings, …)`` rather
    than a module-level constant so a late deactivation / perm-revoke
    is observed on the next invocation (the existing ``AA_DISCORD_AUDIT_*``
    constants are import-time cached, which would defeat revalidation).
    """
    configured = getattr(settings, "AA_DISCORD_AUDIT_CLI_ACTOR", None)
    if not isinstance(configured, str) or not configured.strip():
        # Unset, blank, or wrong type: existing OS-login behaviour.
        return _resolve_os_user()

    username = configured.strip()
    User = get_user_model()
    try:
        user = cast(
            "AbstractUser",
            User.objects.get(username=username, is_active=True),
        )
    except User.DoesNotExist as exc:
        raise CommandError(
            f"AA_DISCORD_AUDIT_CLI_ACTOR={username!r} does not match an "
            f"active Django user; cannot resolve the CLI actor. Fix the "
            f"setting or activate the account (a deactivated user is "
            f"denied — deactivation revokes access)."
        ) from exc
    except User.MultipleObjectsReturned as exc:
        # Diverge from ``set_command_user``'s sentinel downgrade: an
        # ambiguous username must DENY, never silently pick a row.
        raise CommandError(
            f"AA_DISCORD_AUDIT_CLI_ACTOR={username!r} is ambiguous "
            f"(matches multiple users); refusing to guess the CLI actor."
        ) from exc
    return user


@contextlib.contextmanager
def cli_actor_scope(
    actor: AbstractUser | None,
) -> Generator[None, None, None]:
    """
    Pin ``actor`` to the ``current_user`` ContextVar for the block.

    Sets the ``management_command`` origin on enter, clears the
    ContextVar on exit so a subsequent ``call_command`` (or process
    reuse under custom test harnesses) does not inherit a stale
    actor. ``actor=None`` short-circuits the set on enter but still
    clears on exit — callers that decide NOT to attribute (e.g. the
    ``call_command`` programmatic path the run command skips via
    ``_called_from_command_line``) pass ``None`` and get the same
    cleanup-on-exit guarantee for free.

    Decoupling "who to attribute" (the caller's policy) from "do not
    leak the ContextVar" (this helper's job) keeps the two duties
    from drifting — every CLI handler that touches the ContextVar
    goes through one cleanup path.
    """
    if actor is not None:
        set_command_user(actor, origin="management_command")
    try:
        yield
    finally:
        set_command_user(None)


def _get_run_or_raise(run_id: int) -> AuditRun:
    """Load an AuditRun by pk; raise CommandError on miss."""
    try:
        return AuditRun.objects.get(pk=run_id)
    except AuditRun.DoesNotExist as exc:
        raise CommandError(f"AuditRun id={run_id} does not exist") from exc


def _record_refusal(
    user: AbstractUser | None,
    argv: list[str],
    reason: InvocationRefusal,
) -> None:
    """Persist an ``accepted=False`` AuditInvocation row for the trail."""
    record_refusal(trigger=RunTrigger.CLI, user=user, argv=argv, reason=reason)


def _record_acceptance(
    user: AbstractUser | None,
    argv: list[str],
) -> AuditInvocation:
    """Create an ``accepted=True`` AuditInvocation row."""
    return record_acceptance(trigger=RunTrigger.CLI, user=user, argv=argv)


def _link_invocation(invocation: AuditInvocation, run_pk: int) -> None:
    """Snapshot the run pk on the invocation row (post-prune trail)."""
    link_invocation(invocation, run_pk=run_pk)


def _require_run_perm(
    user: AbstractUser | None,
    argv: list[str],
    *,
    action_label: str,
    extra_no_user_hint: str = "",
) -> AbstractUser:
    """
    Verify ``run_audit``; record refusal and raise on miss.

    Thin CLI wrapper: delegates the perm decision to
    ``check_run_perm`` and translates a refused outcome to a
    ``CommandError`` (with refusal trail) the way the management
    command path expects. Web callers should compose
    ``check_run_perm`` + ``record_refusal`` themselves rather than
    use this wrapper.
    """
    outcome = check_run_perm(user=user)
    if outcome.granted:
        # ``check_run_perm`` echoes the user back when granted; the
        # ``granted=True`` branch is reached only when ``user`` was
        # not-None at the call site, so ``outcome.user`` is the same
        # not-None reference. ``cast`` narrows for the type-checker
        # without a runtime assert (bandit B101 forbids assert in
        # production code that could be stripped under ``-O``).
        return cast("AbstractUser", outcome.user)
    _record_refusal(user, argv, InvocationRefusal.PERMISSION_DENIED)
    if user is None:
        message = (
            "OS-side actor could not be mapped to a Django user; "
            f"cannot verify {RUN_AUDIT} for {action_label}."
        )
        if extra_no_user_hint:
            message += " " + extra_no_user_hint
        raise CommandError(message)
    raise CommandError(f"{RUN_AUDIT} permission required to {action_label}")


def _begin_readonly_cli_invocation(*, action_label: str) -> AbstractUser:
    """Resolve OS user, redact argv, gate ``run_audit`` perm; no AuditInvocation row."""  # noqa: E501
    # Read-only sub-CLIs (``--diff``, ``--explain``, ``--policy-preview``)
    # do not write an ``AuditInvocation`` row by design — they do not
    # consume the per-day rate budget. They still need the same perm
    # gate + argv redaction for the refusal-trail path. Returns the
    # narrowed ``AbstractUser`` (never ``None``; the gate raises).
    # ``resolve_cli_actor`` consults ``AA_DISCORD_AUDIT_CLI_ACTOR`` and
    # raises a setting-named ``CommandError`` BEFORE any work when the
    # setting is set-but-invalid (gate-before-attribute ordering).
    #
    # ACCEPTED GAP: a SUCCESSFUL read-only call leaves no trail and eats
    # no rate budget, so an actor already holding ``run_audit`` can run
    # ``--explain`` / ``--diff`` repeatedly to enumerate guild membership
    # and the AA-user mapping without a forensic record. This is accepted:
    # the actor is already trusted (holds ``run_audit``) and these
    # diagnostics mutate nothing on Discord; refusals ARE still recorded.
    # If the threat model ever has to cover a trusted-but-curious actor,
    # switch these to an ``accepted=True`` invocation here (the redaction
    # + argv machinery is already in place) and decide whether read-only
    # recon should consume the rate budget.
    user = resolve_cli_actor()
    argv = redact_argv(sys.argv[1:])
    return _require_run_perm(user, argv, action_label=action_label)


def _begin_cli_invocation(
    *,
    action_label: str,
    extra_no_user_hint: str = "",
) -> tuple[AbstractUser, list[str]]:
    """Resolve OS user, redact argv, gate ``run_audit`` perm."""
    # Centralises the three-line preamble repeated by every
    # subcommand. Returns the narrowed ``AbstractUser`` (never
    # ``None``; the gate raises) plus the redacted argv recorded
    # into AuditInvocation rows. ``resolve_cli_actor`` honors
    # ``AA_DISCORD_AUDIT_CLI_ACTOR`` and raises a setting-named
    # ``CommandError`` before any work when set-but-invalid.
    user = resolve_cli_actor()
    argv = redact_argv(sys.argv[1:])
    user = _require_run_perm(
        user,
        argv,
        action_label=action_label,
        extra_no_user_hint=extra_no_user_hint,
    )
    return user, argv


def _begin_cli_invocation_accepted(
    *,
    action_label: str,
) -> AuditInvocation:
    """
    Resolve OS user, gate perm, record an ``accepted=True`` invocation.

    Wraps ``_begin_cli_invocation`` + ``_record_acceptance`` for the
    rate-limit-exempt subcommands (``--resume``, ``--abandon``) that
    finish or close an existing run rather than spawning a new one.
    Bundles the two into one call so a future subcommand cannot accept
    the invocation without first gating the perm + redacting the argv.
    The persisted user / argv survive on ``invocation.triggered_by_user``
    and ``invocation.cli_argv`` for callers that still need them.
    """
    user, argv = _begin_cli_invocation(action_label=action_label)
    return _record_acceptance(user, argv)


def _rate_limit_or_record(
    user: AbstractUser,
    argv: list[str],
) -> AuditInvocation:
    """
    Apply the per-user rate limit or record acceptance.

    Delegates the gate to the trigger-neutral core
    ``rate_limit_or_record`` and translates its ``RateLimitOutcome``
    into the CLI contract: return the persisted ``AuditInvocation``
    on accept, raise ``CommandError`` on rate-limit refuse. Web
    callers consume ``rate_limit_or_record`` directly and translate
    refusal to flash + redirect, so the result-object form lives in
    the core and the CommandError translation lives only here.
    """
    outcome = rate_limit_or_record(
        trigger=RunTrigger.CLI, user=user, argv=argv
    )
    if outcome.accepted:
        # ``accepted=True`` invariant guarantees ``invocation`` is the
        # persisted row, not None. ``cast`` narrows for the type-checker
        # without a runtime ``assert`` (bandit B101 forbids assert in
        # production code that could be stripped under ``-O``).
        return cast("AuditInvocation", outcome.invocation)
    raise CommandError(
        f"rate limit exceeded: {outcome.prior} accepted runs in the prior "
        f"24h (limit={outcome.limit}); try again later or ask an "
        f"operator to bump AA_DISCORD_AUDIT_RUN_RATE_LIMIT_PER_DAY"
    )
