"""
``build_plan``: enumerate guild members, classify, persist PENDING run.

The build phase is read-only over Discord (a paginated
``guild_members`` walk plus one ``guild_roles`` snapshot) and
write-only over the local DB (one ``AuditRun`` insert plus a
batched ``bulk_create`` of ``AuditFinding`` rows). The
``last_seen_in_guild_at`` refresh that earlier revisions did
inline lives in ``apply_plan`` so build is also a no-op under
``--dry-run``.
"""

from __future__ import annotations

import logging
from datetime import timedelta
from itertools import chain, product
from types import MappingProxyType
from typing import TYPE_CHECKING, cast

from django.contrib.auth import get_user_model
from django.db.models import Q
from django.utils import timezone

from ..app_settings import (
    AA_DISCORD_AUDIT_POLICY,
    AA_DISCORD_AUDIT_RUN_DEADLINE_MINUTES,
)
from ..client import extract_member_user_id
from ..constants import PAGE_SIZE
from ..models import (
    AuditRun,
    AuditRunState,
    FindingActionOverride,
    OrphanAction,
    OrphanCategory,
    RunTrigger,
)
from ..persistence import bulk_create_findings
from ..policy import (
    build_bot_uids,
    build_discord_users_by_uid,
    build_identities_by_uid,
    build_users_with_discord_access,
    classify,
    policy_from_snapshot,
    policy_to_snapshot,
    resolve_action,
)
from ..reasons import build_reason
from ..role_filter import (
    aa_role_names,
    exempt_role_ids,
    reserved_role_names,
)
from ._types import AuditPlan, OrphanFinding

if TYPE_CHECKING:
    from datetime import datetime

    from django.contrib.auth.models import AbstractUser

    from ..client import AuditedDiscordClient
    from ..policy import Policy

logger = logging.getLogger(f"extensions.{__name__}")


class IdempotencyConflictError(Exception):
    """
    Raised when ``build_plan`` is called with an ``idempotency_key``
    that already exists on a previously-created ``AuditRun``.

    Carries the existing run's primary key and state so the caller
    can decide whether to wait (PENDING / RUNNING) or treat the
    prior outcome as the answer (SUCCEEDED / FAILED / etc.). The
    exception path is preferred over silently returning the prior
    plan because re-materialising the role catalog and operator
    config snapshot would defeat the dedup win — and because the
    caller's policy on "already exists" is genuinely ambiguous.
    """

    def __init__(self, run_id: int, state: str) -> None:
        super().__init__(
            f"AuditRun id={run_id} already exists with the same "
            f"idempotency key (state={state})"
        )
        self.run_id = run_id
        self.state = state


