"""
``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 contextlib import contextmanager
from typing import TYPE_CHECKING, cast

from django.core.management.base import BaseCommand

from ._audit._stubs import _RoleStub

if TYPE_CHECKING:
    import argparse
    from collections.abc import Generator

    from ...client import AuditedDiscordClient


@contextmanager
def _ensure_initial_ack(*, cleanup: bool) -> Generator[None, None, None]:
    """Insert ``InitialAuditAcknowledgement`` if missing; remove only the row we created."""  # noqa: E501
    # Without an ack row, ``apply_plan`` coerces every action to
    # REPORT, which defeats the apply-side measurement. The bench
    # creates the row only when the operator did not already have
    # one, so a real-world ack always survives. With ``cleanup=False``
    # the synthesised row is also kept, mirroring ``--no-cleanup``.
    from ...models import InitialAuditAcknowledgement

    was_present = InitialAuditAcknowledgement.objects.exists()
    if not was_present:
        InitialAuditAcknowledgement.objects.create()
    try:
        yield
    finally:
        if cleanup and not was_present:
            InitialAuditAcknowledgement.objects.all().delete()


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 handle(self, *args, **options) -> None:
        size = int(options["size"])
        if size <= 0:
            self.stdout.write(self.style.ERROR("--size must be positive"))
            return

        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
        with _ensure_initial_ack(cleanup=cleanup):
            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()
