"""
TextChoices enums backing model field ``choices``.

The core domain enums (``OrphanCategory``, ``OrphanAction``) are
re-exported from ``policy.py`` for callers that don't need the
model layer.
"""

from __future__ import annotations

import functools

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


class IdentitySource(models.TextChoices):
    """Provenance of a ``DiscordIdentity`` row."""

    OPERATOR = "operator", _("Manually linked by operator")
    HISTORICAL = "historical", _("Migrated from a deleted DiscordUser row")


class OrphanCategory(models.TextChoices):
    """Classification result for a guild member during audit."""

    UNKNOWN_GUEST = "unknown_guest", _("Identity unknown to AA")
    LINKED_NO_PERM = (
        "linked_no_perm",
        _("Identity known but lacks access_perm"),
    )
    BOT_FILTERED = "bot_filtered", _("Filtered as a known bot account")


class OrphanAction(models.TextChoices):
    """Action policy applied to an orphan finding."""

    REPORT = "report", _("Report only — no changes")
    STRIP = "strip", _("Strip AA-managed roles")
    STRIP_KICK = "strip_kick", _("Strip AA-managed roles and kick")

    @property
    def is_destructive(self) -> bool:
        """
        Whether this action mutates Discord (strips roles / kicks).

        Single source of truth for "destructive" so the confirmation
        gates (CLI ``--yes`` in ``_run.py`` and the engine gate in
        ``audit/_apply.py``) cannot drift. Adding a destructive action is
        a one-line change here, not a grep across duplicated frozensets.
        """
        return self in (OrphanAction.STRIP, OrphanAction.STRIP_KICK)


class AuditRunState(models.TextChoices):
    """Lifecycle state of an ``AuditRun`` row."""

    PENDING = "pending", _("Pending")
    RUNNING = "running", _("Running")
    SUCCEEDED = "succeeded", _("Succeeded")
    FAILED = "failed", _("Failed")
    BUDGET_EXCEEDED = "budget_exceeded", _("Budget exceeded")
    ABANDONED = "abandoned", _("Abandoned")
    # Operator hit Ctrl-C / the worker received SIGTERM mid-run. Distinct
    # from FAILED (no exception was raised; the run was deliberately
    # cut short) and from ABANDONED (deliberate operator decision via
    # --abandon, not an interruption).
    INTERRUPTED = "interrupted", _("Interrupted")

    @property
    def is_terminal(self) -> bool:
        """
        The FSM will not move on its own from this state.

        Distinct from :attr:`is_finished` — ``INTERRUPTED`` is
        terminal (the FSM is parked; ``--resume`` is required to move
        it) but NOT finished (the run can still complete). The two
        predicates diverge only on ``INTERRUPTED``.
        """
        return self.is_finished or self == AuditRunState.INTERRUPTED

    @property
    def is_finished(self) -> bool:
        """
        The run is complete; ``finished_at`` is stamped on this state.

        Drives :func:`persistence._run_state_extras` (which writes the
        ``finished_at`` column) and the polling UI's "stop polling"
        signal. ``INTERRUPTED`` is intentionally NOT finished — it is
        a parked state resumable via ``--resume``, and stamping
        ``finished_at`` on it would lie about completion. Distinct
        from :attr:`is_terminal`: see that docstring for the
        boundary.
        """
        return self in {
            AuditRunState.SUCCEEDED,
            AuditRunState.FAILED,
            AuditRunState.BUDGET_EXCEEDED,
            AuditRunState.ABANDONED,
        }

    @property
    def is_active(self) -> bool:
        """The run is dispatched and may still progress."""
        return self in {AuditRunState.PENDING, AuditRunState.RUNNING}

    @property
    def is_successful(self) -> bool:
        """The run completed without errors."""
        return self == AuditRunState.SUCCEEDED

    @property
    def is_resumable(self) -> bool:
        """The run can be picked up by audit_resume from this state."""
        return self in {AuditRunState.PENDING, AuditRunState.INTERRUPTED}

    @property
    def is_abandonable(self) -> bool:
        """The run can transition to ABANDONED via audit_abandon_stuck_runs."""
        return self in {
            AuditRunState.PENDING,
            AuditRunState.RUNNING,
            AuditRunState.INTERRUPTED,
        }


class AuditFindingState(models.TextChoices):
    """Lifecycle state of an ``AuditFinding`` row."""

    PENDING = "pending", _("Pending")
    APPLIED = "applied", _("Applied")
    SKIPPED = "skipped", _("Skipped")
    FAILED = "failed", _("Failed")


