"""
Trigger-neutral invocation accounting core.

Single writer for ``AuditInvocation`` rows shared by all three
triggers — CLI (``management/commands/_audit/_invocation.py``),
beat (``tasks.py``) and (future) web (``views.py``). Returns
result-object dataclasses instead of raising ``CommandError`` so
HTTP callers can map refusals to flash + redirect without
importing a Django-management exception.

All functions take ``trigger: RunTrigger`` as a keyword-only
argument. No path is allowed to assume the CLI; CLI-specific
glue (OS-login resolution, ``CommandError`` translation, argv
redaction from ``sys.argv``) lives in the CLI wrapper at
``management/commands/_audit/_invocation.py``.
"""

from __future__ import annotations

from dataclasses import dataclass
from datetime import timedelta
from typing import TYPE_CHECKING, Final

from django.contrib.auth import get_user_model
from django.db import transaction
from django.utils import timezone

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

if TYPE_CHECKING:  # pragma: no cover
    from django.contrib.auth.models import AbstractUser


@dataclass(frozen=True)
class PermCheckOutcome:
    """
    Result of a ``run_audit`` permission check.

    ``granted`` is the only field callers need to branch on; the
    other two are for trail/error-message construction. ``user``
    is the same object the caller passed in (echoed for symmetry
    with ``RateLimitOutcome.invocation``); ``reason`` is set iff
    ``granted is False``.
    """

    granted: bool
    user: AbstractUser | None
    reason: InvocationRefusal | None


@dataclass(frozen=True)
class RateLimitOutcome:
    """
    Result of a rate-limit decision.

    ``accepted`` true -> an ``accepted=True`` ``AuditInvocation``
    has been persisted and is exposed via ``invocation``.
    ``accepted`` false -> an ``accepted=False`` row has been
    persisted (the refusal trail), ``invocation`` is ``None``,
    ``reason`` is set to ``InvocationRefusal.RATE_LIMITED``, and
    ``prior`` / ``limit`` carry the numbers for the operator-facing
    message.
    """

    accepted: bool
    invocation: AuditInvocation | None
    prior: int
    limit: int
    reason: InvocationRefusal | None


_ACCEPTED_OUTCOME_LABEL: Final[str] = "accepted"


def create_invocation(
    *,
    trigger: RunTrigger,
    user: AbstractUser | None,
    argv: list[str],
    accepted: bool,
    refusal_reason: InvocationRefusal | None = None,
) -> AuditInvocation:
    """
    Persist one ``AuditInvocation`` row + emit its metric.

    Single writer behind ``record_refusal`` / ``record_acceptance``
    and the beat-task helper. The metric ``outcome`` label and the
    row's ``refusal_reason`` value derive from the same
    ``(accepted, refusal_reason)`` pair, so callers cannot
    accidentally desynchronise the two — a refusal row carrying a
    ``RATE_LIMITED`` reason will always emit
    ``outcome="rate_limited"`` on the metric. ``refusal_reason``
    must be ``None`` iff ``accepted is True`` (the model's
    acceptance invariant is checked at save-time; the assertion
    here surfaces the contract violation at the call site).
    """
    if accepted == (refusal_reason is not None):
        msg = (
            "create_invocation: refusal_reason must be None iff "
            f"accepted is True (got accepted={accepted}, "
            f"refusal_reason={refusal_reason!r})"
        )
        raise AssertionError(msg)
    db_reason = refusal_reason.value if refusal_reason is not None else ""
    outcome_label = _ACCEPTED_OUTCOME_LABEL if accepted else db_reason
    row = AuditInvocation.objects.create(
        triggered_by=trigger,
        triggered_by_user=user,
        cli_argv=argv,
        accepted=accepted,
        refusal_reason=db_reason,
    )
    observe_invocation(trigger=trigger.value, outcome=outcome_label)
    return row


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


