"""
Django admin registrations.

Three classes of admin behavior:

- **Editable models** (``DiscordIdentity``, ``BotAccountUid``,
  ``ProtectedDiscordMember``, ``ManagedRoleException``): standard
  CRUD gated on the dedicated ``manage_*`` permission for that
  model. Each save / delete writes a ``ConfigChangeLog`` row via
  the signal handler in ``signals.py``.
- **Append-only audit-trail models** (``AuditRun``,
  ``AuditFinding``, ``ConfigChangeLog``, ``AuditInvocation``):
  ``has_add_permission`` and ``has_change_permission`` return
  ``False`` regardless of Django's auto-generated
  ``add_/change_<model>`` perms. ``has_delete_permission`` for
  ``AuditRun`` consults ``run_audit`` (retention path);
  ``ConfigChangeLog`` and ``AuditInvocation`` are fully
  immutable from admin.
- **Safety-lock model** (``InitialAuditAcknowledgement``):
  ``has_add_permission`` AND-gates ``run_audit`` AND
  ``acknowledge_initial_audit``; change / delete denied.
"""

from __future__ import annotations

from typing import ClassVar

from django.contrib import admin

from .models import (
    AuditFinding,
    AuditFindingState,
    AuditInvocation,
    AuditRun,
    BotAccountUid,
    ConfigChangeLog,
    DiscordIdentity,
    FindingActionOverride,
    FindingSkipReason,
    InitialAuditAcknowledgement,
    ManagedRoleException,
    ProtectedDiscordMember,
)
from .permissions import (
    ACKNOWLEDGE_INITIAL_AUDIT,
    MANAGE_BOT_ACCOUNT_UID,
    MANAGE_DISCORD_IDENTITY,
    MANAGE_FINDING_OVERRIDE,
    MANAGE_PROTECTED_MEMBER,
    MANAGE_ROLE_EXCEPTION,
    RUN_AUDIT,
    VIEW_AUDITFINDING,
    VIEW_AUDITRUN,
)
from .persistence import persist_finding_skipped

# --- Editable models ---


class _SinglePermAdmin(admin.ModelAdmin):
    """ModelAdmin gating view/add/change/delete on one ``manage_perm``."""

    # Subclasses set the ``manage_perm`` codename string. The four
    # has_*_permission methods then resolve uniformly through Django's
    # ``user.has_perm``. Subclasses with extra perm logic (e.g.
    # ``DiscordIdentityAdmin`` ORs with the auto-generated
    # ``view_<model>`` perm via ``has_module_permission``) override
    # the relevant method. Marked ``ClassVar`` so pyright does not
    # treat the bare annotation as an uninitialised instance var —
    # the value is configured at class-definition time, not per
    # instance.
    manage_perm: ClassVar[str]

    def __init_subclass__(cls, **kwargs: object) -> None:
        # Fail at import time if a subclass forgot to declare
        # ``manage_perm``. The previous default was an empty string,
        # which silently locked the model for every user (because
        # ``user.has_perm("")`` is False even for superuser) instead
        # of raising — a footgun for future maintainers adding new
        # editable models.
        super().__init_subclass__(**kwargs)
        if not getattr(cls, "manage_perm", ""):
            raise TypeError(
                f"{cls.__name__} must set ``manage_perm`` to a "
                f"permission codename (got empty)"
            )

    def has_view_permission(self, request, obj=None) -> bool:
        return request.user.has_perm(self.manage_perm)

    def has_add_permission(self, request) -> bool:
        return request.user.has_perm(self.manage_perm)

    def has_change_permission(self, request, obj=None) -> bool:
        return request.user.has_perm(self.manage_perm)

    def has_delete_permission(self, request, obj=None) -> bool:
        return request.user.has_perm(self.manage_perm)


