"""
``_apply_finding`` orchestrator + three unit-test entry-point shims.

``_apply_finding`` owns the pre-executor resolution (db_finding lookup,
INTERNAL_INCONSISTENCY short-circuit, PROTECTED_CONFIG static check,
policy ``action_for``) and then hands off to
``_ApplyFindingExecutor.execute()`` for the destructive sandwich.

The three module-level shim functions (``_emit_skipped`` / ``_emit_failed``
/ ``_phase1_lock_reclassify``) are unit-test entry points: tests in
``tests/test_audit_apply_plan.py`` import each one directly and
exercise it in isolation, so the executor construction stays one line
per test instead of repeated executor plumbing. The remaining executor
phases (``phase2_strip_roles`` / ``phase3_persist`` /
``http_error_or_auth``) are tested via the full ``execute()`` lane and
have no module-level shim.
"""

from __future__ import annotations

from types import MappingProxyType
from typing import TYPE_CHECKING, cast

from ...models import (
    AuditFinding,
    FindingSkipReason,
    OrphanAction,
)
from ...persistence import increment_run_counters
from ._executor import _ApplyFindingExecutor
from ._helpers import _skipped_action

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

    from django.contrib.auth.models import AbstractUser

    from ...client import AuditedDiscordClient
    from ...models import AuditRun, OrphanCategory
    from .._types import AppliedAction, AuditPlan, OrphanFinding
    from ._executor import _Phase1Done, _Phase1Skipped


# Default users_by_id for the unit-test entry points that exercise
# individual phases without standing up _walk_findings / run_resume
# (those production callers always preload via
# preload_users_for_findings). On a miss the policy resolver treats
# the linked user as aa_user=None — see _apply_finding below.
#
# DANGER: production callers MUST pass an explicit preloaded map.
# Falling through to this default in the apply/resume hot paths would
# silently coerce every linked-user finding to the policy ``default``
# branch (no per-group / per-state routing) — a destructive-action
# regression with no log line. The empty default exists ONLY for
# tests that exercise single phases without policy routing. Adding a
# new caller of ``_apply_finding`` requires preloading the map
# upstream and threading it through.
_EMPTY_USERS_BY_ID: Mapping[int, AbstractUser] = MappingProxyType({})


def preload_users_for_findings(
    aa_user_ids: Iterable[int | None],
) -> dict[int, AbstractUser]:
    """
    Preload AA users referenced by ``aa_user_ids`` with groups + state.

    Returns a ``{pk: User}`` map for every non-``None`` id; ``groups``
    + ``profile.state`` are prefetched so per-finding policy resolution
    (per-group / per-state routing in ``policy._resolve_against_user``)
    is O(1) instead of N+1. Misses (``aa_user_id is None`` or the row
    was deleted between build / classify and this lookup) fall
    through to ``aa_user=None`` at the callsite, which the resolver
    treats as "default branch only".

    Callers pass ``f.aa_user_id`` directly so the helper does not
    couple to either ``OrphanFinding`` (in-memory dataclass) or
    ``AuditFinding`` (Django model) shapes — both expose the column
    under the same attribute name and the helper need only see the
    column values.

    The single helper backs ``build_plan`` (bulk_create_findings
    resolver), ``_walk_findings`` (apply pass), and ``run_resume``
    (resume pass) — the resume path previously skipped this preload
    and silently bypassed per-group / per-state routing under
    ``_EMPTY_USERS_BY_ID``.
    """
    # ``get_user_model`` is imported inside the function so the orch
    # module stays importable before Django's apps registry is ready
    # (mirrors build_plan's existing pattern).
    from django.contrib.auth import get_user_model

    user_model = get_user_model()
    user_ids = {uid for uid in aa_user_ids if uid is not None}
    # ``cast``: ``get_user_model().objects`` is typed
    # ``AbstractBaseUser`` (the polymorphic base), but the configured
    # user model is always an ``AbstractUser`` subclass with
    # ``PermissionsMixin`` — groups + profile.state attributes are
    # accessed downstream. Django optimises an empty ``pk__in`` to
    # ``WHERE FALSE`` so the empty-set branch is a no-op.
    return {
        u.pk: cast("AbstractUser", u)
        for u in user_model.objects.filter(pk__in=user_ids).prefetch_related(
            "groups", "profile__state"
        )
    }


# --- Unit-test entry points ---------------------------------------
#
# Three tiny wrappers around executor methods. Each is imported
# directly by ``tests/test_audit_apply_plan.py`` so a single phase or
# emit branch can be exercised without building the surrounding
# ``execute()`` pipeline. Nothing here is the source of truth — the
# executor methods are.


def _emit_skipped(
    *,
    db_finding: AuditFinding,
    finding: OrphanFinding,
    run: AuditRun,
    skipped_reason: FindingSkipReason,
    action: OrphanAction,
    final_category: OrphanCategory | None = None,
    role_ids_removed: frozenset[int] = frozenset(),
) -> AppliedAction:
    """Shim that delegates to ``_ApplyFindingExecutor.emit_skipped``."""
    return _ApplyFindingExecutor(
        finding=finding,
        run=run,
        db_finding=db_finding,
        action=action,
    ).emit_skipped(
        skipped_reason=skipped_reason,
        final_category=final_category,
        role_ids_removed=role_ids_removed,
    )


