"""
Core audit-trail models: ``AuditRun`` and ``AuditFinding``.

Both inherit ``AppendOnlyModelMixin`` and expose
``_APPEND_ONLY_UPDATABLE_FIELDS`` allow-lists; the FSM transitions
that mutate ``state`` go through
``AppendOnlyQuerySet.transition_state`` (compare-and-set), the
counter bumps go through ``AppendOnlyQuerySet.bump_counters``, and
ad-hoc mutation outside the allow-list is rejected at both the
manager and instance layers.
"""

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,
    _state_check,
)
from ._constants import (
    DISCORD_NAME_MAX_LENGTH,
    ENUM_MAX_LENGTH,
    REASON_ENUM_MAX_LENGTH,
)
from ._enums import (
    AuditFindingState,
    AuditRunState,
    FindingSkipReason,
    OrphanAction,
    OrphanCategory,
    RunTrigger,
)


class AuditRun(AppendOnlyModelMixin, models.Model):
    """One row per audit run (CLI invocation, beat tick, or manual trigger)."""

    started_at = models.DateTimeField(auto_now_add=True, db_index=True)
    finished_at = models.DateTimeField(null=True, blank=True)
    state = models.CharField(
        max_length=ENUM_MAX_LENGTH,
        choices=AuditRunState.choices,
        default=AuditRunState.PENDING,
    )
    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="+",
    )
    cli_argv = models.JSONField(default=list, blank=True)
    was_confirmation_bypassed = models.BooleanField(default=False)
    dry_run = models.BooleanField(default=False)
    first_run_lock_was_active = models.BooleanField(default=False)
    policy_snapshot = models.JSONField(default=dict)
    # Counters use BigInt for consistency with member_id / uid widths
    # elsewhere in the schema. Real values stay well below 2**31 for a
    # single-run scan, but ``increment_run_counters`` sums per-finding
    # deltas and a future cumulative-snapshot use-case should not have
    # to revisit the column type.
    members_scanned = models.PositiveBigIntegerField(default=0)
    findings_count = models.PositiveBigIntegerField(default=0)
    actions_applied = models.PositiveBigIntegerField(default=0)
    actions_skipped = models.PositiveBigIntegerField(default=0)
    summary = models.JSONField(default=dict)
    error_message = models.TextField(blank=True)
    # Optional caller-supplied dedup token. ``build_plan`` raises
    # ``IdempotencyConflict`` when an existing run already carries
    # the same key, so an at-least-once trigger (webhook retries,
    # CLI scripts with retry-on-error loops) cannot create a duplicate
    # AuditRun. ``unique=True`` is paired with ``null=True`` so the
    # legacy / unkeyed code path remains uncoupled — multiple NULLs
    # do not collide on either PostgreSQL or MySQL.
    idempotency_key = models.CharField(
        max_length=64,
        null=True,
        blank=True,
        unique=True,
    )

    objects = AppendOnlyManager()

    _APPEND_ONLY_UPDATABLE_FIELDS = frozenset(
        {
            "state",
            "finished_at",
            "summary",
            "members_scanned",
            "findings_count",
            "actions_applied",
            "actions_skipped",
            "error_message",
            "dry_run",
            "policy_snapshot",
            "first_run_lock_was_active",
        }
    )

    class Meta:
        # Use ``get_latest_by`` rather than ``ordering=("-started_at",)``
        # so AuditRun.objects.latest() works without leaking an implicit
        # ORDER BY into ``count()`` / ``annotate()`` / ``distinct()``
        # querysets. Admin changelist orders by column header anyway.
        get_latest_by = "started_at"
        verbose_name = _("audit run")
        verbose_name_plural = _("audit runs")
        # Defense-in-depth on the FSM: choices are only enforced by
        # Form / Serializer; raw save() accepts any string. The state
        # machine is load-bearing (terminal-state membership in
        # ``AuditRunState.is_finished`` decides whether finished_at gets
        # stamped), so a malformed state from a typo or future enum-
        # rename mistake should fail at the DB layer.
        constraints = [
            _state_check(
                AuditRunState, "aa_discord_audit_auditrun_state_valid"
            ),
        ]
        permissions = [
            ("run_audit", _("Can trigger an audit run")),
            (
                "run_audit_destructive",
                _(
                    "Can launch a destructive audit run "
                    "(strip/kick) from the web UI"
                ),
            ),
        ]

    def __str__(self) -> str:
        return (
            f"AuditRun(id={self.pk}, state={self.state}, "
            f"started={self.started_at.isoformat()})"
        )


