"""
Operator-managed configuration models.

Tables here are edited by operators through Django admin. They split
into two contracts:

Classification + apply-time inputs (``BulkBlockingManager`` so
``post_save`` / ``post_delete`` fire for every edit and the
``ConfigChangeLog`` trail stays complete):

- ``BotAccountUid``: uids skipped as bot accounts; consulted by
  ``classify()``.
- ``ProtectedDiscordMember``: uids the audit must never act on;
  short-circuits ``apply_plan`` to REPORT, never expires.
- ``ManagedRoleException``: roles excluded from the AA-managed set;
  consulted by ``role_filter``.
- ``FindingActionOverride``: per-uid override of the resolved
  action; consulted by ``AuditPlan.action_for`` at apply time only,
  optional ``expires_at``. Does not affect classification.

Apply-time gate (default manager — single-row sentinel):

- ``InitialAuditAcknowledgement``: presence releases the first-run
  dry-run lock. Pure on/off latch; not consulted by ``classify``.
"""

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 BulkBlockingManager
from ._constants import DISCORD_ROLE_NAME_MAX_LENGTH, ENUM_MAX_LENGTH
from ._enums import OrphanAction


class BotAccountUid(models.Model):
    """
    Operator-managed list of Discord uids known to be bot accounts.

    Replaces the missing ``User.bot`` payload field from AA core's
    ``User`` dataclass. ``classify()`` returns
    ``OrphanCategory.BOT_FILTERED`` for members whose uid appears
    here, producing a finding row so the audit trail records what
    was filtered (defangs the silent-defang vector).
    """

    uid = models.PositiveBigIntegerField(unique=True)
    notes = models.TextField(blank=True)
    added_at = models.DateTimeField(auto_now_add=True)
    added_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="+",
    )

    objects = BulkBlockingManager()

    class Meta:
        verbose_name = _("bot account uid")
        verbose_name_plural = _("bot account uids")
        permissions = [
            ("manage_bot_account_uid", "Can edit BotAccountUid rows"),
        ]

    def __str__(self) -> str:
        return f"BotAccountUid(uid={self.uid})"


class ProtectedDiscordMember(models.Model):
    """
    Members the audit must never act on.

    Admin-managed so changes can land without a redeploy during
    incidents.
    """

    uid = models.PositiveBigIntegerField(unique=True)
    reason = models.TextField()
    added_at = models.DateTimeField(auto_now_add=True)
    added_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="+",
    )

    objects = BulkBlockingManager()

    class Meta:
        verbose_name = _("protected discord member")
        verbose_name_plural = _("protected discord members")
        permissions = [
            (
                "manage_protected_member",
                "Can edit ProtectedDiscordMember rows",
            ),
        ]

    def __str__(self) -> str:
        return f"ProtectedDiscordMember(uid={self.uid})"


class ManagedRoleException(models.Model):
    """
    Roles excluded from the AA-managed set despite name match.

    The single configuration knob for managed-role discovery.
    """

    role_id = models.PositiveBigIntegerField(unique=True)
    role_name = models.CharField(max_length=DISCORD_ROLE_NAME_MAX_LENGTH)
    reason = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="+",
    )

    objects = BulkBlockingManager()

    class Meta:
        verbose_name = _("managed role exception")
        verbose_name_plural = _("managed role exceptions")
        permissions = [
            ("manage_role_exception", "Can edit ManagedRoleException rows"),
        ]

    def __str__(self) -> str:
        return (
            f"ManagedRoleException(role_id={self.role_id}, "
            f"name={self.role_name!r})"
        )