@admin.register(DiscordIdentity)
class DiscordIdentityAdmin(_SinglePermAdmin):
    manage_perm = MANAGE_DISCORD_IDENTITY
    list_display = (
        "uid",
        "user",
        "username_snapshot",
        "display_name_snapshot",
        "source",
        "last_seen_in_guild_at",
        "created_at",
    )
    list_select_related = ("user",)
    list_filter = ("source",)
    search_fields = (
        "uid",
        "user__username",
        "username_snapshot",
        "display_name_snapshot",
        "notes",
    )
    # ``raw_id_fields`` over ``autocomplete_fields``: AA core's bundled
    # UserAdmin does not declare ``search_fields``, which makes
    # ``autocomplete_fields`` raise ``admin.E039`` at startup.
    # ``raw_id_fields`` is the safe fallback that does not require
    # upstream cooperation; the operator gets a popup-search dialog
    # instead of an inline autocomplete.
    raw_id_fields = ("user",)
    readonly_fields = ("created_at",)

    def has_module_permission(self, request) -> bool:
        return request.user.has_perm(
            self.manage_perm
        ) or super().has_module_permission(request)

    def has_view_permission(self, request, obj=None) -> bool:
        # Module-level OR fallback (Django's auto-generated
        # ``view_<model>`` perm) makes the changelist reachable to
        # operators with read-only access; add/change/delete remain
        # locked behind ``manage_perm`` per the base class.
        return self.has_module_permission(request)


@admin.register(BotAccountUid)
class BotAccountUidAdmin(_SinglePermAdmin):
    manage_perm = MANAGE_BOT_ACCOUNT_UID
    list_display = ("uid", "added_by", "added_at")
    list_select_related = ("added_by",)
    raw_id_fields = ("added_by",)
    search_fields = ("uid", "notes")
    readonly_fields = ("added_at",)


@admin.register(ProtectedDiscordMember)
class ProtectedDiscordMemberAdmin(_SinglePermAdmin):
    manage_perm = MANAGE_PROTECTED_MEMBER
    list_display = ("uid", "reason", "added_by", "added_at")
    list_select_related = ("added_by",)
    raw_id_fields = ("added_by",)
    search_fields = ("uid", "reason")
    readonly_fields = ("added_at",)


@admin.register(ManagedRoleException)
class ManagedRoleExceptionAdmin(_SinglePermAdmin):
    manage_perm = MANAGE_ROLE_EXCEPTION
    list_display = (
        "role_id",
        "role_name",
        "reason",
        "created_by",
        "created_at",
    )
    list_select_related = ("created_by",)
    raw_id_fields = ("created_by",)
    search_fields = ("role_id", "role_name", "reason")
    readonly_fields = ("created_at",)


@admin.register(FindingActionOverride)
class FindingActionOverrideAdmin(_SinglePermAdmin):
    manage_perm = MANAGE_FINDING_OVERRIDE
    list_display = (
        "member_id",
        "action",
        "expires_at",
        "added_by",
        "added_at",
    )
    list_select_related = ("added_by",)
    raw_id_fields = ("added_by",)
    list_filter = ("action",)
    search_fields = ("member_id", "reason")
    readonly_fields = ("added_at",)


# --- Append-only audit-trail models ---


class _ReadOnlyAdmin(admin.ModelAdmin):
    """
    Base ModelAdmin pinning add / change / delete to False by default.

    Subclasses that allow deletion (e.g. ``AuditRunAdmin`` on the
    retention path) must explicitly override ``has_delete_permission``.
    Without this default, a future audit-trail admin that forgot to
    override would silently fall back to Django's ``delete_<model>``
    permission check and re-enable deletion — breaking the append-only
    contract for that table.
    """

    # Typed wider than ``= None`` so subclasses (``AuditRunAdmin``,
    # ``AuditFindingAdmin``) that selectively re-enable a bulk action
    # can override with a tuple without a mypy ``[assignment]`` error.
    # ``ModelAdmin.actions`` is typed by django-stubs as a broader
    # union; pyright correctly flags the narrowing here, but the
    # narrowing is the whole point of the override.
    actions: tuple[str, ...] | None = None  # pyright: ignore[reportIncompatibleVariableOverride]

    def has_add_permission(self, request) -> bool:
        return False

    def has_change_permission(self, request, obj=None) -> bool:
        return False

    def has_delete_permission(self, request, obj=None) -> bool:
        return False

    def get_readonly_fields(self, request, obj=None) -> tuple[str, ...]:
        # Computed at request time so a model field added by a later
        # migration is rendered read-only on existing installs without
        # a process restart. The class-definition-time tuple cached
        # the field list when the class loaded, so new fields stayed
        # editable in the form until restart.
        return tuple(f.name for f in self.model._meta.fields)


