"""
Frozen dataclasses passed between ``build_plan`` and ``apply_plan``.

Both phases are read-only over an ``AuditPlan``: ``build_plan``
returns one, ``apply_plan`` reads its findings + role catalog +
policy snapshot. The ``frozen=True`` envelope guarantees no
phase mutates the plan in place; first-run-lock coercion in
``apply_plan`` rebuilds the plan via ``dataclasses.replace``.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from types import MappingProxyType
from typing import TYPE_CHECKING, Final

from ..models import (
    AuditRunState,
    FindingSkipReason,
    OrphanAction,
    OrphanCategory,
)
from ..policy import resolve_action

if TYPE_CHECKING:  # pragma: no cover
    from collections.abc import Mapping
    from datetime import datetime

    from django.contrib.auth.models import AbstractUser

    from ..policy import Policy
    from ..role_filter import RoleLike


# Per-finding skip reason emitted by ``_apply_finding``. Backed by
# the ``FindingSkipReason`` enum; ``None`` marks a non-skipped applied
# action. The DB row column is a separate ``CharField`` whose empty-
# string default represents the same "no skip" state — the in-memory
# dataclass uses ``None`` to keep the type narrowing distinct from the
# enum's string values.
SkipReason = FindingSkipReason | None


# Single source of truth for the auth-fail forensic tag written by:
# ``_ApplyFindingExecutor.http_error_or_auth`` (per-AuditFinding row),
# ``_walk_findings`` in ``_apply`` (AuditRun.error_message via FAILED
# flip), and ``run_resume`` in commands' ``_resume`` (same flip in
# the resume path). A forensic operator filters by exact value, so the
# three write sites MUST agree on the string. Lives in ``_types`` (the
# leaf module) so the executor can import it top-level — wiring the
# constant through ``_apply`` would produce a circular-import cycle.
_AUTH_FAILED_ERROR_MESSAGE: Final[str] = "auth failed"


@dataclass(frozen=True)
class OrphanFinding:
    """Per-member finding produced by ``build_plan``."""

    member_id: int
    member_username: str
    category: OrphanCategory
    aa_user_id: int | None


@dataclass(frozen=True)
class AuditPlan:
    """Frozen plan from ``build_plan`` consumed by ``apply_plan``."""

    run_id: int
    guild_id: int
    findings: tuple[OrphanFinding, ...]
    role_catalog: tuple[RoleLike, ...]
    # Pre-computed ``id → name`` index over ``role_catalog``. Built once
    # in ``build_plan`` so phase-2 of ``_apply_finding`` resolves
    # ``role_names_removed`` in O(|role_ids|) instead of iterating the
    # full catalog (~100-300 roles) per non-REPORT finding.
    role_name_by_id: Mapping[int, str]
    aa_role_names: frozenset[str]
    reserved_role_names: frozenset[str]
    bot_uids: frozenset[int]
    # Frozen at plan-build time so per-finding role derivation does
    # not re-issue the ``ManagedRoleException.objects.values_list``
    # query inside the apply loop (was N+1 across findings).
    exempt_role_ids: frozenset[int]
    # Snapshot of every Discord uid observed during the build_plan
    # enumeration. Consumed by apply_plan to refresh
    # ``DiscordIdentity.last_seen_in_guild_at`` once, gated on
    # ``not run.dry_run`` so dry-run truly writes nothing.
    observed_uids: frozenset[int]
    # Pre-computed set of ``User.pk`` holding ``discord.access_discord``
    # at plan-build time; supplied to classify() so the per-member
    # perm check is O(1). Re-classify in the apply loop falls back to
    # ``user.has_perm`` for users newly linked between build and apply.
    users_with_access: frozenset[int]
    policy: Policy
    cli_override: OrphanAction | None
    started_at: datetime
    deadline_at: datetime
    # Per-uid action override (``FindingActionOverride``) snapshotted at
    # plan-build time. Apply-time lookup honours an entry here ABOVE
    # the policy default but BELOW the ``ProtectedDiscordMember``
    # protection (which still coerces to REPORT). Empty by default so
    # legacy AuditPlan envelopes constructed in tests / ``_resume``
    # without explicit overrides retain their behaviour.
    override_by_uid: Mapping[int, OrphanAction] = field(
        default_factory=lambda: MappingProxyType({})
    )
    # Pre-computed ``id → role`` index over ``role_catalog``, mirroring
    # ``role_name_by_id``. ``derive_managed_role_ids`` reuses it instead
    # of rebuilding the dict on every non-REPORT finding under the apply
    # lock. Defaulted so test fixtures that build AuditPlan with an
    # empty ``role_catalog`` do not need an explicit empty mapping.
    role_by_id: Mapping[int, RoleLike] = field(
        default_factory=lambda: MappingProxyType({})
    )

    def action_for(
        self,
        category: OrphanCategory,
        *,
        member_id: int | None = None,
        aa_user: AbstractUser | None = None,
    ) -> OrphanAction:
        """Resolve the action: per-uid override first, then per-user policy."""
        # ``member_id`` drives the per-uid override lookup; when a
        # non-expired ``FindingActionOverride`` exists, that action
        # wins over the policy (but still loses to
        # ``ProtectedDiscordMember`` which is checked separately).
        # ``aa_user`` drives the per-group / per-state policy
        # routing inside ``resolve_action`` — pass the linked AA
        # user when one is known so the operator's nested policy
        # (e.g. ``by_group={"Directors": "report"}``) can take
        # effect. ``None`` falls through to the policy entry's
        # ``default`` branch.
        if member_id is not None:
            override = self.override_by_uid.get(member_id)
            if override is not None:
                return override
        return resolve_action(
            category,
            policy=self.policy,
            cli_override=self.cli_override,
            aa_user=aa_user,
        )


@dataclass(frozen=True)
class AppliedAction:
    """Per-finding outcome from ``apply_plan``."""

    finding: OrphanFinding
    final_category: OrphanCategory | None
    action: OrphanAction
    role_ids_removed: frozenset[int]
    was_kicked: bool
    skipped_reason: SkipReason


@dataclass(frozen=True)
class AuditResult:
    """Aggregate outcome of ``apply_plan``."""

    run_id: int
    state: AuditRunState
    applied: tuple[AppliedAction, ...] = ()
    error_message: str = ""