class AuditFinding(AppendOnlyModelMixin, models.Model):
    """One row per orphan member observed during a run."""

    run = models.ForeignKey(
        AuditRun,
        on_delete=models.CASCADE,
        related_name="findings",
        help_text=_(
            "Findings co-die with their run. The only path to a Run "
            "delete is the retention sweep (``prune_audit_runs``); "
            "CASCADE keeps that prune atomic without a per-finding "
            "fan-out."
        ),
    )
    member_id = models.PositiveBigIntegerField()
    member_username = models.CharField(max_length=DISCORD_NAME_MAX_LENGTH)
    category = models.CharField(
        max_length=ENUM_MAX_LENGTH, choices=OrphanCategory.choices
    )
    aa_user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="discord_audit_findings",
    )
    state = models.CharField(
        max_length=ENUM_MAX_LENGTH,
        choices=AuditFindingState.choices,
        default=AuditFindingState.PENDING,
    )
    action = models.CharField(
        max_length=ENUM_MAX_LENGTH, choices=OrphanAction.choices
    )
    role_ids_removed = models.JSONField(default=list)
    role_names_removed = models.JSONField(default=list)
    was_kicked = models.BooleanField(default=False)
    skipped_reason = models.CharField(
        max_length=REASON_ENUM_MAX_LENGTH,
        blank=True,
        choices=FindingSkipReason.choices,
    )
    error_message = models.TextField(blank=True)

    objects = AppendOnlyManager()

    _APPEND_ONLY_UPDATABLE_FIELDS = frozenset(
        {
            "state",
            "role_ids_removed",
            "role_names_removed",
            "was_kicked",
            "skipped_reason",
            "error_message",
        }
    )

    class Meta:
        verbose_name = _("audit finding")
        verbose_name_plural = _("audit findings")
        # FSM defense-in-depth — see AuditRun.Meta.constraints rationale.
        # ``UniqueConstraint(run, member_id)`` codifies the write-once
        # invariant ``build_plan`` upholds: one finding per orphan
        # member per run. The unique index it spawns also serves the
        # in-run dedupe lookup that the previous non-unique
        # ``auditfinding_run_member_idx`` covered, so no separate
        # composite index is needed.
        constraints = [
            _state_check(
                AuditFindingState, "aa_discord_audit_auditfinding_state_valid"
            ),
            models.UniqueConstraint(
                fields=["run", "member_id"],
                name="aa_discord_audit_auditfinding_uniq_run_member",
            ),
        ]
        # Two indexes for distinct query shapes (the third — (run,
        # member_id) — is provided by the UniqueConstraint above):
        # - (run, state): apply-phase scan in audit/_apply.py.
        # - (member_id) standalone: cross-run forensic / admin search
        #   by uid (admin.py search_fields includes ``member_id``); the
        #   unique index's left-prefix rule does not serve a
        #   member_id-only filter once the table grows.
        indexes = [
            models.Index(
                fields=["run", "state"], name="auditfinding_run_state_idx"
            ),
            models.Index(fields=["member_id"], name="auditfinding_member_idx"),
        ]

    def __str__(self) -> str:
        return (
            f"AuditFinding(id={self.pk}, run={self.run_id}, "
            f"member={self.member_id}, cat={self.category}, "
            f"state={self.state})"
        )