def _emit_failed(
    *,
    db_finding: AuditFinding,
    finding: OrphanFinding,
    run: AuditRun,
    skipped_reason: FindingSkipReason,
    action: OrphanAction,
    error_message: str,
    role_ids_removed: frozenset[int] = frozenset(),
) -> AppliedAction:
    """Shim that delegates to ``_ApplyFindingExecutor.emit_failed``."""
    return _ApplyFindingExecutor(
        finding=finding,
        run=run,
        db_finding=db_finding,
        action=action,
    ).emit_failed(
        skipped_reason=skipped_reason,
        error_message=error_message,
        role_ids_removed=role_ids_removed,
    )


def _phase1_lock_reclassify(
    plan: AuditPlan,
    finding: OrphanFinding,
    client: AuditedDiscordClient,
    run: AuditRun,
    db_finding: AuditFinding,
    action: OrphanAction,
) -> _Phase1Skipped | _Phase1Done:
    """Shim — see ``_ApplyFindingExecutor.phase1_lock_reclassify``."""
    return _ApplyFindingExecutor(
        plan=plan,
        client=client,
        finding=finding,
        run=run,
        db_finding=db_finding,
        action=action,
    ).phase1_lock_reclassify()


def _apply_finding(
    plan: AuditPlan,
    finding: OrphanFinding,
    client: AuditedDiscordClient,
    run: AuditRun,
    protected_uids: frozenset[int],
    users_by_id: Mapping[int, AbstractUser] = _EMPTY_USERS_BY_ID,
) -> AppliedAction:
    """
    Per-finding action loop body.

    Orchestrates the pre-executor short-circuits + the executor's
    three-phase destructive sandwich:

    1. Lookup ``db_finding`` (early return if build/apply disagree).
    2. ``ProtectedDiscordMember`` static check — wins over policy
       and per-uid overrides.
    3. Resolve the action via the plan (per-uid override → per-group
       / per-state policy → default).
    4. Hand off to ``_ApplyFindingExecutor.execute()``, which fans
       out into REPORT short-circuit or phase 1 / 2 / 3.

    ``users_by_id`` is the apply-pass preload of every ``User``
    referenced by ``plan.findings`` (with ``groups`` + ``profile.state``
    prefetched). Lookup is O(1); a miss means the user row was deleted
    between build and apply and the policy falls through to its
    ``default`` branch via ``aa_user=None``.
    """
    db_finding = AuditFinding.objects.filter(
        run=run,
        member_id=finding.member_id,
    ).first()
    if db_finding is None:
        # Unexpected; build_plan should have persisted every finding.
        # This branch fires only when build_plan and apply_plan
        # disagreed on which findings exist (typically: an admin
        # deleted the AuditFinding row out-of-band between phases).
        # Bump ``actions_skipped`` to keep the run summary's
        # ``actions_applied + actions_skipped == findings_count``
        # invariant intact for forensic admin views — otherwise the
        # run reports fewer actions than findings without explanation.
        # ``aa_user`` is left unresolved here: the persisted intent
        # column is the operator-visible field, and there is no DB
        # row to update either way.
        increment_run_counters(run, actions_skipped=1)
        return _skipped_action(
            finding,
            skipped_reason=FindingSkipReason.INTERNAL_INCONSISTENCY,
            action=plan.action_for(
                finding.category, member_id=finding.member_id
            ),
        )

    # Static protection check (admin-managed list). Wins over the
    # policy default AND any ``FindingActionOverride`` for the same
    # uid: the protected list is the strongest "do nothing" signal,
    # and an operator who set both is unambiguously asking for
    # protection.
    if finding.member_id in protected_uids:
        return _ApplyFindingExecutor(
            finding=finding,
            run=run,
            db_finding=db_finding,
            action=OrphanAction.REPORT,
        ).emit_skipped(skipped_reason=FindingSkipReason.PROTECTED_CONFIG)

    # ``aa_user`` resolved from the pre-fetched ``users_by_id`` map
    # (built once by ``_walk_findings`` / ``run_resume`` via
    # ``preload_users_for_findings``) so per-group / per-state policy
    # routing fires for any resolved action without re-issuing the
    # User+groups+profile.state SELECT per finding (was N+1 across
    # non-protected findings).
    aa_user_for_policy = (
        users_by_id.get(finding.aa_user_id)
        if finding.aa_user_id is not None
        else None
    )
    action = plan.action_for(
        finding.category,
        member_id=finding.member_id,
        aa_user=aa_user_for_policy,
    )

    return _ApplyFindingExecutor(
        plan=plan,
        client=client,
        finding=finding,
        run=run,
        db_finding=db_finding,
        action=action,
    ).execute()
