"""Web-launch views: POST submit + JSON polling endpoint."""

from __future__ import annotations

import re
import secrets
from typing import TYPE_CHECKING, Final, cast

from celery.exceptions import OperationalError as BrokerError
from django.conf import settings as django_settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required, permission_required
from django.core.exceptions import PermissionDenied
from django.db import IntegrityError, transaction
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.utils.translation import gettext as _
from django.views.decorators.cache import never_cache
from django.views.decorators.clickjacking import xframe_options_deny
from django.views.decorators.http import require_http_methods

from ._log import get_logger
from .invocation import (
    check_run_perm,
    link_invocation,
    rate_limit_or_record,
    record_refusal,
)
from .models import (
    AuditRun,
    AuditRunState,
    InitialAuditAcknowledgement,
    InvocationRefusal,
    RunTrigger,
)
from .permissions import RUN_AUDIT_DESTRUCTIVE, VIEW_AUDITRUN
from .persistence import force_transition_to_failed
from .policy import policy_from_snapshot, policy_has_destructive
from .tasks import process_pending_run

if TYPE_CHECKING:  # pragma: no cover
    from django.contrib.auth.models import AbstractUser
    from django.http import HttpRequest, HttpResponse


# The web has no command-line argv; pass an empty list to the
# invocation helpers so the audit-trail row carries no fake CLI string.
_WEB_ARGV: Final[list[str]] = []


# logger is reused by the POST submit view added alongside.
logger = get_logger(__name__)


# Per-render idempotency nonce. The launch form embeds a fresh token on
# every GET render (``new_idempotency_token``); a double-submit of the
# SAME rendered form therefore collides on the unique
# ``AuditRun.idempotency_key`` and is deduplicated, while a fresh page
# load mints a new token and is correctly allowed to start a new run.
# This replaces a wall-clock-minute bucket, which both let two submits
# straddling a minute boundary create two runs and wrongly collapsed two
# deliberate launches within the same minute onto a single run.
_IDEMPOTENCY_TOKEN_BYTES: Final[int] = 16
_IDEMPOTENCY_TOKEN_RE: Final = re.compile(r"\A[A-Za-z0-9_-]{1,32}\Z")


def new_idempotency_token() -> str:
    """Mint a fresh URL-safe nonce for one launch-form render."""
    return secrets.token_urlsafe(_IDEMPOTENCY_TOKEN_BYTES)


def _idempotency_key(user_pk: int, raw_token: str) -> str:
    """
    Build the per-user dedup key from the form's idempotency nonce.

    A well-formed ``raw_token`` round-trips so a double-submit of the
    same rendered form collides on the unique key; a missing or
    malformed token (a client that dropped the hidden field) falls back
    to a fresh server nonce -- the run is still created, just not
    deduplicated, so the unique constraint never raises on a degraded
    client.
    """
    token = (
        raw_token
        if _IDEMPOTENCY_TOKEN_RE.match(raw_token)
        else new_idempotency_token()
    )
    return f"web-{user_pk}-{token}"


# Field tuple shared by the ORM .only(...) hint and the response shape.
# Anchored as a module-level constant so the test asserts exact equality
# against the payload keys — protects against a future refactor that
# silently swaps to model_to_dict (which would leak error_message).
_STATE_JSON_FIELDS: Final[tuple[str, ...]] = (
    "state",
    "started_at",
    "finished_at",
    "members_scanned",
    "findings_count",
    "actions_applied",
    "actions_skipped",
)

# Subset of ``_STATE_JSON_FIELDS`` whose values are ``datetime`` and
# therefore need ``.isoformat()`` on the wire. Lifted out of the loop's
# ``hasattr(value, "isoformat")`` duck-test so the JSON contract is
# self-documenting: a reader sees AT THE TOP which fields are temporal,
# and a future addition to ``_STATE_JSON_FIELDS`` that happens to also
# expose ``.isoformat`` (e.g. a ``Date`` column) cannot accidentally
# inherit datetime-serialisation by quacking like one.
_STATE_JSON_DATETIME_FIELDS: Final[frozenset[str]] = frozenset(
    {"started_at", "finished_at"}
)