def build_plan(
    client: AuditedDiscordClient,
    *,
    triggered_by: RunTrigger = RunTrigger.CLI,
    triggered_by_user: AbstractUser | None = None,
    cli_argv: list[str] | None = None,
    confirmation_bypassed: bool = False,
    cli_override: OrphanAction | None = None,
    user_id_filter: int | None = None,
    limit: int | None = None,
    now: datetime | None = None,
    idempotency_key: str | None = None,
) -> AuditPlan:
    """
    Enumerate guild members, classify, persist PENDING run + findings.

    Returns the in-memory ``AuditPlan``. The caller passes it to
    ``apply_plan`` to execute.

    Pre-validation: ``build_reason()`` is called once with the
    longest expected enum combination so any future maintainer
    extending the reason format will see the validation failure
    at plan-build time, not mid-``apply_plan`` inside a
    ``transaction.atomic`` block.

    Optional ``idempotency_key`` (any string ≤ 64 chars; UUID4 is
    a typical choice) deduplicates retried triggers: a second
    call with the same key raises ``IdempotencyConflictError`` carrying
    the existing run's pk + state so the caller can decide whether
    to wait or treat the prior outcome as the answer.
    """
    from allianceauth.services.modules.discord.discord_client.models import (
        GuildMember,
    )

    if idempotency_key is not None:
        # Cheap pre-check before any Discord API call. The DB-level
        # uniqueness constraint still serves as the ultimate guard
        # if two callers race past this lookup, but raising early
        # avoids both wasted enumeration and a partial AuditRun row.
        existing = (
            AuditRun.objects.filter(idempotency_key=idempotency_key)
            .only("pk", "state")
            .first()
        )
        if existing is not None:
            raise IdempotencyConflictError(
                run_id=existing.pk, state=existing.state
            )

    now = now or timezone.now()
    deadline_at = now + timedelta(
        minutes=AA_DISCORD_AUDIT_RUN_DEADLINE_MINUTES
    )

    # Pre-validate every (category, action) reason combination so
    # apply_plan cannot raise ValueError mid-transaction. The earlier
    # form pre-tested only LINKED_NO_PERM x STRIP_KICK (the longest
    # *current* pair); a future enum extension with a longer value
    # would land at apply-time inside transaction.atomic. The full
    # cartesian product (~9 combinations) costs <1 ms and closes
    # the regression surface for good.
    for _category, _action in product(OrphanCategory, OrphanAction):
        build_reason(
            run_id=0,
            category=_category.value,
            action=_action.value,
        )

    # Resolve the frozen policy. ``MappingProxyType`` makes the policy
    # runtime-immutable to match the ``@dataclass(frozen=True)`` envelope
    # of ``AuditPlan`` it is going to live on.
    policy: Policy = MappingProxyType(
        policy_from_snapshot(AA_DISCORD_AUDIT_POLICY)
    )

    # Persist the AuditRun BEFORE any expensive enumeration so a
    # crash mid-pagination still leaves a tombstone the operator
    # can --abandon.
    run = AuditRun.objects.create(
        triggered_by=triggered_by,
        triggered_by_user=triggered_by_user,
        cli_argv=cli_argv or [],
        confirmation_bypassed=confirmation_bypassed,
        policy_snapshot=policy_to_snapshot(policy),
        state=AuditRunState.PENDING.value,
        idempotency_key=idempotency_key,
    )

    # Snapshot AA-side data the plan must freeze.
    aa_names = aa_role_names()
    reserved_names = reserved_role_names()
    bot_uid_set = build_bot_uids()
    exempt_ids = exempt_role_ids()
    # Pre-compute the perm set so per-member classify is O(1) instead
    # of running 3 SQL queries through the Django permission cache
    # for each guild member (was N+1 across thousands). Also snapshotted
    # onto the plan so the apply-time reclassify uses the same set;
    # users newly linked between build and apply land in the
    # ``has_perm`` fallback inside ``classify`` (a small N second pass
    # is acceptable; the build-time large N pass is what mattered).
    users_with_access = build_users_with_discord_access()
    # Pre-load DiscordUser and DiscordIdentity by uid so per-member
    # classify is O(1) instead of issuing two SELECTs per uid (was
    # 2N round-trips across the guild). The maps are local to
    # build_plan; the apply-time reclassify in ``_apply_finding``
    # intentionally uses the per-uid DB path so a row created
    # between build and apply is visible.
    discord_users_by_uid = build_discord_users_by_uid()
    identities_by_uid = build_identities_by_uid()

    # Pull guild + role catalog (single guild_roles call).
    from allianceauth.services.modules.discord.app_settings import (
        DISCORD_GUILD_ID,
    )

    role_catalog = tuple(client.guild_roles(guild_id=DISCORD_GUILD_ID))

    # Page through members and classify each.
    findings: list[OrphanFinding] = []
    members_scanned = 0
    update_seen_uids: list[int] = []

    members_iter = chain.from_iterable(
        client.guild_members(guild_id=DISCORD_GUILD_ID, limit=PAGE_SIZE)
    )
    for raw_member in members_iter:
        members_scanned += 1
        # Extract uid up-front so presence-tracking
        # (``last_seen_in_guild_at`` refresh) covers every
        # scanned member, even when ``--user-id`` filters
        # findings down to one. Conflating presence-tracking
        # with finding-eligibility caused identities outside
        # the filter to never get their ``last_seen`` bumped.
        member_user_id = extract_member_user_id(raw_member)
        if member_user_id is not None:
            update_seen_uids.append(member_user_id)
        if user_id_filter is not None and (
            member_user_id is None or member_user_id != user_id_filter
        ):
            continue
        member = GuildMember.from_dict(raw_member)
        category, aa_user = classify(
            member,
            bot_uids=bot_uid_set,
            users_with_access=users_with_access,
            discord_users_by_uid=discord_users_by_uid,
            identities_by_uid=identities_by_uid,
        )
        if category is None:
            continue  # active AA user with perm — not an orphan
        # Role derivation is per-finding work; ``_apply_finding``
        # re-runs ``derive_managed_role_ids`` /
        # ``persistent_role_ids_for`` against the live member at
        # apply time (member roles can change between build and
        # apply). Computing them here on every member just to
        # snapshot them onto a no-longer-read field was wasted
        # work — the dataclass fields ``aa_managed_role_ids`` /
        # ``aa_managed_role_names`` were never consumed.
        findings.append(
            OrphanFinding(
                member_id=member.user.id,
                member_username=member.user.username,
                category=category,
                aa_user_id=aa_user.pk if aa_user is not None else None,
            )
        )
        if limit is not None and len(findings) >= limit:
            break

    # Persist findings, then snapshot counters. ``bulk_create`` is
    # already auto-committed per batch by Django; wrapping all 100
    # batches (50k findings) in a single ``transaction.atomic``
    # holds the connection ``idle in transaction`` for the duration
    # and amplifies tail latency under replica lag. The
    # ``findings_count`` field is documented as a cached count, not
    # a hard invariant — a crash between ``bulk_create`` and the
    # counter save leaves the rows persisted with the run still
    # showing ``findings_count=0``; ``--resume`` then walks the rows
    # via ``state=PENDING`` regardless of the cached count, so the
    # forensic trail is correct even when the cache is stale.
    # Pre-load AA users referenced by findings + their groups + state
    # so per-finding action resolution against the new per-group /
    # per-state policy is O(1) inside the bulk_create loop. Misses
    # (aa_user_id IS NULL or User row deleted between classify() and
    # this lookup) fall through to ``_resolve_against_user`` which
    # treats ``aa_user=None`` as "default branch only".

    user_model = get_user_model()
    user_ids = {f.aa_user_id for f in findings if f.aa_user_id is not None}
    # Cast: the configured user model is always an ``AbstractUser``
    # subclass with ``PermissionsMixin``; the django-stubs return type
    # is the wider ``AbstractBaseUser`` because ``get_user_model`` is
    # polymorphic over ``AUTH_USER_MODEL``.
    users_by_id: dict[int, AbstractUser] = (
        {
            u.pk: cast("AbstractUser", u)
            for u in user_model.objects.filter(
                pk__in=user_ids
            ).prefetch_related("groups", "profile__state")
        }
        if user_ids
        else {}
    )

    def _per_finding_action(f: OrphanFinding) -> OrphanAction:
        aa_user = (
            users_by_id.get(f.aa_user_id) if f.aa_user_id is not None else None
        )
        return resolve_action(
            f.category,
            policy=policy,
            cli_override=cli_override,
            aa_user=aa_user,
        )

    bulk_create_findings(
        run,
        findings,
        action_resolver=_per_finding_action,
    )
    run.members_scanned = members_scanned
    run.findings_count = len(findings)
    run.save(update_fields=["members_scanned", "findings_count"])

    # Snapshot per-uid action overrides active at build time. Filter
    # at the SQL layer so we do not load expired rows; the
    # resulting MappingProxyType is consulted by
    # ``AuditPlan.action_for`` instead of running a per-finding
    # lookup at apply time.
    override_by_uid = MappingProxyType(
        {
            o.member_id: OrphanAction(o.action)
            for o in FindingActionOverride.objects.filter(
                Q(expires_at__isnull=True) | Q(expires_at__gt=now)
            )
        }
    )

    return AuditPlan(
        run_id=run.pk,
        guild_id=int(DISCORD_GUILD_ID),
        findings=tuple(findings),
        role_catalog=role_catalog,
        role_name_by_id=MappingProxyType({r.id: r.name for r in role_catalog}),
        aa_role_names=aa_names,
        reserved_role_names=reserved_names,
        bot_uids=bot_uid_set,
        exempt_role_ids=exempt_ids,
        observed_uids=frozenset(update_seen_uids),
        users_with_access=users_with_access,
        policy=policy,
        cli_override=cli_override,
        started_at=run.started_at,
        deadline_at=deadline_at,
        override_by_uid=override_by_uid,
    )