def record_acceptance(
    *,
    trigger: RunTrigger,
    user: AbstractUser | None,
    argv: list[str],
) -> AuditInvocation:
    """Create an ``accepted=True`` ``AuditInvocation`` row."""
    return create_invocation(
        trigger=trigger,
        user=user,
        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 attname is set
    directly to skip Django's FK-instance fetch (the pk is already
    known); ``update_fields`` uses the relation name because that
    is the form ``AppendOnlyMixin`` registers in its allow-list.
    The asymmetry between setattr-side and update_fields-side is
    intentional — each side uses the form correct for its own
    layer (ORM-bypass on the read, allow-list on the write).
    """
    invocation.audit_run_id = run_pk
    invocation.audit_run_pk = run_pk
    invocation.save(update_fields=["audit_run", "audit_run_pk"])


def check_run_perm(*, user: AbstractUser | None) -> PermCheckOutcome:
    """
    Decide whether ``user`` holds ``run_audit``.

    Does NOT write the refusal trail — that is the caller's
    responsibility (CLI translates refusal to ``CommandError`` +
    ``record_refusal``; web translates to flash + ``record_refusal``).
    Pulling out the perm-decision so HTTP callers do not have to
    catch ``CommandError`` from a management-command helper.
    """
    if user is not None and user.has_perm(RUN_AUDIT):
        return PermCheckOutcome(granted=True, user=user, reason=None)
    return PermCheckOutcome(
        granted=False,
        user=user,
        reason=InvocationRefusal.PERMISSION_DENIED,
    )


def rate_limit_or_record(
    *,
    trigger: RunTrigger,
    user: AbstractUser,
    argv: list[str],
) -> RateLimitOutcome:
    """
    Apply per-user rate limit and persist the resulting trail row.

    Returns a ``RateLimitOutcome`` rather than raising — callers
    decide how to surface a refusal (CLI raises ``CommandError``;
    web flashes + redirects). The accepted ``AuditInvocation`` row
    is created inside the actor-row lock so two concurrent calls
    from the same user serialise; the refusal row is created
    OUTSIDE the lock (after the atomic block exits) so a rolled-
    back refusal does not erase the reconnaissance trail the limit
    exists to capture.

    On SQLite ``select_for_update`` is a documented no-op — fine for
    the single-connection test path; concurrent serialisation kicks
    in on Postgres / MySQL.
    """
    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:
        invocation = record_acceptance(trigger=trigger, user=user, argv=argv)
        return RateLimitOutcome(
            accepted=True,
            invocation=invocation,
            prior=0,
            limit=limit,
            reason=None,
        )

    with transaction.atomic():
        # Row lock on the actor only — the returned row is discarded.
        # ``.only("pk")`` avoids tugging in the AA-User custom columns
        # (eveonline / mumble / etc.) we never read here.
        get_user_model().objects.select_for_update().only("pk").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:
            invocation = record_acceptance(
                trigger=trigger, user=user, argv=argv
            )
            return RateLimitOutcome(
                accepted=True,
                invocation=invocation,
                prior=prior,
                limit=limit,
                reason=None,
            )

    record_refusal(
        trigger=trigger,
        user=user,
        argv=argv,
        reason=InvocationRefusal.RATE_LIMITED,
    )
    return RateLimitOutcome(
        accepted=False,
        invocation=None,
        prior=prior,
        limit=limit,
        reason=InvocationRefusal.RATE_LIMITED,
    )


def record_beat_run(*, run_id: int, argv: list[str]) -> None:
    """
    Persist an accepted BEAT invocation and link it to ``run_id``.

    Convenience for the unattended beat-task path which would
    otherwise leave no audit-the-auditor trail. ``triggered_by_user``
    is ``None`` because the BEAT trigger itself IS the "no human"
    marker; rate limit does not apply (beat is bounded by
    ``BEAT_MIN_INTERVAL``, not per-user quota).
    """
    invocation = record_acceptance(
        trigger=RunTrigger.BEAT, user=None, argv=argv
    )
    link_invocation(invocation, run_pk=run_id)