# Per-request cap on the bulk runs_state_json endpoint: limits the
# upper bound on a single SELECT and bounds query-string growth
# from a manipulated client. 50 covers the default run_list page
# size with headroom.
_RUNS_STATE_BULK_MAX_IDS: Final[int] = 50


def _run_state_payload(
    run: AuditRun, *, include_pk: bool = False
) -> dict[str, object]:
    """
    Serialise the ``_STATE_JSON_FIELDS`` slice of ``run`` for the wire.

    Single source for the polling-endpoint payload shape: both
    ``run_state_json`` (single-row) and ``runs_state_json`` (bulk)
    walk the same key set so a future change to
    ``_STATE_JSON_FIELDS`` lands in one helper, not two parallel
    loops. ``include_pk`` adds the ``pk`` key the bulk variant needs
    to disambiguate rows on the client.
    """
    payload: dict[str, object] = {"pk": run.pk} if include_pk else {}
    for field in _STATE_JSON_FIELDS:
        value = getattr(run, field)
        if field in _STATE_JSON_DATETIME_FIELDS and value is not None:
            value = value.isoformat()
        payload[field] = value
    return payload


@never_cache
@xframe_options_deny
@login_required
@permission_required(VIEW_AUDITRUN, raise_exception=True)
def run_state_json(_request: HttpRequest, pk: int) -> HttpResponse:
    """Return a narrow JSON snapshot for client-side polling."""
    run = get_object_or_404(
        AuditRun.objects.only(*_STATE_JSON_FIELDS),
        pk=pk,
    )
    return JsonResponse(_run_state_payload(run))


@never_cache
@xframe_options_deny
@login_required
@permission_required(VIEW_AUDITRUN, raise_exception=True)
def runs_state_json(request: HttpRequest) -> HttpResponse:
    """Bulk JSON snapshot for client-side run-list polling."""
    raw_ids = request.GET.get("ids", "")
    # ``_RUNS_STATE_BULK_MAX_IDS`` caps *unique* pks rather than raw
    # commas, so a manipulated ``?ids=1,1,1,...`` cannot mask one real
    # pk behind 49 dupes. ``chunk.isascii() and chunk.isdigit()``
    # rejects unicode-digit chars where ``int()`` would still parse
    # them to 0 / ASCII equivalents and hit ``pk__in`` uselessly.
    seen: set[int] = set()
    pks: list[int] = []
    for raw_chunk in raw_ids.split(","):
        chunk = raw_chunk.strip()
        if not (chunk.isascii() and chunk.isdigit()):
            continue
        pk = int(chunk)
        if pk in seen:
            continue
        seen.add(pk)
        pks.append(pk)
        if len(pks) >= _RUNS_STATE_BULK_MAX_IDS:
            break
    if not pks:
        return JsonResponse({"runs": []})
    # Stable ordering on the bulk read so the JS poller can match
    # rows by index without rebuilding a pk-keyed dict — Django does
    # not guarantee ordering on ``filter(pk__in=...)`` otherwise.
    runs = (
        AuditRun.objects.filter(pk__in=pks)
        .only("pk", *_STATE_JSON_FIELDS)
        .order_by("pk")
    )
    payload_runs = [_run_state_payload(run, include_pk=True) for run in runs]
    return JsonResponse({"runs": payload_runs})