class RunTrigger(models.TextChoices):
    """How the audit run / invocation was triggered."""

    CLI = "cli", _("Management command")
    BEAT = "beat", _("Celery beat schedule")
    MANUAL = "manual", _("Manual / API call")
    WEB = "web", _("Web UI")


class InvocationRefusal(models.TextChoices):
    """Reason an ``AuditInvocation`` was refused (``accepted=False``)."""

    RATE_LIMITED = "rate_limited", _("Per-user rate limit exceeded")
    # Permission check failed before reaching the rate-limit / quota
    # gate. Recorded so a malicious user repeatedly attempting an audit
    # leaves an audit-trail without consuming their own daily quota.
    PERMISSION_DENIED = "permission_denied", _("run_audit permission missing")
    # Destructive --action (strip / strip_kick) without --yes. Refused
    # rows do not count toward the per-user limit (matches the
    # rate-limited contract documented on AuditInvocation.accepted).
    CONFIRMATION_DECLINED = (
        "confirmation_declined",
        _("Destructive action requested without --yes"),
    )
    # Used by the web-launch path (PR 4) when
    # InitialAuditAcknowledgement is missing. CLI has no analog —
    # CLI silently coerces to REPORT instead.
    INITIAL_LOCK_HELD = (
        "initial_lock_held",
        _("Initial audit not acknowledged"),
    )


class FindingSkipReason(models.TextChoices):
    """Reason an ``AuditFinding`` was skipped during the apply phase."""

    HTTP_ERROR = "http_error", _("Discord API HTTP error")
    PROTECTED_CONFIG = "protected_config", _("Member added to protect list")
    RECLASSIFIED = "reclassified", _("Member reclassified between phases")
    RECLASSIFIED_USER_CHANGE = (
        "reclassified_user_change",
        _("Linked AA user changed between phases"),
    )
    AUTH_FAILED = "auth_failed", _("Discord bot auth failed (401)")
    FORBIDDEN_403 = "forbidden_403", _("Discord refused the request (403)")
    # Operator marked the finding as reviewed via the admin bulk-action.
    # Distinct from PROTECTED_CONFIG: that one carries a uid forward
    # into every future run; MANUALLY_REVIEWED only retires *this*
    # finding, leaving classification of the same uid in the next run
    # unchanged.
    MANUALLY_REVIEWED = (
        "manually_reviewed",
        _("Operator marked the finding as manually reviewed"),
    )
    # build_plan and apply_plan disagreed on which findings exist
    # (typically: an admin deleted the AuditFinding row out-of-band
    # between the two phases). Distinct from HTTP_ERROR so admins can
    # filter forensic views; the in-memory AppliedAction is recorded
    # but no DB row is updated (there's nothing to update).
    INTERNAL_INCONSISTENCY = (
        "internal_inconsistency",
        _("Build/apply disagreed on which findings exist"),
    )

    @classmethod
    @functools.cache
    def failure_reasons(cls) -> frozenset[FindingSkipReason]:
        """
        Skip reasons carrying *error* semantics (vs. deliberate non-acts).

        Used by the per-finding metric to keep its ``outcome`` label
        honest — operators reading
        ``rate(aa_discord_audit_findings_total{outcome="failed"}[5m])``
        want Discord-side errors (HTTP / auth / 403), not policy-driven
        skips like PROTECTED_CONFIG or MANUALLY_REVIEWED. Cached so the
        frozenset is allocated once per process.

        ``INTERNAL_INCONSISTENCY`` joins the failure set because the
        build/apply-disagree path bumps ``actions_skipped`` for forensic
        invariant reasons (so ``applied + skipped == findings_count``)
        but represents an UNEXPECTED state divergence — not an operator-
        configured skip. Grouping it under ``outcome="failed"`` keeps
        the Prometheus histogram aligned with the row's logical state.
        """
        return frozenset(
            {
                cls.HTTP_ERROR,
                cls.AUTH_FAILED,
                cls.FORBIDDEN_403,
                cls.INTERNAL_INCONSISTENCY,
            }
        )


class ConfigAction(models.TextChoices):
    """Operation recorded by ``ConfigChangeLog`` for a tracked-model edit."""

    CREATE = "create", _("Created")
    UPDATE = "update", _("Updated")
    DELETE = "delete", _("Deleted")