class _AuditTrailViewAdmin(_ReadOnlyAdmin):
    """Read-only audit-trail admin gating view perm on VIEW_AUDITRUN."""

    def has_view_permission(self, request, obj=None) -> bool:
        return request.user.has_perm(VIEW_AUDITRUN)


@admin.register(AuditRun)
class AuditRunAdmin(_AuditTrailViewAdmin):
    list_display = (
        "id",
        "state",
        "started_at",
        "finished_at",
        "members_scanned",
        "findings_count",
        "actions_applied",
        "actions_skipped",
        "dry_run",
        "first_run_lock_was_active",
    )
    list_select_related = ("triggered_by_user",)
    list_filter = (
        "state",
        "dry_run",
        "first_run_lock_was_active",
        "triggered_by",
    )
    search_fields = ("id", "triggered_by", "triggered_by_user__username")
    date_hierarchy = "started_at"
    # Restore the default ``delete_selected`` action that
    # ``_ReadOnlyAdmin.actions = None`` strips so retention cleanup
    # is reachable from the changelist. Without this an operator
    # could only delete one row at a time via the change form;
    # ``has_delete_permission`` already gates the operation on
    # ``run_audit``.
    actions = ("delete_selected",)

    def has_delete_permission(self, request, obj=None) -> bool:
        # Retention path: deletion gated on the destructive-action perm.
        # Overrides the _ReadOnlyAdmin default-False because retention
        # cleanup goes through the admin in disaster-recovery scenarios.
        return request.user.has_perm(RUN_AUDIT)


