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

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

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

if TYPE_CHECKING:
    import argparse
    from collections.abc import Generator
    from types import FrameType

    from ._audit import CommandOptions

logger = get_logger(__name__)


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).",
        )
        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.
        if hasattr(self, "_called_from_command_line"):
            import getpass

            try:
                os_username = getpass.getuser()
            except OSError as exc:
                # Crippled containers without a passwd entry raise
                # ``OSError``; log instead of swallowing so the
                # operator can correlate ``<system>``-attributed
                # ConfigChangeLog rows with the root cause.
                logger.warning(
                    "could not capture OS-side actor for ConfigChangeLog: %s",
                    exc,
                )
            else:
                set_command_user(os_username, origin="management_command")

        with 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
            # Clear the ContextVar so a subsequent ``call_command``
            # invocation (or process re-use under custom test
            # harnesses) does not inherit a stale actor.
            set_command_user(None)

    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

            # ``expected=RUNNING`` makes the flip a no-op when the row
            # is already in a terminal state. The race window is narrow
            # but real: ``apply_plan`` / ``run_resume`` flip the run to
            # SUCCEEDED / FAILED / BUDGET_EXCEEDED before the
            # contextmanager's ``finally`` restores signal handlers,
            # so a SIGTERM landing during ``fan_out_for_run`` (or any
            # post-flip handler-restore syscall) would otherwise
            # silently overwrite the terminal state and corrupt the
            # audit trail.
            transition_run_state(
                run,
                AuditRunState.INTERRUPTED,
                error_message=f"interrupted by signal {signum}",
                expected=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."""
        # Each subcommand has its own argparse dest. Only one is set
        # per invocation (operators do not combine ``--explain`` with
        # ``--diff``); first-match-wins.
        json_output = bool(opts.get("json"))
        explain_id = opts.get("explain_member_id")
        if explain_id is not None:
            self._require_positive_arg("--explain", explain_id)
            run_explain(self, explain_id, json_output=json_output)
            return True
        diff_id = opts.get("diff_run_id")
        if diff_id is not None:
            self._require_positive_arg("--diff", diff_id)
            run_diff(self, diff_id, json_output=json_output)
            return True
        fixture_path = opts.get("from_fixture")
        if fixture_path is not None:
            run_from_fixture(self, fixture_path, json_output=json_output)
            return True
        policy_json = opts.get("policy_preview")
        if policy_json is not None:
            run_policy_preview(self, policy_json, json_output=json_output)
            return True
        abandon_id = opts.get("abandon_run_id")
        if abandon_id is not None:
            self._require_positive_arg("--abandon", abandon_id)
            run_abandon(self, abandon_id)
            return True
        resume_id = opts.get("resume_run_id")
        if resume_id is not None:
            self._require_positive_arg("--resume", resume_id)
            run_resume(self, resume_id)
            return True
        return False