@require_http_methods(["POST"])
@xframe_options_deny
@login_required
def run_launch_submit(request: HttpRequest) -> HttpResponse:
    """Create a PENDING AuditRun and enqueue process_pending_run."""
    # @login_required guarantees an authenticated user before this body
    # runs; the cast satisfies the AbstractUser-typed helpers without
    # adding a redundant runtime check.
    user = cast("AbstractUser", request.user)

    def _refuse(reason: InvocationRefusal) -> None:
        # Closure binds the (user, argv, trigger) triple so the two
        # refusal branches reduce to "decide reason, then refuse".
        # trigger is captured rather than parameterised because this
        # view only ever writes WEB-trail rows; lifting it to a kwarg
        # would weaken that module invariant.
        record_refusal(
            trigger=RunTrigger.WEB,
            user=user,
            argv=_WEB_ARGV,
            reason=reason,
        )

    perm_outcome = check_run_perm(user=user)
    if not perm_outcome.granted:
        _refuse(InvocationRefusal.PERMISSION_DENIED)
        raise PermissionDenied

    if not InitialAuditAcknowledgement.objects.exists():
        _refuse(InvocationRefusal.INITIAL_LOCK_HELD)
        messages.error(
            request,
            _(
                "Initial audit acknowledgement is missing. Run the "
                "audit_acknowledge_initial CLI command first."
            ),
        )
        return redirect("aa_discord_audit:run_list")

    rate_outcome = rate_limit_or_record(
        trigger=RunTrigger.WEB,
        user=user,
        argv=_WEB_ARGV,
    )
    if not rate_outcome.accepted:
        messages.error(
            request,
            _("Per-user audit rate limit reached. Try again later."),
        )
        return redirect("aa_discord_audit:run_list")

    policy = policy_from_snapshot(
        getattr(django_settings, "AA_DISCORD_AUDIT_POLICY", {})
    )
    effective_destructive = user.has_perm(
        RUN_AUDIT_DESTRUCTIVE
    ) and policy_has_destructive(policy)
    idempotency_key = _idempotency_key(
        user.pk, request.POST.get("idempotency_token", "")
    )

    try:
        with transaction.atomic():
            run = AuditRun.objects.create(
                state=AuditRunState.PENDING,
                triggered_by=RunTrigger.WEB,
                triggered_by_user=user,
                was_confirmation_bypassed=effective_destructive,
                idempotency_key=idempotency_key,
            )
            if rate_outcome.invocation is not None:
                link_invocation(rate_outcome.invocation, run_pk=run.pk)
            transaction.on_commit(lambda: _enqueue_or_fail(run.pk, request))
    except IntegrityError:
        existing = AuditRun.objects.get(idempotency_key=idempotency_key)
        return redirect("aa_discord_audit:run_detail", pk=existing.pk)

    logger.info(
        "web launch run_id=%s actor=%s bypass=%s remote_addr=%s",
        run.pk,
        user.username,
        effective_destructive,
        request.META.get("REMOTE_ADDR"),
    )
    return redirect("aa_discord_audit:run_detail", pk=run.pk)


def _enqueue_or_fail(run_pk: int, request: HttpRequest) -> None:
    """
    Enqueue process_pending_run; flip the run to FAILED on broker error.

    Runs as a post-commit callback so the inserted row is visible.
    Cannot return an HTTP response (the original response already
    shipped). Instead, on broker failure it routes the flip through
    ``force_transition_to_failed`` so the FSM hop matches the path
    ``tasks.process_pending_run`` already uses, and pushes a flash so
    the next page render surfaces the problem to the operator.
    """
    try:
        process_pending_run.delay(run_pk)  # pyright: ignore[reportCallIssue]
    except BrokerError:
        run = AuditRun.objects.get(pk=run_pk)
        # ``force_transition_to_failed`` refreshes from DB, gates the
        # PENDING -> RUNNING hop on the row still being PENDING (a
        # racing worker that already advanced the FSM is left alone),
        # then attempts RUNNING -> FAILED. Its return value flags
        # whether the second CAS won; we surface the flash regardless
        # so the operator sees the broker problem even when the row
        # is already terminal under a concurrent writer.
        force_transition_to_failed(
            run,
            error_message=_("Celery broker unreachable at launch."),
        )
        messages.error(
            request,
            _(
                "Celery broker is unreachable; the audit run was marked "
                "FAILED. Try again once the broker is back."
            ),
        )
