"""
``audit_benchmark`` -- synthetic-load harness for build_plan / apply_plan.

The audit's hot paths run on hosts where 5k-50k Discord members
get classified per pass. Operators sizing their deployment need
representative timings for ``build_plan`` (read-only over Discord,
write-only over the audit DB) and ``apply_plan`` (read+write
across both, but on ``--policy report`` the Discord side is a no-op
beyond the per-finding live-member fetch). This command synthesises
a guild of N members, runs both phases, and reports per-phase
elapsed time + counts.

The fake client mirrors the surface ``build_plan`` actually
consumes: ``guild_roles`` (one call) and ``guild_members``
(paginated). Members are fully classified -- every uid is an
UNKNOWN_GUEST by default, which exercises the hot loop without
seeding the DB-heavy LINKED_NO_PERM branches.

Run via ``python manage.py audit_benchmark --size 1000``. The
``--cleanup`` flag (default on) deletes the AuditRun + findings
created during the measurement so a benchmark run does not
pollute the operator's real audit history.
"""

from __future__ import annotations

import time
from typing import TYPE_CHECKING, cast

from django.core.management.base import BaseCommand, CommandError

from ._audit._fixture_client import _RoleStub

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

    from ...client import AuditedDiscordClient


class _BenchClient:
    """
    Fake ``AuditedDiscordClient`` consumed by ``build_plan``.

    Generates ``guild_members`` pages from a deterministic seed so
    successive runs on the same ``--size`` produce the same shape.
    ``guild_member`` (the apply-time live-member fetch) returns
    the same record the enumeration would have returned, keyed by
    uid — so ``apply_plan``'s reclassify lands on the same
    classification the build-time pass produced.
    """

    PAGE_SIZE = 1000

    def __init__(self, member_count: int) -> None:
        self._member_count = member_count
        # A handful of role ids the bot would see as "everyone" + a
        # couple of decorative roles. Keeps the catalog non-empty so
        # ``role_catalog`` snapshotting is exercised.
        self._roles = [
            _RoleStub(role_id=1, name="@everyone"),
            _RoleStub(role_id=100, name="bench-decorative-1"),
            _RoleStub(role_id=200, name="bench-decorative-2"),
        ]

    def guild_roles(self, guild_id: object) -> list[_RoleStub]:
        del guild_id
        return list(self._roles)

    def _make_member(self, uid: int) -> dict[str, object]:
        return {
            "user": {
                "id": uid,
                "username": f"bench-{uid}",
                "discriminator": "0",
            },
            "nick": None,
            "roles": [1],
        }

    def guild_members(
        self, *, guild_id: object, limit: int
    ) -> Generator[list[dict[str, object]], None, None]:
        del guild_id, limit
        for start in range(1, self._member_count + 1, self.PAGE_SIZE):
            end = min(start + self.PAGE_SIZE, self._member_count + 1)
            yield [self._make_member(uid) for uid in range(start, end)]

    def guild_member(
        self, *, guild_id: object, user_id: int
    ) -> dict[str, object]:
        del guild_id
        return self._make_member(user_id)


class Command(BaseCommand):
    help = (
        "Run a synthetic build_plan + apply_plan against an "
        "in-memory fake guild and report per-phase timings."
    )

    def add_arguments(self, parser: argparse.ArgumentParser) -> None:
        parser.add_argument(
            "--size",
            type=int,
            default=1000,
            help="Number of synthetic guild members. Default 1000.",
        )
        parser.add_argument(
            "--no-apply",
            action="store_true",
            help=(
                "Skip apply_plan (only measure build_plan). Useful "
                "for read-only benchmarks of large guilds."
            ),
        )
        parser.add_argument(
            "--no-cleanup",
            action="store_true",
            help=(
                "Keep the synthesised AuditRun + findings in the DB "
                "after the run. Default deletes them."
            ),
        )

    def _require_run_audit_actor(self) -> None:
        """
        Refuse unless the resolved CLI actor holds ``run_audit``.

        The benchmark drives a real ``apply_plan``, so it must not be a
        perm-free bypass of the audit's authorization gate. Reuses the
        shared CLI actor resolution (the configured actor setting or the
        OS-login convention) so it gates identically to the real audit
        command. No ``AuditInvocation`` is written — the benchmark is a
        measurement tool, not an audited operation.
        """
        from ...permissions import RUN_AUDIT
        from ._audit._invocation import resolve_cli_actor

        actor = resolve_cli_actor()
        if actor is None:
            raise CommandError(
                "no Django user could be resolved as the CLI actor; "
                f"cannot verify {RUN_AUDIT} for audit_benchmark. Set "
                "AA_DISCORD_AUDIT_CLI_ACTOR or match the OS login to a "
                "Django username."
            )
        if not actor.has_perm(RUN_AUDIT):
            raise CommandError(
                f"{RUN_AUDIT} permission required to run audit_benchmark."
            )

    def handle(self, *args, **options) -> None:
        self._require_run_audit_actor()

        size = int(options["size"])
        if size <= 0:
            # Raise (non-zero exit) rather than stdout+return: a CI /
            # cron wrapper checking the exit code must not treat an
            # invalid --size as success. Matches the rest of the
            # package's CommandError convention.
            raise CommandError("--size must be positive")

        from ...audit import apply_plan, build_plan
        from ...models import AuditRun, RunTrigger

        cleanup = not options["no_cleanup"]
        client = _BenchClient(member_count=size)
        run_id: int | None = None
        # No ack row is created: the benchmark respects the first-run
        # lock like every other caller. On a system that has released
        # the lock the apply path measures the real cost; on a fresh
        # install it measures the REPORT-coerced path. Either way the
        # bench never touches the global InitialAuditAcknowledgement
        # interlock, so it can never transiently release the lock for a
        # concurrent live run.
        try:
            self.stdout.write(f"Synthesising guild of {size} members...\n")

            t0 = time.perf_counter()
            # Two-step cast through ``object`` because ``_BenchClient``
            # is a structural subset of ``AuditedDiscordClient`` (only
            # the methods ``build_plan`` / ``apply_plan`` reach for);
            # pyright refuses the direct narrowing and asks for the
            # explicit ``object`` round-trip.
            plan = build_plan(
                cast("AuditedDiscordClient", cast("object", client)),
                triggered_by=RunTrigger.MANUAL,
            )
            t_build = time.perf_counter() - t0
            run_id = plan.run_id
            findings_count = len(plan.findings)

            self.stdout.write(
                self.style.SUCCESS(
                    f"build_plan: {t_build:.3f}s "
                    f"(findings={findings_count}, scanned={size})"
                )
            )
            if t_build > 0:
                self.stdout.write(
                    f"  throughput: {size / t_build:,.0f} members/s, "
                    f"{findings_count / t_build:,.0f} findings/s"
                )

            if not options["no_apply"]:
                t0 = time.perf_counter()
                result = apply_plan(
                    plan,
                    cast("AuditedDiscordClient", cast("object", client)),
                )
                t_apply = time.perf_counter() - t0

                self.stdout.write(
                    self.style.SUCCESS(
                        f"apply_plan: {t_apply:.3f}s "
                        f"(state={result.state.value}, "
                        f"applied={len(result.applied)})"
                    )
                )
                if t_apply > 0 and findings_count > 0:
                    self.stdout.write(
                        f"  throughput: "
                        f"{findings_count / t_apply:,.0f} findings/s"
                    )

        finally:
            if cleanup and run_id is not None:
                AuditRun.objects.filter(pk=run_id).delete()
