"""
``audit_discord_roles`` management command.

Operator-facing entry point.

Subcommand bodies live in the private ``_audit`` package alongside
this module; this file holds only the argparse skeleton, the
signal-scope context manager, and the dispatcher that routes each
subcommand to its handler.
"""

from __future__ import annotations

import contextlib
import signal
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final, cast

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

from ..._log import get_logger
from ..._redaction import log_redacted_exception
from ...models import AuditRun, AuditRunState
from ._audit import (
    run_abandon,
    run_diff,
    run_explain,
    run_from_fixture,
    run_main,
    run_policy_preview,
    run_resume,
)
from ._audit._invocation import cli_actor_scope, resolve_cli_actor

if TYPE_CHECKING:  # pragma: no cover
    import argparse
    from collections.abc import Callable, Generator
    from types import FrameType

    from ._audit import CommandOptions

logger = get_logger(__name__)


@dataclass(frozen=True, slots=True)
class _Sub:
    """Static description of one ``audit_discord_roles`` subcommand."""

    # Argparse ``dest`` the subcommand binds to. Exactly one subcommand
    # runs per invocation; combining two (e.g. ``--explain`` with
    # ``--diff``) is refused with a ``CommandError`` in
    # ``_dispatch_subcommand`` rather than silently running the first.
    opt_key: str
    # Operator-facing flag label used in ``_require_positive_arg`` /
    # CommandError messages so the error names the flag the user typed
    # rather than the dest.
    flag_label: str
    # The handler imported from ``_audit``. Called as
    # ``handler(self, value)`` or ``handler(self, value, json_output=…)``
    # depending on ``passes_json``. The two call shapes share the same
    # field, kept loose because per-handler argument types diverge
    # (``run_explain`` takes ``member_id: int`` while ``run_from_fixture``
    # takes ``path: str``) and a unifying Protocol would break Liskov.
    handler: Callable[..., None]
    # Numeric flags (``--resume``, ``--explain``, …) must reject
    # ``0`` / negatives upfront — argparse only narrows to int.
    # Path / JSON-string flags (``--from-fixture``, ``--policy-preview``)
    # skip the check.
    require_positive: bool
    # Whether the handler accepts ``json_output=`` (true for the
    # diagnostic subcommands, false for the run-mutating ones).
    passes_json: bool


_SUBCOMMANDS: Final[tuple[_Sub, ...]] = (
    _Sub("explain_member_id", "--explain", run_explain, True, True),
    _Sub("diff_run_id", "--diff", run_diff, True, True),
    _Sub("from_fixture", "--from-fixture", run_from_fixture, False, True),
    _Sub(
        "policy_preview", "--policy-preview", run_policy_preview, False, True
    ),
    _Sub("abandon_run_id", "--abandon", run_abandon, True, False),
    _Sub("resume_run_id", "--resume", run_resume, True, False),
)


