"""
Audit-the-auditor models: ``ConfigChangeLog`` + ``AuditInvocation``.

``ConfigChangeLog`` records every operator edit to the operator-
managed config tables (wired by ``signals.py``). ``AuditInvocation``
records every CLI / beat trigger of the audit, including refused
attempts (so reconnaissance via repeated REPORT runs is rate-limited).
Both are append-only with carefully constrained allow-lists.
"""

from __future__ import annotations

from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _

from ._append_only import AppendOnlyManager, AppendOnlyModelMixin
from ._audit import AuditRun
from ._constants import (
    DJANGO_USERNAME_MAX_LENGTH,
    ENUM_MAX_LENGTH,
    MODEL_LABEL_MAX_LENGTH,
    OBJECT_PK_MAX_LENGTH,
    REASON_ENUM_MAX_LENGTH,
)
from ._enums import ConfigAction, InvocationRefusal, RunTrigger


class ConfigChangeLog(AppendOnlyModelMixin, models.Model):
    """
    Append-only log of edits to ``ManagedRoleException``,
    ``ProtectedDiscordMember``, ``BotAccountUid``.

    Wired via ``post_save`` / ``post_delete`` signals on each
    tracked model (see ``signals.py``); the actor is captured
    from ``current_user``'s thread-local with a ``<system>``
    fallback enriched with origin context. Full immutability —
    no updatable fields after creation.
    """

    actor_user_id = models.PositiveBigIntegerField(null=True, blank=True)
    actor_username = models.CharField(max_length=DJANGO_USERNAME_MAX_LENGTH)
    model_name = models.CharField(max_length=MODEL_LABEL_MAX_LENGTH)
    object_pk = models.CharField(max_length=OBJECT_PK_MAX_LENGTH)
    action = models.CharField(
        max_length=ENUM_MAX_LENGTH, choices=ConfigAction.choices
    )
    payload = models.JSONField(default=dict)
    created_at = models.DateTimeField(auto_now_add=True, db_index=True)

    objects = AppendOnlyManager()

    # Empty allow-list = fully immutable: ConfigChangeLog rows cannot
    # change after insert. Different from AuditRun (lifecycle fields
    # mutable) and AuditFinding (state-transition fields mutable) —
    # the change log is a forensic record with no legitimate
    # post-insert mutation.
    _APPEND_ONLY_UPDATABLE_FIELDS: frozenset[str] = frozenset()

    class Meta:
        verbose_name = _("config change log entry")
        verbose_name_plural = _("config change log entries")
        # Two index dimensions:
        # - (model_name, object_pk): forensic narrow "every edit to
        #   ProtectedDiscordMember pk=42" (admin search_fields).
        # - actor_username: admin search "what did <operator> do?"
        indexes = [
            models.Index(
                fields=["model_name", "object_pk"],
                name="configchangelog_target_idx",
            ),
            models.Index(
                fields=["actor_username"],
                name="configchangelog_actor_idx",
            ),
        ]

    def __str__(self) -> str:
        return (
            f"ConfigChangeLog(model={self.model_name}, pk={self.object_pk}, "
            f"action={self.action}, actor={self.actor_username})"
        )


class AuditInvocation(AppendOnlyModelMixin, models.Model):
    """
    One row per CLI / beat trigger of the audit, regardless of acceptance.

    Used to rate-limit reconnaissance via repeated REPORT runs.
    Refused (``accepted=False``) rows do NOT count toward the
    per-user limit; this design eliminates self-DoS where a
    bad-perm user repeatedly bumping the limit would otherwise
    lock themselves out indefinitely.
    """

    triggered_by = models.CharField(
        max_length=ENUM_MAX_LENGTH, choices=RunTrigger.choices
    )
    triggered_by_user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="+",
    )
    started_at = models.DateTimeField(auto_now_add=True, db_index=True)
    cli_argv = models.JSONField(default=list, blank=True)
    accepted = models.BooleanField()
    refusal_reason = models.CharField(
        max_length=REASON_ENUM_MAX_LENGTH,
        blank=True,
        choices=InvocationRefusal.choices,
    )
    audit_run = models.ForeignKey(
        AuditRun,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="invocations",
        help_text=_(
            "Live FK to the produced AuditRun (set after acceptance). "
            "Goes NULL on retention prune of the run; see "
            "``audit_run_pk`` for the post-prune forensic anchor."
        ),
    )
    audit_run_pk = models.PositiveBigIntegerField(
        null=True,
        blank=True,
        db_index=True,
        help_text=_(
            "Snapshot of audit_run_id at insertion time. Survives "
            "retention pruning of AuditRun, so forensic queries "
            "spanning >RETENTION_DAYS can still join through this "
            "column when ``audit_run`` has been NULL'd."
        ),
    )

    objects = AppendOnlyManager()

    _APPEND_ONLY_UPDATABLE_FIELDS = frozenset({"audit_run", "audit_run_pk"})

    class Meta:
        verbose_name = _("audit invocation")
        verbose_name_plural = _("audit invocations")
        # Insert is permitted (every invocation creates a row); update
        # is blocked by AppendOnlyManager + save() override; delete is
        # blocked at the admin layer (parallel to AuditRun /
        # ConfigChangeLog).
        default_permissions = ("view",)

    def __str__(self) -> str:
        return (
            f"AuditInvocation(id={self.pk}, accepted={self.accepted}, "
            f"by={self.triggered_by_user_id}, "
            f"at={self.started_at.isoformat()})"
        )

    def save(self, *args: object, **kwargs: object) -> None:
        """Reject inconsistent (accepted, refusal_reason) tuples at INSERT."""
        # Application-level defense-in-depth for the acceptance invariant:
        #   accepted=True  ⇒ refusal_reason == ""  (success has no reason)
        #   accepted=False ⇒ refusal_reason != "" (refusal must explain)
        # ``audit_run`` / ``audit_run_pk`` are set AFTER insert via
        # ``_link_invocation`` (post-acceptance only), so they are NOT
        # part of the INSERT-time invariant — the ``_APPEND_ONLY``
        # allow-list governs that update path separately.
        #
        # A DB-level CHECK constraint would close this end-to-end (raw
        # SQL writes too) but requires a schema migration with an
        # operator-side data audit; this in-Python guard catches every
        # write that flows through Django's ORM, which is the only
        # production path. Raw-SQL writes against this table do not
        # exist in the codebase today.
        if self.pk is None:
            self._check_acceptance_invariant()
        super().save(*args, **kwargs)

    def _check_acceptance_invariant(self) -> None:
        if self.accepted and self.refusal_reason:
            raise ValueError(
                f"AuditInvocation invariant violation: accepted=True must "
                f"have empty refusal_reason (got {self.refusal_reason!r}). "
                f"Successful invocations have no refusal text."
            )
        if not self.accepted and not self.refusal_reason:
            raise ValueError(
                "AuditInvocation invariant violation: accepted=False must "
                "carry a non-empty refusal_reason from InvocationRefusal. "
                "Refused invocations must explain why."
            )