class FindingActionOverride(models.Model):
    """
    Per-uid override of the resolved ``OrphanAction``.

    Sits one layer above ``ProtectedDiscordMember`` semantically:
    where ``ProtectedDiscordMember`` always coerces an action to
    ``REPORT``, ``FindingActionOverride`` lets the operator pin
    *any* action for a specific uid, optionally with an expiry.
    The ``apply_plan`` lookup order is documented in
    ``_apply_finding``: ``ProtectedDiscordMember`` first (always
    REPORT, no expiry), then ``FindingActionOverride`` (typed
    action, optional expiry), then the policy default.

    Unique on ``member_id`` so the operator UI shows one row per
    uid; mutating the row records a ``ConfigChangeLog`` entry via
    ``signals.py``.
    """

    member_id = models.PositiveBigIntegerField(unique=True)
    action = models.CharField(
        max_length=ENUM_MAX_LENGTH, choices=OrphanAction.choices
    )
    reason = models.TextField()
    expires_at = models.DateTimeField(null=True, blank=True)
    added_at = models.DateTimeField(auto_now_add=True)
    added_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="+",
    )

    objects = BulkBlockingManager()

    class Meta:
        verbose_name = _("finding action override")
        verbose_name_plural = _("finding action overrides")
        permissions = [
            (
                "manage_finding_override",
                "Can edit FindingActionOverride rows",
            ),
        ]
        # Hot path on apply: ``build_plan`` snapshots every active
        # override into ``AuditPlan.override_by_uid`` via a single
        # query filtered by the expiry window. The index supports
        # both the ``expires_at IS NULL`` and the
        # ``expires_at > now`` predicates.
        indexes = [
            models.Index(
                fields=["expires_at"],
                name="findingoverride_expiry_idx",
            ),
        ]

    def __str__(self) -> str:
        return (
            f"FindingActionOverride(member_id={self.member_id}, "
            f"action={self.action})"
        )

    def clean(self) -> None:
        """
        Refuse overrides an operator installs against their own uid.

        ``manage_finding_override`` lets the operator pin any action
        for any uid. Without this guard, an operator
        with a linked Discord account could pre-create
        ``FindingActionOverride(member_id=<own uid>, action=REPORT)``
        and neutralise any future strip against themselves —
        privilege escalation at the policy layer, hidden behind a
        single admin-form submission.

        The guard fires only when the operator has a *currently*
        linked ``DiscordUser``; a historical link in
        ``DiscordIdentity`` is NOT consulted because (a) the audit
        flags findings against live Discord uids, not historical
        ones, and (b) blocking historical pairings would lock an
        operator out of overriding accounts they once held but no
        longer control.

        Defense is in ``clean()``, called by Django admin via
        ``ModelForm.full_clean()``. Direct ORM
        (``Model.objects.create``) bypasses ``clean()``, which is
        intentional: shell access is a separate trust tier and the
        attacker already has stronger primitives there.
        """
        super().clean()
        if self.added_by_id is None:
            return
        # Lazy import — AA's discord service is loaded after Django
        # apps registry is ready, and the model module is imported
        # during initial app loading. The local import keeps
        # ``aa_discord_audit.models`` import-safe.
        from allianceauth.services.modules.discord.models import (
            DiscordUser,
        )
        from django.core.exceptions import ValidationError

        own_uids = set(
            DiscordUser.objects.filter(user_id=self.added_by_id).values_list(
                "uid", flat=True
            )
        )
        if self.member_id in own_uids:
            raise ValidationError(
                {
                    "member_id": _(
                        "Operators cannot create a FindingActionOverride "
                        "for their own linked Discord identity. Have a "
                        "second operator review and submit the override."
                    ),
                }
            )


class InitialAuditAcknowledgement(models.Model):
    """
    One-row sentinel that releases the first-run dry-run lock.

    Creating a row requires both ``run_audit`` and
    ``acknowledge_initial_audit`` permissions; the admin's
    ``has_add_permission`` AND-gates them. Deletion is blocked at
    the admin layer; operators wanting to re-arm the lock must
    drop the row via shell, which is intentional friction.
    """

    acknowledged_at = models.DateTimeField(auto_now_add=True)
    acknowledged_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="+",
    )
    notes = models.TextField(blank=True)

    class Meta:
        verbose_name = _("initial audit acknowledgement")
        verbose_name_plural = _("initial audit acknowledgements")
        permissions = [
            (
                "acknowledge_initial_audit",
                "Can release the first-run dry-run lock",
            ),
        ]

    def __str__(self) -> str:
        return (
            f"InitialAuditAcknowledgement"
            f"(at={self.acknowledged_at.isoformat()})"
        )