@admin.register(AuditFinding)
class AuditFindingAdmin(_ReadOnlyAdmin):
    list_display = (
        "id",
        "run",
        "member_id",
        "member_username",
        "category",
        "state",
        "action",
        "kicked",
        "skipped_reason",
    )
    # ``run`` is in list_display; ``aa_user`` is NOT in list_display so
    # it does not need preloading per row. ``aa_user__username`` in
    # search_fields uses its own JOIN; pre-selecting on every changelist
    # paint added a wasted LEFT JOIN to the row count.
    list_select_related = ("run",)
    list_filter = ("category", "state", "action", "kicked")
    search_fields = ("member_id", "member_username", "aa_user__username")
    # Restore the bulk-action dropdown (``_ReadOnlyAdmin`` strips it
    # by default). The single registered action is gated on
    # ``run_audit`` via ``get_actions`` below; viewers without that
    # perm see no action menu.
    actions = ("mark_pending_as_manually_reviewed",)

    def has_view_permission(self, request, obj=None) -> bool:
        # Deep-link denial: must hold both per-model AND run perms.
        return request.user.has_perm(
            VIEW_AUDITFINDING
        ) and request.user.has_perm(VIEW_AUDITRUN)

    def get_actions(self, request):
        # Hide ``mark_pending_as_manually_reviewed`` when the operator
        # lacks ``run_audit``. The action mutates AuditFinding state
        # (PENDING → SKIPPED) which influences what the next ``--resume``
        # processes; gate on the same perm that lets an operator start
        # the audit so the authority surface is symmetric.
        actions = super().get_actions(request)
        if not request.user.has_perm(RUN_AUDIT):
            actions.pop("mark_pending_as_manually_reviewed", None)
        return actions

    @admin.action(
        description="Mark selected PENDING findings as manually reviewed",
        permissions=("view",),
    )
    def mark_pending_as_manually_reviewed(self, request, queryset):
        """
        Bulk-skip PENDING findings with reason MANUALLY_REVIEWED.

        Already-terminal rows (APPLIED / SKIPPED / FAILED) are
        ignored — the FSM compare-and-set in
        ``persist_finding_skipped`` returns rowcount=0 for them.
        Counter bookkeeping happens once per affected run, batched
        across the selection so a 500-finding selection issues
        ~N + |runs| writes instead of 2N.
        """
        pending = queryset.filter(state=AuditFindingState.PENDING.value)
        per_run_counts: dict[int, int] = {}
        for finding in pending:
            if persist_finding_skipped(
                finding, reason=FindingSkipReason.MANUALLY_REVIEWED
            ):
                per_run_counts[finding.run_id] = (
                    per_run_counts.get(finding.run_id, 0) + 1
                )
        # ``bump_counters`` operates directly on a filter queryset:
        # one ``UPDATE … SET col = col + N WHERE pk = ?`` per affected
        # run, no preceding SELECT. The previous loop did
        # ``AuditRun.objects.get(pk=...)`` per iteration, producing
        # an N+1 fetch chain on multi-run admin selections.
        for run_id, count in per_run_counts.items():
            AuditRun.objects.filter(pk=run_id).bump_counters(
                actions_skipped=count
            )
        total = sum(per_run_counts.values())
        self.message_user(
            request,
            f"Marked {total} finding(s) as manually reviewed across "
            f"{len(per_run_counts)} run(s).",
        )


@admin.register(ConfigChangeLog)
class ConfigChangeLogAdmin(_AuditTrailViewAdmin):
    list_display = (
        "id",
        "created_at",
        "model_name",
        "object_pk",
        "action",
        "actor_username",
    )
    list_filter = ("action", "model_name")
    search_fields = ("model_name", "object_pk", "actor_username")
    date_hierarchy = "created_at"


@admin.register(AuditInvocation)
class AuditInvocationAdmin(_AuditTrailViewAdmin):
    list_display = (
        "id",
        "started_at",
        "triggered_by",
        "triggered_by_user",
        "accepted",
        "refusal_reason",
        "audit_run_pk",
    )
    # ``audit_run`` not in list_display (we render ``audit_run_pk``
    # directly); pre-selecting it added a wasted LEFT JOIN per row.
    list_select_related = ("triggered_by_user",)
    list_filter = ("accepted", "refusal_reason", "triggered_by")
    search_fields = ("triggered_by_user__username",)
    date_hierarchy = "started_at"


# --- Safety lock ---


@admin.register(InitialAuditAcknowledgement)
class InitialAuditAcknowledgementAdmin(admin.ModelAdmin):
    list_display = ("id", "acknowledged_at", "acknowledged_by")
    list_select_related = ("acknowledged_by",)
    raw_id_fields = ("acknowledged_by",)
    readonly_fields = ("acknowledged_at",)

    def has_view_permission(self, request, obj=None) -> bool:
        return request.user.has_perm(ACKNOWLEDGE_INITIAL_AUDIT)

    def has_add_permission(self, request) -> bool:
        # AND-gate: both perms required to release the safety lock.
        return request.user.has_perm(RUN_AUDIT) and request.user.has_perm(
            ACKNOWLEDGE_INITIAL_AUDIT
        )

    def has_change_permission(self, request, obj=None) -> bool:
        return False

    def has_delete_permission(self, request, obj=None) -> bool:
        # Re-arming the lock requires shell access — intentional friction.
        return False

    def save_model(self, request, obj, form, change) -> None:
        if not change and obj.acknowledged_by_id is None:
            obj.acknowledged_by = request.user
        super().save_model(request, obj, form, change)