class Command(BaseCommand):
    help = (
        "Run a Discord guild audit, resume / abandon an existing run, or "
        "produce a JSON summary."
    )

    # Run id of the currently-active run, set by the run_main /
    # run_resume handlers via ``track_active_run``. The signal
    # handler does not touch this directly; it only flips
    # ``_signal_received`` so the main ``finally`` block can do the
    # ORM write outside the signal context. Lives as an instance
    # attribute (initialised in ``__init__``) so concurrent
    # ``Command()`` instances do not alias state via the class.
    _active_run_id: int | None
    _signal_received: int | None

    def __init__(self, *args: object, **kwargs: object) -> None:  # noqa: D107 -- private __init__ on BaseCommand subclass; intent documented via the surrounding class docstring
        # Type-erase via Any so BaseCommand's *args/**kwargs surface
        # accepts the call without the type checkers complaining
        # about the parent's untyped signature.
        super().__init__(*args, **kwargs)  # type: ignore[arg-type]
        self._active_run_id = None
        self._signal_received = None

    def add_arguments(self, parser: argparse.ArgumentParser) -> None:
        parser.add_argument(
            "--action",
            choices=["report", "strip", "strip_kick"],
            help="Override resolved policy for the whole run.",
        )
        parser.add_argument(
            "--limit",
            type=int,
            help="Cap on findings (debugging).",
        )
        parser.add_argument(
            "--user-id",
            type=int,
            help="Process exactly one Discord user id.",
        )
        parser.add_argument(
            "--no-notify",
            action="store_true",
            help="Suppress AA-notify and webhook channels.",
        )
        parser.add_argument(
            "--yes",
            action="store_true",
            help="Skip interactive confirmation. Recorded on AuditRun.",
        )
        parser.add_argument(
            "--json",
            action="store_true",
            help="Emit AuditResult as JSON to stdout.",
        )
        parser.add_argument(
            "--resume",
            type=int,
            dest="resume_run_id",
            help=(
                "Resume a PENDING run (refused under first-run lock — "
                "the break-glass safety latch released by "
                "audit_acknowledge_initial)."
            ),
        )
        parser.add_argument(
            "--abandon",
            type=int,
            dest="abandon_run_id",
            help="Mark a run as ABANDONED.",
        )
        parser.add_argument(
            "--explain",
            type=int,
            dest="explain_member_id",
            help=(
                "Read-only diagnostic: show how the policy resolves for "
                "one Discord member id without persisting an AuditRun."
            ),
        )
        parser.add_argument(
            "--diff",
            type=int,
            dest="diff_run_id",
            help=(
                "Read-only diagnostic: compare current would-be findings "
                "to the persisted findings of an existing AuditRun."
            ),
        )
        parser.add_argument(
            "--from-fixture",
            type=str,
            dest="from_fixture",
            help=(
                "Repro mode: build_plan against a JSON fixture instead "
                "of the live Discord client; apply_plan is skipped."
            ),
        )
        parser.add_argument(
            "--policy-preview",
            type=str,
            dest="policy_preview",
            help=(
                "Read-only diagnostic: project a hypothetical policy "
                "(JSON object {category: action}) against the live "
                "guild state; no AuditRun is persisted."
            ),
        )

    def handle(self, *args, **options) -> None:
        # Capture the operator for ConfigChangeLog attribution if any
        # downstream code triggers a tracked-model write. The check
        # uses _called_from_command_line (set by BaseCommand.run_from_argv)
        # to skip programmatic call_command() invocations, which inject
        # actor through their own path.
        #
        # ``resolve_cli_actor`` is the SAME resolver the perm gate
        # consumes (it honors ``AA_DISCORD_AUDIT_CLI_ACTOR``), so the
        # attribution and the gate always agree on the actor identity —
        # resolving once here prevents the gate and trail from diverging.
        # It returns a validated User OBJECT
        # — passing the resolved object (not a username string) to
        # ``set_command_user`` keeps it from re-resolving independently
        # without the is_active / perm checks. Resolving here also
        # gates-before-attribute: a set-but-invalid setting raises a
        # setting-named ``CommandError`` BEFORE any work.
        # ``cli_actor_scope`` owns the ContextVar cleanup; the
        # ``hasattr(_called_from_command_line)`` check decides WHO to
        # attribute (None for programmatic ``call_command`` paths,
        # which inject the actor through their own route).
        actor = (
            resolve_cli_actor()
            if hasattr(self, "_called_from_command_line")
            else None
        )
        with cli_actor_scope(actor), self._signal_scope():
            opts: CommandOptions = cast(
                "CommandOptions", cast("object", options)
            )
            if self._dispatch_subcommand(opts):
                return
            verbosity = int(cast("int", options.get("verbosity", 1)))
            run_main(self, opts, verbosity=verbosity)

    @contextlib.contextmanager
    def _signal_scope(self) -> Generator[None, None, None]:
        """Install SIGTERM/SIGINT handlers, restore + flush on exit."""
        # The handler itself does NOT touch the ORM (re-entrant DB
        # calls from a Python signal context can deadlock if the
        # main thread holds a transaction); it only sets
        # ``_signal_received`` and arranges for the main loop to
        # observe it via ``KeyboardInterrupt`` (SIGINT) or
        # ``SystemExit(128 + signum)`` (SIGTERM, the supervised-
        # shutdown convention).
        #
        # The ``_active_run_id`` / ``_signal_received`` reset MUST
        # happen AFTER ``_flush_signal_to_run``: clearing earlier
        # (e.g. in an inner ``finally`` around ``apply_plan``) races
        # the signal-flush path for SIGTERM/SIGINT delivered mid-run
        # and silently leaves the AuditRun stuck in RUNNING.
        previous_sigterm = signal.signal(signal.SIGTERM, self._on_signal)
        previous_sigint = signal.signal(signal.SIGINT, self._on_signal)
        try:
            yield
        finally:
            signal.signal(signal.SIGTERM, previous_sigterm)
            signal.signal(signal.SIGINT, previous_sigint)
            self._flush_signal_to_run()
            self._active_run_id = None
            self._signal_received = None
            # ContextVar cleanup is owned by the surrounding
            # ``cli_actor_scope`` (see ``handle``); the outer
            # ``__exit__`` runs after this one because
            # ``cli_actor_scope`` is entered first.

    def _on_signal(
        self,
        signum: int,
        _frame: FrameType | None,
    ) -> None:
        """
        Mark the signal as received and trigger orderly unwind.

        This handler runs in Python's signal context where re-entrant
        ORM calls are unsafe (the main thread may be inside a
        ``transaction.atomic`` holding row locks; a SELECT/UPDATE
        from this handler can deadlock against itself or the
        connection pool). The handler therefore only stashes the
        signum on ``self`` and raises a control-flow exception that
        the main loop's ``finally`` block (in ``handle``) observes,
        does the DB write outside the signal context.
        """
        self._signal_received = signum
        # SIGINT is the user-keyboard cancellation contract;
        # SIGTERM is the supervisor-orderly-shutdown contract. They
        # need different exit signatures so wrappers (systemd,
        # supervisord, Celery's worker) can react correctly.
        if signum == signal.SIGINT:
            raise KeyboardInterrupt
        raise SystemExit(128 + signum)

    def _flush_signal_to_run(self) -> None:
        """Write INTERRUPTED to the run row outside the signal context."""
        signum = self._signal_received
        run_id = self._active_run_id
        if signum is None or run_id is None:
            return
        try:
            run = AuditRun.objects.get(pk=run_id)
            from ...persistence import transition_run_state

            # Only an ACTIVE run (PENDING or RUNNING) is flushed to
            # INTERRUPTED. A terminal run
            # (SUCCEEDED/FAILED/BUDGET_EXCEEDED/ABANDONED) must not be
            # overwritten: a SIGTERM during the post-flip handler
            # restore lands after the run already reached its terminal
            # state, and clobbering it would corrupt the audit trail. A
            # run already INTERRUPTED is left alone too — the resume
            # path registers an adopted INTERRUPTED predecessor before
            # its own CAS to RUNNING, so a signal in that window finds
            # the row INTERRUPTED, and INTERRUPTED -> INTERRUPTED is an
            # illegal transition that would raise. The short-circuit
            # below skips both cases before the gated flip.
            #
            # The flip then accepts both PENDING and RUNNING: a signal
            # can land between ``track_active_run`` and the subcommand's
            # PENDING -> RUNNING compare-and-set, leaving the row
            # PENDING. Without PENDING in ``expected`` that signal is
            # lost and the run is stranded in PENDING for ever.
            if run.state not in (AuditRunState.PENDING, AuditRunState.RUNNING):
                return
            transition_run_state(
                run,
                AuditRunState.INTERRUPTED,
                error_message=f"interrupted by signal {signum}",
                expected=(
                    AuditRunState.PENDING,
                    AuditRunState.RUNNING,
                ),
            )
        except Exception as exc:  # noqa: BLE001 — signal-flush must classify any raise (DB driver, missing row, sys-level) as audit-trail corruption risk; ``log_redacted_exception`` couples the scrub-before-log invariant with the ERROR level a Sentry rule keys off.
            log_redacted_exception(
                logger,
                "failed to flip AuditRun id=%s to INTERRUPTED on signal %s",
                run_id,
                signum,
                exc=exc,
            )

    def track_active_run(self, run_id: int) -> None:
        """
        Register the run id whose state the signal flush should flip.

        Called by ``run_main`` / ``run_resume`` once they spawn (or
        adopt) an ``AuditRun``. The signal handler only flips
        ``_signal_received``; ``_flush_signal_to_run`` consults the
        id stashed here when the outer ``finally`` runs the DB
        write. Exposing this as a public method keeps the subcommand
        modules from reaching into a private attribute on Command.
        """
        self._active_run_id = run_id

    @staticmethod
    def _require_positive_arg(flag: str, value: int) -> None:
        """Reject ``--<flag> 0`` / negatives; argparse only narrows to int."""
        # ``is not None`` truthiness alone would silently fall through
        # for ``--<flag> 0``; AuditRun PKs and Discord uids both start
        # at 1, so 0 cannot match any real row and is safer to reject
        # loudly than to silently treat the flag as unset.
        if value <= 0:
            raise CommandError(
                f"{flag} expects a positive integer; got {value}"
            )

    def _dispatch_subcommand(self, opts: CommandOptions) -> bool:
        """Run a subcommand if any was selected; return ``True`` if handled."""
        # Single-source-of-truth dispatch over ``_SUBCOMMANDS``: every
        # subcommand has its own argparse dest and only one is set per
        # invocation, so the first table row with a non-None value wins.
        # Adding a new subcommand is one new row + one new handler
        # import — no new ``if`` branch to keep in sync.
        #
        # Presence is tested with ``is not None``, never truthiness: a
        # falsy-but-present value (``--from-fixture ""``) is a real
        # selection and must dispatch to its handler — which fails
        # loudly on the empty path — rather than silently fall through
        # to a full ``run_main`` as a truthiness test would. Numeric
        # subcommands additionally reject ``0`` via ``require_positive``
        # below; string subcommands let the handler surface the bad
        # value.
        json_output = bool(opts.get("json"))
        # Mutual exclusion: each subcommand binds its own argparse dest,
        # so more than one being non-None means the operator combined
        # them (e.g. ``--explain 5 --diff 9``). The table-order dispatch
        # below would silently run the first and drop the rest, hiding
        # the mistake; refuse loudly instead. Enforcing it here keeps
        # ``_SUBCOMMANDS`` the single source of truth rather than
        # duplicating the flag list in an argparse mutually-exclusive
        # group.
        selected = [
            sub for sub in _SUBCOMMANDS if opts.get(sub.opt_key) is not None
        ]
        if len(selected) > 1:
            labels = ", ".join(sub.flag_label for sub in selected)
            raise CommandError(
                f"these subcommands cannot be combined; pass only one "
                f"of: {labels}"
            )
        for sub in _SUBCOMMANDS:
            value = opts.get(sub.opt_key)
            if value is None:
                continue
            if sub.require_positive:
                # ``require_positive`` is a configuration invariant of
                # the ``_SUBCOMMANDS`` table — a row that sets it must
                # also point at an ``int``-typed argparse dest. Hiding
                # the type mismatch behind a conditional ``isinstance``
                # would silently skip the ``> 0`` gate when the table
                # drifts; the explicit raise loudly fails dev-time
                # wiring bugs (and survives ``python -O`` where
                # ``assert`` would be stripped).
                if not isinstance(value, int):
                    msg = (
                        f"_SUBCOMMANDS row {sub.flag_label!r}: "
                        f"require_positive=True but argparse dest "
                        f"returned {type(value).__name__}"
                    )
                    raise TypeError(msg)
                self._require_positive_arg(sub.flag_label, value)
            if sub.passes_json:
                sub.handler(self, value, json_output=json_output)
            else:
                sub.handler(self, value)
            return True
        return False
