"""
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 logging
import sys
from datetime import timedelta
from typing import TYPE_CHECKING, cast

from django.core.management.base import CommandError
from django.utils import timezone

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,
    InvocationRefusal,
    RunTrigger,
)
from ....permissions import RUN_AUDIT

if TYPE_CHECKING:
    from django.contrib.auth.models import AbstractUser

logger = logging.getLogger(f"extensions.{__name__}")


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.
    """
    import getpass

    from django.contrib.auth import get_user_model

    User = get_user_model()
    try:
        return cast(
            "AbstractUser | None",
            User.objects.filter(username=getpass.getuser()).first(),
        )
    except Exception:  # noqa: BLE001 — getpass / 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 _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."""
    AuditInvocation.objects.create(
        triggered_by=RunTrigger.CLI,
        triggered_by_user=user,
        cli_argv=argv,
        accepted=False,
        refusal_reason=reason,
    )


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


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).
    user = _resolve_os_user()
    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.
    user = _resolve_os_user()
    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 _rate_limit_or_record(
    user: AbstractUser | None,
    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``.
    """
    rate_limit_disabled = AA_DISCORD_AUDIT_RUN_RATE_LIMIT_DISABLED
    limit = AA_DISCORD_AUDIT_RUN_RATE_LIMIT_PER_DAY
    if not rate_limit_disabled and limit > 0 and user is not None:
        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:
            _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"
            )
    return _record_acceptance(user, argv)
