"""
``audit_acknowledge_initial`` management command.

Releases the first-run safety lock by creating the single
``InitialAuditAcknowledgement`` sentinel row that ``apply_plan``
consults via ``.exists()`` (``audit/_apply.py``). Until that row
exists every destructive action is coerced to REPORT — the
"break-glass" backstop of a fresh installation.

This is a STANDALONE command, NOT a flag on ``audit_discord_roles``:
releasing the lock is an installation-level safety action, not an
operation over a run. Keeping it separate also keeps the
``_SUBCOMMANDS`` dispatch table (keyed on ``value is None``)
untouched, and makes the distinct ``acknowledge_initial_audit``
permission boundary visible in ``aa-manager help``.

Trail model (hybrid):

* **success** -> the ``InitialAuditAcknowledgement`` row itself is the
  trail (``acknowledged_by`` = the gate-validated actor);
* **refusal** (either perm missing) -> an ``AuditInvocation`` refusal
  row via ``_record_refusal`` BEFORE raising ``CommandError`` (the
  refusal trail ``ConfigChangeLog`` does not record).

Actor resolution reuses ``resolve_cli_actor`` so the
``AA_DISCORD_AUDIT_CLI_ACTOR`` setting and the OS-login convention
behave identically to the run path, and the actor is validated
(exists, active) consistently with the rest of the CLI.
"""

from __future__ import annotations

import sys
from typing import TYPE_CHECKING

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

from ..._log import get_logger
from ..._redaction import redact_str
from ...argv_redact import redact_argv
from ...exceptions import AaDiscordAuditError
from ...models import InitialAuditAcknowledgement, InvocationRefusal
from ...permissions import RUN_AUDIT, can_acknowledge_initial
from ._audit._invocation import (
    _record_refusal,
    cli_actor_scope,
    resolve_cli_actor,
)

if TYPE_CHECKING:  # pragma: no cover
    import argparse

    from django.contrib.auth.models import AbstractUser

logger = get_logger(__name__)


class _AckRefusedError(AaDiscordAuditError):
    """
    Internal signal raised from inside the atomic block when the perm
    gate denies the acknowledgement.

    Raised so the surrounding ``transaction.atomic()`` rolls back any
    work BEFORE the refusal trail is written (the refusal row must live
    in its own committed transaction, not be rolled back with the ack).
    The refusal ``AuditInvocation`` row and the operator-facing
    ``CommandError`` are produced by ``handle`` after the rollback.
    """


class Command(BaseCommand):
    help = (
        "Release the first-run safety lock by creating the "
        "InitialAuditAcknowledgement row. Requires BOTH the run_audit "
        "and acknowledge_initial_audit permissions. Idempotent: a "
        "second call is a no-op. Re-arming the lock is shell-only by "
        "design."
    )

    def add_arguments(self, parser: argparse.ArgumentParser) -> None:
        parser.add_argument(
            "--note",
            type=str,
            default="",
            help=(
                "Optional free-text note stored on the acknowledgement "
                "row. Run through the webhook-URL redactor before "
                "persisting (free text may embed a webhook credential)."
            ),
        )

    def handle(self, *args, **options) -> None:
        del args
        # Resolve the actor FIRST (gate-before-attribute): a set-but-
        # invalid ``AA_DISCORD_AUDIT_CLI_ACTOR`` raises a setting-named
        # ``CommandError`` here, before any work or attribution. The
        # same resolver feeds the run path, so the actor never
        # disagrees with the rest of the CLI. ``cli_actor_scope``
        # pins the attribution to the resolved User OBJECT for the
        # block and clears the ContextVar on exit so a subsequent
        # ``call_command`` invocation does not inherit a stale actor.
        actor = resolve_cli_actor()
        note = redact_str(options["note"]) if options["note"] else ""

        with cli_actor_scope(actor):
            try:
                # gate -> attribute -> create in ONE transaction: a
                # gate failure mid-block leaves no partial ack row,
                # and the ``acknowledged_by`` written is always the
                # gate-validated actor (never NULL on success).
                with transaction.atomic():
                    self._acknowledge(actor, note=note)
            except _AckRefusedError:
                # The atomic block has rolled back; write the refusal
                # trail now (outside the rolled-back transaction) and
                # raise.
                self._refuse(actor)

    def _acknowledge(
        self,
        actor: AbstractUser | None,
        *,
        note: str,
    ) -> None:
        """Gate, then create / attribute the sentinel row (in the atomic)."""
        existing = list(InitialAuditAcknowledgement.objects.all())
        attributed = [row for row in existing if row.acknowledged_by_id]
        if attributed:
            # Already released AND attributed: a clean no-op (exit 0).
            row = attributed[0]
            self.stdout.write(
                self.style.SUCCESS(
                    f"already acknowledged at "
                    f"{row.acknowledged_at.isoformat()} by "
                    f"{row.acknowledged_by}"
                )
            )
            return

        # A row must be created or attributed from here: gate first.
        # A ``False`` return raises ``_AckRefusedError`` so the atomic
        # rolls back; an unexpected gate exception also propagates and
        # rolls back (no partial ack row either way).
        if not can_acknowledge_initial(actor):
            raise _AckRefusedError

        if existing:
            # Unattributed row(s) exist (e.g. an ``audit_benchmark``
            # break-glass row left unattributed by the benchmark
            # command, or a row whose ``acknowledged_by`` was SET_NULL
            # on user deletion). This is NOT a clean no-op: attribute
            # the existing row to the gate-validated actor instead of
            # leaving the lock release unattributed. No second row is
            # created.
            row = existing[0]
            row.acknowledged_by = actor
            if note:
                row.notes = note
            row.save(update_fields=["acknowledged_by", "notes"])
            self.stdout.write(
                self.style.SUCCESS(
                    "attributed existing unattributed acknowledgement to "
                    f"{actor}"
                )
            )
            return

        row = InitialAuditAcknowledgement.objects.create(
            acknowledged_by=actor,
            notes=note,
        )
        self.stdout.write(
            self.style.SUCCESS(
                f"first-run lock released; acknowledged by {actor} "
                f"at {row.acknowledged_at.isoformat()}"
            )
        )

    def _refuse(self, actor: AbstractUser | None) -> None:
        """Write the refusal trail, then raise ``CommandError``."""
        argv = redact_argv(sys.argv[1:])
        _record_refusal(actor, argv, InvocationRefusal.PERMISSION_DENIED)
        if actor is None:
            raise CommandError(
                "no Django user could be resolved as the CLI actor; "
                f"cannot verify {RUN_AUDIT} + acknowledge_initial_audit "
                "to release the first-run lock. Set "
                "AA_DISCORD_AUDIT_CLI_ACTOR or match the OS login to a "
                "Django username."
            )
        raise CommandError(
            f"both {RUN_AUDIT} and acknowledge_initial_audit permissions "
            "are required to release the first-run lock"
        )
