"""
Append-only enforcement primitives shared by the audit-trail models.

Append-only enforcement runs in two layers, applied to ``AuditRun``,
``AuditFinding``, ``ConfigChangeLog``, and ``AuditInvocation``:

1. ``AppendOnlyManager`` (via ``Manager.from_queryset(AppendOnlyQuerySet)``)
   blocks ``Model.objects.update()``,
   ``Model.objects.filter(...).update()``, and ``bulk_update``.
2. ``AppendOnlyModelMixin.save()`` validates that an existing-row
   update only changes fields listed in
   ``_APPEND_ONLY_UPDATABLE_FIELDS``. Per-model overrides spell
   out the allow-list.

The accepted residual is raw SQL through ``connection.cursor()``
which bypasses both layers; this is the documented escape hatch
for forensic ops and not used by the module itself today.

A parallel ``BulkBlockingManager`` lives here too: it is applied
to the operator-managed config models (``BotAccountUid``,
``ProtectedDiscordMember``, ``ManagedRoleException``) so the
``post_save`` / ``post_delete`` signal contract that powers
``ConfigChangeLog`` cannot be silently bypassed by ``QuerySet.update``.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any, NoReturn

from django.core.exceptions import ValidationError
from django.db import models

if TYPE_CHECKING:
    from collections.abc import Callable


def _state_check(
    enum_cls: type[models.TextChoices],
    name: str,
) -> models.CheckConstraint:
    """Build a CheckConstraint asserting ``state`` is an enum wire value."""
    # The constraint catches FSM typos / future enum-rename mistakes at
    # the DB layer, since model field choices are only enforced by
    # Form / Serializer paths. ``# type: ignore[call-arg]`` and the
    # matching pyright suppression match Django 4.2's runtime kwarg
    # ``check=``; django-stubs 6.x assumes the 5.1+ surface where it
    # was renamed to ``condition=``.
    return models.CheckConstraint(  # type: ignore[call-arg]
        check=models.Q(  # pyright: ignore[reportCallIssue]
            state__in=[s.value for s in enum_cls]
        ),
        name=name,
    )


def _forbid(message: str) -> Callable[..., NoReturn]:
    """
    Build a QuerySet method that raises ``NotImplementedError(message)``.

    Centralises the boilerplate for blocked bulk-write paths in
    ``AppendOnlyQuerySet`` and ``_BulkBlockingQuerySet`` so all five
    rejection methods share a single implementation. The ``message``
    argument is what callers see in tracebacks; it must name the
    contract (append-only vs. ConfigChangeLog) so a future reader
    knows which invariant the call site violated.
    """

    def _stub(self: Any, *args: Any, **kwargs: Any) -> NoReturn:
        raise NotImplementedError(message)

    return _stub


class AppendOnlyQuerySet(models.QuerySet):
    """
    QuerySet that blocks bulk update paths.

    Closes the ``Model.objects.all().update(...)`` and
    ``filter(...).update(...)`` bypasses around the
    ``AppendOnlyModelMixin.save()`` instance check.
    """

    update = _forbid(
        "append-only model: use instance.save(update_fields=[...]) instead"
    )
    bulk_update = _forbid("append-only model: bulk_update forbidden")

    def bump_counters(self, **deltas: int) -> int:
        """
        Atomic ``F()``-based counter increment escape hatch.

        Privileged carve-out for ``increment_run_counters`` so monotonic
        counters (``members_scanned`` / ``actions_applied`` /
        ``actions_skipped``) increment via ``UPDATE col = col + N``
        instead of a Python read-modify-write. The base
        ``update`` is forbidden by the append-only contract; this
        method names the bypass intent so a future reader sees why
        we are calling the parent's SQL ``UPDATE`` directly.

        Empty ``deltas`` is a no-op (returns 0). Negative deltas are
        rejected at the Python layer so a typo fails with a clear
        ``ValueError`` instead of an opaque ``IntegrityError`` from
        the ``PositiveBigIntegerField`` CHECK at the DB layer; the
        carve-out is for monotonic counters only.
        """
        if negative := {k: v for k, v in deltas.items() if v < 0}:
            raise ValueError(
                f"bump_counters refuses negative delta(s) {negative}; "
                f"counters are monotonic-increasing by contract."
            )
        f_kwargs = {k: models.F(k) + v for k, v in deltas.items() if v}
        if not f_kwargs:
            return 0
        # Bypass our own ``update`` override by calling the QuerySet
        # base implementation explicitly.
        return models.QuerySet.update(self, **f_kwargs)

    def transition_state(
        self,
        *,
        expected_state: str | tuple[str, ...],
        new_state: str,
        **fields: Any,
    ) -> int:
        """
        Atomic compare-and-set FSM transition (carve-out).

        Privileged carve-out for ``persist_finding_*`` and
        ``flip_run_state`` so a single ``UPDATE … WHERE state =
        :expected SET state = :new, …`` enforces idempotency under
        retry: a second writer that arrives after the first
        transitioned the row sees ``rowcount = 0`` and skips the
        overwrite. Without this, a Celery retry walking an already-
        APPLIED finding would silently clobber ``role_ids_removed`` /
        ``role_names_removed`` because Django's instance.save() does
        not gate on prior state.

        ``expected_state`` accepts either a single state value or a
        tuple of values; the latter compiles to ``state IN (…)`` for
        FSM transitions whose predecessor is one of several states
        (e.g. ``--resume`` accepting both ``PENDING`` and
        ``INTERRUPTED``).

        Returns the row count touched (0 or 1). Caller decides what
        ``0`` means — for finding state, "another worker already did
        it"; for run state, "FSM rejected the transition".

        Defends against misuse: every entry in ``**fields`` is checked
        against the model's ``_APPEND_ONLY_UPDATABLE_FIELDS`` so a
        future caller cannot accidentally clobber an immutable column
        through this carve-out. The carve-out exists for FSM
        transitions only; non-allow-listed fields belong on
        ``instance.save(update_fields=[...])``.
        """
        allowed = getattr(
            self.model, "_APPEND_ONLY_UPDATABLE_FIELDS", frozenset()
        )
        if forbidden := fields.keys() - allowed:
            raise ValueError(
                f"transition_state cannot write to non-allow-listed "
                f"field(s) {sorted(forbidden)} on {self.model.__name__}; "
                f"allowed: {sorted(allowed)}"
            )
        if isinstance(expected_state, str):
            gated = self.filter(state=expected_state)
        else:
            gated = self.filter(state__in=expected_state)
        return models.QuerySet.update(gated, state=new_state, **fields)


_AppendOnlyManagerBase = models.Manager.from_queryset(AppendOnlyQuerySet)


class AppendOnlyManager(_AppendOnlyManagerBase):  # type: ignore[misc,valid-type]
    """Manager whose chained QuerySet rejects update / bulk_update."""


class _BulkBlockingQuerySet(models.QuerySet):
    """
    QuerySet that forces per-instance writes so signals fire on every row.

    The audit's ``ConfigChangeLog`` trail lives entirely in the
    ``post_save`` / ``post_delete`` receivers in ``signals.py``.
    Django's bulk paths (``QuerySet.update``, ``bulk_update``,
    ``bulk_create``, ``QuerySet.delete``) bypass those receivers
    silently — a ``Model.objects.filter(...).update(notes="...")``
    from a Django shell would tamper with operator data without
    leaving a ``ConfigChangeLog`` row, defeating the M4 audit-the-
    auditor guarantee.

    Applied to the three operator-managed tables
    (``BotAccountUid``, ``ProtectedDiscordMember``,
    ``ManagedRoleException``) so the bypass paths raise loudly and
    callers are forced into the per-instance API. ``delete()`` is
    fanned out per-row instead of raising so admin's
    ``delete_selected`` action keeps working — it pays the per-row
    cost in exchange for ``post_delete`` firing for every row.
    """

    update = _forbid(
        "operator table: use instance.save() so post_save fires; "
        "QuerySet.update bypasses ConfigChangeLog signals"
    )
    bulk_update = _forbid(
        "operator table: bulk_update bypasses ConfigChangeLog signals"
    )
    bulk_create = _forbid(
        "operator table: bulk_create bypasses ConfigChangeLog signals"
    )

    def delete(self) -> tuple[int, dict[str, int]]:
        """Per-row delete so ``post_delete`` fires for every row."""
        # Materialize before deletion so admin's delete_selected
        # action (which constructs the queryset, then calls .delete())
        # gets the same return shape Django expects: (count, by_label).
        instances = list(self)
        for instance in instances:
            instance.delete()
        if not instances:
            return (0, {})
        return (len(instances), {self.model._meta.label: len(instances)})


_BulkBlockingManagerBase = models.Manager.from_queryset(_BulkBlockingQuerySet)


class BulkBlockingManager(_BulkBlockingManagerBase):  # type: ignore[misc,valid-type]
    """Manager that forces per-instance writes; see _BulkBlockingQuerySet."""


if TYPE_CHECKING:
    # Tell mypy the mixin inherits from ``models.Model`` so attribute
    # accesses like ``_meta``, ``pk``, ``DoesNotExist`` resolve. At
    # runtime the mixin inherits from ``object``, so subclasses that
    # do ``class AuditRun(AppendOnlyModelMixin, models.Model)`` get
    # the real Model machinery via the second base.
    _MixinBase = models.Model
else:
    _MixinBase = object


class AppendOnlyModelMixin(_MixinBase):
    """
    Mixin that constrains ``save()`` to a per-class field allow-list on update.

    Subclasses declare ``_APPEND_ONLY_UPDATABLE_FIELDS: frozenset[str]``;
    insert (where ``pk is None``) is unaffected. On update, if
    ``update_fields`` is provided the override checks each named
    field against the allow-list; if ``update_fields`` is omitted
    the override compares the in-memory instance against the DB row
    and rejects writes touching disallowed fields.
    """

    _APPEND_ONLY_UPDATABLE_FIELDS: frozenset[str] = frozenset()

    def save(self, *args: Any, **kwargs: Any) -> None:
        """Validate update field set against the per-class allow-list."""
        if self.pk is not None:
            update_fields = kwargs.get("update_fields")
            forbidden: set[str]
            if update_fields is not None:
                forbidden = {
                    f
                    for f in update_fields
                    if f not in self._APPEND_ONLY_UPDATABLE_FIELDS
                }
            else:
                # update_fields omitted → diff every field vs DB row.
                forbidden = self._diff_against_db()
            if forbidden:
                raise ValidationError(
                    f"{type(self).__name__} is append-only; cannot update "
                    f"field(s) {sorted(forbidden)}. Allowed: "
                    f"{sorted(self._APPEND_ONLY_UPDATABLE_FIELDS)}"
                )
        super().save(*args, **kwargs)

    def _diff_against_db(self) -> set[str]:
        try:
            stored = type(self).objects.get(pk=self.pk)
        except type(self).DoesNotExist:
            return set()
        # Allow-lists key by ``field.name`` (e.g. ``"audit_run"``). For
        # FKs ``field.attname`` is ``"audit_run_id"``, so a naïve
        # membership check on attname alone would treat every allow-
        # listed FK as forbidden. Compare names against the allow-
        # list, but read values via attname so FK reads do not trigger
        # a related-object SELECT. Use ``primary_key`` to skip the PK
        # rather than the literal ``"id"`` attname, in case a future
        # model declares its own PK field.
        return {
            field.name
            for field in self._meta.concrete_fields
            if not field.primary_key
            and field.name not in self._APPEND_ONLY_UPDATABLE_FIELDS
            and getattr(self, field.attname) != getattr(stored, field.attname)
        }
