"""
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 getpass
import sys
from datetime import timedelta
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 django.db import transaction
from django.utils import timezone

from ...._log import get_logger
from ...._metrics import observe_invocation
from ....app_settings import (
    AA_DISCORD_AUDIT_RUN_RATE_LIMIT_DISABLED,
    AA_DISCORD_AUDIT_RUN_RATE_LIMIT_PER_DAY,
)
from ....argv_redact import redact_argv
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 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


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 _create_invocation(
    user: AbstractUser | None,
    argv: list[str],
    *,
    accepted: bool,
    outcome: str,
    refusal_reason: InvocationRefusal | str = "",
) -> AuditInvocation:
    """Insert one CLI ``AuditInvocation`` row + emit its metric."""
    # Single writer behind ``_record_acceptance`` / ``_record_refusal``:
    # both used to inline the same ``AuditInvocation.objects.create`` +
    # ``observe_invocation`` pair, so a new per-invocation column had to
    # be wired in two places that could drift. ``refusal_reason``
    # defaults to ``""`` — equivalent to omitting it, and the form the
    # model's acceptance invariant (``accepted=True ⇒ refusal_reason``
    # empty) requires for the accepted path.
    row = AuditInvocation.objects.create(
        triggered_by=RunTrigger.CLI,
        triggered_by_user=user,
        cli_argv=argv,
        accepted=accepted,
        refusal_reason=refusal_reason,
    )
    observe_invocation(trigger=RunTrigger.CLI.value, outcome=outcome)
    return row


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


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


def _link_invocation(invocation: AuditInvocation, run_pk: int) -> None:
    """Snapshot the run pk on the invocation row (post-prune trail)."""
    # ``audit_run_id`` is the FK attname (the integer column),
    # ``audit_run`` is the FK-relation name. The setattr uses attname
    # to skip Django's FK-instance fetch (we already have the pk);
    # ``update_fields`` uses the FK-relation name because that is the
    # form the ``AppendOnlyMixin`` allow-list registers for FK columns.
    # The mix looks asymmetric but each side is correct for its own
    # reason — a "consistency cleanup" to a single form would either
    # round-trip an unnecessary AuditRun fetch (use ``audit_run`` on
    # setattr) or trip the append-only guard (use ``audit_run_id`` in
    # update_fields).
    invocation.audit_run_id = run_pk
    invocation.audit_run_pk = run_pk
    invocation.save(update_fields=["audit_run", "audit_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 if missing.

    ``action_label`` distinguishes the trail (e.g. "start an audit",
    "resume an audit") and is woven into the operator-facing
    ``CommandError``. ``extra_no_user_hint`` is appended to the
    no-user-mapped message; the main entry point uses it for the
    OS-username convention reminder.
    """
    if user is not None and user.has_perm(RUN_AUDIT):
        return 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.

    Returns the persisted ``AuditInvocation`` (always with
    ``accepted=True``) when the run may proceed; raises
    ``CommandError`` and writes an ``accepted=False`` row when
    the limit is exceeded. The function never returns ``None``.

    The count-then-create is serialised per actor under a row lock so
    two concurrent invocations by the same user cannot both read the
    same prior count and both insert, slipping past the daily cap. The
    refusal trail is written OUTSIDE that transaction so a rejected call
    still records its ``accepted=False`` row (a rolled-back refusal would
    erase the reconnaissance trail the limit exists to capture).

    ``user`` is typed as non-optional. Every caller routes through
    ``_begin_cli_invocation`` which raises a ``CommandError`` from
    ``_require_run_perm`` before this function ever sees a ``None``.
    Tightening the signature makes the invariant a type-checker error,
    not a silent rate-limit bypass via a future caller path that forgets
    the perm gate.
    """
    rate_limit_disabled = AA_DISCORD_AUDIT_RUN_RATE_LIMIT_DISABLED
    limit = AA_DISCORD_AUDIT_RUN_RATE_LIMIT_PER_DAY
    if rate_limit_disabled or limit <= 0:
        return _record_acceptance(user, argv)

    # Lock the actor row so two concurrent CLI invocations by the same
    # user serialise: without it both could COUNT prior=N-1, both pass,
    # and both INSERT, breaching the per-day cap that bounds
    # reconnaissance / DoS on a command able to strip roles and kick.
    # Locking the actor row (not the prior invocation rows, of which
    # there may be none) serialises even a user with zero prior rows. On
    # SQLite ``select_for_update`` is a documented no-op — fine for the
    # single-connection test path. The accepted INSERT happens inside the
    # lock so a racing caller observes the bumped count.
    with transaction.atomic():
        get_user_model().objects.select_for_update().get(pk=user.pk)
        window_start = timezone.now() - timedelta(hours=24)
        prior = AuditInvocation.objects.filter(
            triggered_by_user=user,
            accepted=True,
            started_at__gte=window_start,
        ).count()
        if prior < limit:
            return _record_acceptance(user, argv)

    # Reached only when over the cap: the atomic above committed nothing
    # (the lock is released on exit). Record the refusal in its own
    # autocommit so it survives, then raise.
    _record_refusal(user, argv, InvocationRefusal.RATE_LIMITED)
    raise CommandError(
        f"rate limit exceeded: {prior} accepted runs in the prior "
        f"24h (limit={limit}); try again later or ask an "
        f"operator to bump AA_DISCORD_AUDIT_RUN_RATE_LIMIT_PER_DAY"
    )
