"""
``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

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

from django.db.models import Q
from django.utils import timezone

from .._log import get_logger
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 ..exceptions import AaDiscordAuditError
from ..models import (
    AuditRun,
    AuditRunState,
    FindingActionOverride,
    OrphanAction,
    OrphanCategory,
    RunTrigger,
)
from ..persistence import bulk_create_findings
from ..policy import (
    assemble_classifier_context,
    policy_from_snapshot,
    policy_to_snapshot,
    resolve_action,
)
from ..reasons import build_reason
from ..role_filter import (
    fetch_aa_role_names,
    fetch_exempt_role_ids,
    fetch_reserved_role_names,
)
from ._finding import preload_users_for_findings
from ._types import AuditPlan, OrphanFinding

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

    from django.contrib.auth.models import AbstractUser

    from ..client import AuditedDiscordClient
    from ..policy import ClassifierContext, Policy

logger = get_logger(__name__)


class IdempotencyConflictError(AaDiscordAuditError):
    """
    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(  # noqa: PLR0913
    client: AuditedDiscordClient,
    *,
    triggered_by: RunTrigger = RunTrigger.CLI,
    triggered_by_user: AbstractUser | None = None,
    cli_argv: list[str] | None = None,
    was_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,
    existing_run: AuditRun | 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.

    Optional ``existing_run`` lets a caller pre-create the ``AuditRun``
    row and have ``build_plan`` reuse it instead of inserting a new
    one. Passing both ``existing_run`` and ``idempotency_key`` raises
    ``ValueError`` — the row already owns its key. The row is updated
    in-place with build-phase outputs (``members_scanned``,
    ``findings_count``).
    """
    if existing_run is not None and idempotency_key is not None:
        raise ValueError(
            "build_plan: pass either existing_run or idempotency_key, "
            "not both — the existing row already owns its key."
        )

    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.
    if existing_run is None:
        run = AuditRun.objects.create(
            triggered_by=triggered_by,
            triggered_by_user=triggered_by_user,
            cli_argv=cli_argv or [],
            was_confirmation_bypassed=was_confirmation_bypassed,
            policy_snapshot=policy_to_snapshot(policy),
            state=AuditRunState.PENDING.value,
            idempotency_key=idempotency_key,
        )
    else:
        run = existing_run

    # Snapshot AA-side data the plan must freeze.
    aa_names = fetch_aa_role_names()
    reserved_names = fetch_reserved_role_names()
    exempt_ids = fetch_exempt_role_ids()
    # Pre-compute the classify inputs once so per-member classify is
    # O(1) instead of N+1 SQL across the guild. ``bot_uids`` and
    # ``users_with_access`` are 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). The apply-time reclassify in
    # ``_apply_finding`` intentionally uses the per-uid DB path so a
    # row created between build and apply is visible.
    ctx = assemble_classifier_context()

    # 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))

    findings, members_scanned, update_seen_uids = _enumerate_and_classify(
        client=client,
        ctx=ctx,
        user_id_filter=user_id_filter,
        limit=limit,
    )

    # 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".
    users_by_id = preload_users_for_findings(f.aa_user_id for f in findings)

    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}),
        role_by_id=MappingProxyType({r.id: r for r in role_catalog}),
        aa_role_names=aa_names,
        reserved_role_names=reserved_names,
        bot_uids=ctx.bot_uids,
        exempt_role_ids=exempt_ids,
        observed_uids=frozenset(update_seen_uids),
        users_with_access=ctx.users_with_access,
        policy=policy,
        cli_override=cli_override,
        started_at=run.started_at,
        deadline_at=deadline_at,
        override_by_uid=override_by_uid,
    )


def _enumerate_and_classify(
    *,
    client: AuditedDiscordClient,
    ctx: ClassifierContext,
    user_id_filter: int | None,
    limit: int | None,
) -> tuple[list[OrphanFinding], int, list[int]]:
    """
    Page through guild members, classify each, build the findings list.

    Extracted from ``build_plan`` (Wave-6 decomposition) to drop the
    parent's cyclomatic complexity. Returns ``(findings, scanned, seen)``
    where:
    - ``findings`` — every classifier-positive member as an
      ``OrphanFinding`` (respecting ``user_id_filter`` and ``limit``);
    - ``scanned`` — every page-yielded member, regardless of filter,
      so the run's ``members_scanned`` counter reflects pagination
      effort honestly;
    - ``seen`` — every uid we could decode from the raw payload, so
      ``last_seen_in_guild_at`` refresh covers presence-tracking
      even when ``--user-id`` filters findings down to one. Conflating
      presence-tracking with finding-eligibility previously caused
      identities outside the filter to never get their ``last_seen``
      bumped — preserved verbatim here.
    """
    # AA-core imports are function-local to keep this module loadable
    # before Django's apps registry is ready (mirrors ``build_plan``).
    from allianceauth.services.modules.discord.app_settings import (
        DISCORD_GUILD_ID,
    )
    from allianceauth.services.modules.discord.discord_client.models import (
        GuildMember,
    )

    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
        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 = ctx.classify(member)
        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, because member roles can change between build
        # and apply.
        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
    return findings, members_scanned, update_seen_uids


def iter_classified_members(
    client: AuditedDiscordClient,
    ctx: ClassifierContext,
) -> Generator[tuple[int, OrphanCategory], None, None]:
    """
    Yield ``(uid, category)`` for each classified live guild member.

    Shared by ``--diff`` and ``--policy-preview``, which both walk
    the live guild members, classify each, and act on the category.
    ``build_plan`` has its own loop with presence-tracking +
    ``--user-id`` filter bookkeeping and does not use this helper.

    The lazy ``GuildMember`` import mirrors ``build_plan``'s pattern:
    AA-core's discord-client module is heavy at import time, so the
    audit's classify-less code paths (settings validators, app
    config) stay free of that load.
    """
    from allianceauth.services.modules.discord.app_settings import (
        DISCORD_GUILD_ID,
    )
    from allianceauth.services.modules.discord.discord_client.models import (
        GuildMember,
    )

    members_iter = chain.from_iterable(
        client.guild_members(guild_id=DISCORD_GUILD_ID, limit=PAGE_SIZE)
    )
    for raw_member in members_iter:
        uid = extract_member_user_id(raw_member)
        if uid is None:
            continue
        member = GuildMember.from_dict(raw_member)
        category, _aa_user = ctx.classify(member)
        if category is None:
            continue
        yield uid, category
