"""Web UI views for the Discord audit module."""

from __future__ import annotations

import csv
import json
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, Final, TypedDict, cast

from django.contrib.auth.decorators import login_required, permission_required
from django.core.paginator import Paginator
from django.db.models import Count, Q
from django.http import StreamingHttpResponse
from django.shortcuts import get_object_or_404, render
from django.utils import timezone

from ._changelog_diff import _ChangelogDiff, diff_for_entry
from ._display import build_badge, flag_badge, pretty_json, relative_since
from ._redaction import redact_str
from .app_settings import AA_DISCORD_AUDIT_POLICY
from .models import (
    AuditFinding,
    AuditFindingState,
    AuditInvocation,
    AuditRun,
    AuditRunState,
    BotAccountUid,
    ConfigChangeLog,
    DiscordIdentity,
    InitialAuditAcknowledgement,
    InvocationRefusal,
    OrphanAction,
    OrphanCategory,
    ProtectedDiscordMember,
    RunTrigger,
)
from .permissions import (
    VIEW_AUDITFINDING,
    VIEW_AUDITINVOCATION,
    VIEW_AUDITRUN,
    VIEW_CONFIGCHANGELOG,
    request_user_has_perm,
)
from .policy import policy_from_snapshot, policy_has_destructive
from .views_launch import new_idempotency_token

if TYPE_CHECKING:  # pragma: no cover
    from collections.abc import Iterable, Iterator
    from enum import Enum

    from django.core.paginator import Page
    from django.db.models import QuerySet
    from django.http import HttpRequest, HttpResponse


# Page size matches AA's other audit-style plugins; large enough that
# a typical month of runs fits on one page, small enough that the
# DataTables JS initialiser stays snappy on shared-tenancy hosts.
_LIST_PAGE_SIZE: Final[int] = 25
# Findings can run into the thousands per run; a smaller page keeps
# the rendered HTML compact when an operator is scanning a specific
# state/category bucket without DataTables hydration.
_FINDING_PAGE_SIZE: Final[int] = 100

# DOM ceiling for the client-side (full-set) DataTables mode. Above
# this many *filtered* rows the view falls back to server pagination so
# a multi-year changelog cannot flood the DOM. Tunable; 2000 rows of
# the widened list (now carrying error_message / cli_argv) is a
# defensible browser payload. Branch on ``.count()``, never a slice.
_DATATABLES_CLIENT_CAP: Final[int] = 2000

# CSV export streams findings via ``QuerySet.iterator``; chunking at
# 500 keeps each fetch round-trip-bounded on PostgreSQL (the default
# server-side cursor batch) without inflating peak memory for runs
# with tens of thousands of findings.
_CSV_CHUNK_SIZE: Final[int] = 500

# Date-range filter end-offset. ``?to=YYYY-MM-DD`` parses as midnight
# of that day, so to make the upper bound inclusive of the whole
# named day the filter uses ``started_at__lt = (to + N days)`` with
# N = 1. Named so a test can pin both the value and the direction
# (mutating the literal to 0 makes the range exclude the named day;
# mutating the addition to subtraction makes it exclude everything).
_DATE_RANGE_END_OFFSET_DAYS: Final[int] = 1

_CSV_HEADER: Final[tuple[str, ...]] = (
    "member_id",
    "member_username",
    "category",
    "state",
    "action",
    "was_kicked",
    "role_ids_removed",
    "role_names_removed",
    "skipped_reason",
    "error_message",
)

# Sentinel for ``_parse_date_range`` — a "filter dict" carrying this
# value means an unparseable YYYY-MM-DD; the caller collapses to
# ``queryset.none()``. Uses a sentinel instead of a magic dict-key
# so a real model field named ``__invalid__`` cannot collide.
_INVALID_DATE_FILTER: Final[object] = object()


class _DashboardSummary(TypedDict):
    """Aggregate headline data for the run_list dashboard card."""

    last_run: AuditRun | None
    last_run_badge: dict[str, str] | None
    last_run_rel: str
    failed_recent: int
    pending_findings: int


class _RunRow(TypedDict):
    """Pre-computed display values for one run_list table row."""

    obj: AuditRun
    state_badge: dict[str, str]
    trigger_badge: dict[str, str]
    dry_run_badge: dict[str, str] | None
    started_rel: str
    started_abs: str
    duration: str
    error_redacted: str


class _FindingRow(TypedDict):
    """Pre-computed display values for one run_detail findings row."""

    obj: AuditFinding
    category_badge: dict[str, str]
    state_badge: dict[str, str]
    action_badge: dict[str, str]
    kicked_badge: dict[str, str] | None
    error_redacted: str


class _InvocationRow(TypedDict):
    """Pre-computed display values for one invocation_list row."""

    obj: AuditInvocation
    trigger_badge: dict[str, str]
    refusal_badge: dict[str, str] | None
    run_state_badge: dict[str, str] | None
    argv_text: str
    started_rel: str
    started_abs: str


class _ChangelogRow(TypedDict):
    """Pre-computed display values for one changelog_list row."""

    obj: ConfigChangeLog
    action_css: str
    action_label: str
    created_rel: str
    created_abs: str
    payload_json: str
    diff: _ChangelogDiff


class _CsvEcho:
    """File-like sink whose ``write`` returns the value back."""

    # Django's StreamingHttpResponse iterates over an iterator and
    # writes each yielded chunk to the wire. csv.writer wants a
    # file-like object, so we pass an "echo" sink that just
    # returns whatever write() receives — the row-by-row generator
    # below threads the produced CSV line through to the client.

    def write(self, value: str) -> str:
        """Return the value verbatim so csv.writer's line ends up streamed."""
        return value


def _parse_date_range(
    request: HttpRequest,
) -> tuple[str, str, dict[str, object] | object]:
    """
    Parse ?from / ?to params into an ``__gte`` / ``__lt`` filter dict.

    Returns ``(from_date_raw, to_date_raw, filter_kwargs)`` so the template can
    re-render the values in the form. Unparseable YYYY-MM-DD returns
    the ``_INVALID_DATE_FILTER`` sentinel so the caller can collapse
    to ``queryset.none()`` — same loud-fail pattern the state filter
    uses.
    """
    from_date_raw = request.GET.get("from") or ""
    to_date_raw = request.GET.get("to") or ""
    out: dict[str, object] = {}
    tz = timezone.get_current_timezone()
    if from_date_raw:
        try:
            from_dt = datetime.strptime(from_date_raw, "%Y-%m-%d").replace(
                tzinfo=tz
            )
        except ValueError:
            return from_date_raw, to_date_raw, _INVALID_DATE_FILTER
        out["started_at__gte"] = from_dt
    if to_date_raw:
        try:
            to_dt = datetime.strptime(to_date_raw, "%Y-%m-%d").replace(
                tzinfo=tz
            ) + timedelta(days=_DATE_RANGE_END_OFFSET_DAYS)
        except ValueError:
            return from_date_raw, to_date_raw, _INVALID_DATE_FILTER
        out["started_at__lt"] = to_dt
    return from_date_raw, to_date_raw, out


def _apply_enum_filter(
    queryset: QuerySet,
    request: HttpRequest,
    *,
    param: str,
    field: str,
    enum_cls: type[Enum],
) -> tuple[QuerySet, str]:
    """
    Apply an enum-typed GET filter; unknown value -> ``.none()``.

    Returns ``(queryset, raw_value)`` so the caller can echo the
    selection back into the template form.
    """
    raw = request.GET.get(param) or ""
    if not raw:
        return queryset, ""
    try:
        return queryset.filter(**{field: enum_cls(raw).value}), raw
    except ValueError:
        return queryset.none(), raw


def _paginate(
    request: HttpRequest,
    queryset: QuerySet,
    *,
    page_size: int = _LIST_PAGE_SIZE,
) -> Page:
    """Wrap the shared ``Paginator(..., page_size).get_page`` step."""
    # Four list views (run_list, invocation_list, changelog_list, plus
    # the run_detail-findings sub-page with its own size) all share
    # this two-line preamble; routing them through one helper makes
    # the shared page-size constant the single source of truth and
    # lets ``run_detail`` opt into ``_FINDING_PAGE_SIZE`` without
    # bypassing the helper.
    return Paginator(queryset, page_size).get_page(request.GET.get("page"))


def build_list_context(
    request: HttpRequest,
    queryset: QuerySet,
    *,
    page_size: int = _LIST_PAGE_SIZE,
) -> dict[str, object]:
    """
    Build the shared list context, choosing client- or server-paging.

    Below ``_DATATABLES_CLIENT_CAP`` filtered rows the full queryset is
    materialised into ``rows`` and DataTables paginates client-side
    (``client_mode=True``). At/above the cap the view falls back to the
    server ``Paginator`` (``page_obj``) and DataTables runs with
    ``paging:false``. Both branches consume the SAME already-filtered
    queryset, so the row set is identical -- only the paging differs.
    """
    count = queryset.count()
    if count < _DATATABLES_CLIENT_CAP:
        return {
            "client_mode": True,
            "rows": list(queryset),
            "page_obj": None,
            "total_count": count,
        }
    return {
        "client_mode": False,
        "rows": None,
        "page_obj": _paginate(request, queryset, page_size=page_size),
        "total_count": count,
    }


def _list_objects(base_ctx: dict[str, object]) -> Iterable[Any]:
    """
    The row source chosen by ``build_list_context`` for this request.

    Returns the full materialised list in client-paging mode and the
    server ``Paginator`` page otherwise, so each list view maps ONE
    iterable through its row-builder instead of re-deciding which
    context key to walk. ``Iterable[Any]`` is returned (not a
    parameterised generic) so the call site doesn't pay a per-view
    cast — the row-builder it feeds is the type contract.
    """
    return cast(
        "Iterable[Any]",
        base_ctx["rows"] if base_ctx["client_mode"] else base_ctx["page_obj"],
    )


_ABS_FMT_MINUTE: Final[str] = "%Y-%m-%d %H:%M"
_ABS_FMT_SECOND: Final[str] = "%Y-%m-%d %H:%M:%S"


def _abs(dt: datetime, *, seconds: bool = False) -> str:
    """Format ``dt`` in localtime with minute or second precision."""
    return timezone.localtime(dt).strftime(
        _ABS_FMT_SECOND if seconds else _ABS_FMT_MINUTE
    )


def _redact_or_blank(value: str | None) -> str:
    """Redact webhook URLs out of ``value``; empty string for falsy input."""
    return redact_str(value) if value else ""


# JS clients (run_list polling + run_detail polling) read this CSV to
# decide which states stop the poll. Pinned as a module constant so the
# two consumers cannot drift on sort order or membership; derived from
# the ``AuditRunState.is_finished`` predicate so the property and the
# wire contract stay in sync.
_FINISHED_STATES_CSV: Final[str] = ",".join(
    sorted(s.value for s in AuditRunState if s.is_finished)
)


_DASHBOARD_WINDOW_DAYS: Final[int] = 7
_FAILED_RUN_STATES: Final[tuple[str, ...]] = (
    AuditRunState.FAILED.value,
    AuditRunState.BUDGET_EXCEEDED.value,
    AuditRunState.INTERRUPTED.value,
)


def _dashboard_summary() -> _DashboardSummary:
    """
    Build the cheap aggregate summary for the run_list dashboard card.

    Two to four aggregate queries, no per-row work: the most recent
    run, the count of failed-ish runs in a trailing window, and the
    open (pending) findings count. Each is rendered as a headline the
    operator can scan before reading the table.
    """
    since = timezone.now() - timedelta(days=_DASHBOARD_WINDOW_DAYS)
    last_run = AuditRun.objects.order_by("-started_at", "-id").first()
    return {
        "last_run": last_run,
        "last_run_badge": (
            build_badge("run_state", last_run.state)
            if last_run is not None
            else None
        ),
        "last_run_rel": (
            relative_since(last_run.started_at)
            if last_run is not None
            else "—"
        ),
        "failed_recent": AuditRun.objects.filter(
            started_at__gte=since, state__in=_FAILED_RUN_STATES
        ).count(),
        "pending_findings": AuditFinding.objects.filter(
            state=AuditFindingState.PENDING
        ).count(),
    }


_SECONDS_PER_MINUTE: Final[int] = 60


def _run_duration(run: AuditRun) -> str:
    """Human duration, or an em-dash marker when the run is unfinished."""
    if run.finished_at is None:
        return "—"
    total = int((run.finished_at - run.started_at).total_seconds())
    if total < _SECONDS_PER_MINUTE:
        return f"{total}s"
    return f"{total // _SECONDS_PER_MINUTE}m {total % _SECONDS_PER_MINUTE}s"


def _run_row(run: AuditRun) -> _RunRow:
    """Pre-compute the display values for one run_list row."""
    return {
        "obj": run,
        "state_badge": build_badge("run_state", run.state),
        "trigger_badge": build_badge("trigger", run.triggered_by),
        "dry_run_badge": flag_badge("dry_run", value=run.dry_run),
        "started_rel": relative_since(run.started_at),
        "started_abs": _abs(run.started_at),
        "duration": _run_duration(run),
        # Render-time redaction: never trust the persisted value.
        "error_redacted": _redact_or_blank(run.error_message),
    }


def _finding_row(finding: AuditFinding) -> _FindingRow:
    """Pre-compute the display values for one run_detail findings row."""
    return {
        "obj": finding,
        "category_badge": build_badge("finding_category", finding.category),
        "state_badge": build_badge("finding_state", finding.state),
        "action_badge": build_badge("action", finding.action),
        "kicked_badge": flag_badge("was_kicked", value=finding.was_kicked),
        "error_redacted": _redact_or_blank(finding.error_message),
    }


# Filter spec for ``run_detail``'s findings table — one entry per
# (GET param, model field, enum). Used by ``_filter_findings``;
# adding a new filter is one row here, not a new ``if`` branch.
_FINDING_FILTERS: Final[tuple[tuple[str, str, type[Enum]], ...]] = (
    ("category", "category", OrphanCategory),
    ("state", "state", AuditFindingState),
    ("action", "action", OrphanAction),
)

# Free-text filter spec for ``changelog_list``.
_CHANGELOG_TEXT_FILTERS: Final[tuple[str, ...]] = (
    "model_name",
    "action",
    "actor_username",
)


@login_required
@permission_required(VIEW_AUDITRUN, raise_exception=True)
def run_list(request: HttpRequest) -> HttpResponse:
    """Paginated list of AuditRun rows, filterable by state + date range."""
    # ``-started_at`` sort matches the operator's mental model
    # (most recent first) and uses the existing index — appending
    # ``id`` as a deterministic tiebreaker keeps pagination stable
    # under the rare same-tick start.
    queryset = AuditRun.objects.select_related("triggered_by_user").order_by(
        "-started_at", "-id"
    )
    queryset, state_filter = _apply_enum_filter(
        queryset,
        request,
        param="state",
        field="state",
        enum_cls=AuditRunState,
    )
    from_date_raw, to_date_raw, date_filters = _parse_date_range(request)
    if date_filters is _INVALID_DATE_FILTER:
        # Malformed YYYY-MM-DD: surface as zero rows rather than
        # silently widening to all runs.
        queryset = queryset.none()
    elif date_filters:
        # Narrowing: by this branch ``date_filters`` is a populated
        # dict (the sentinel branch returned earlier; an empty dict is
        # falsy and would have skipped this elif). ``cast`` rather than
        # ``assert`` so the narrowing survives under ``python -O``.
        queryset = queryset.filter(**cast("dict[str, object]", date_filters))
    base_ctx = build_list_context(request, queryset)
    rows = [_run_row(r) for r in _list_objects(base_ctx)]
    return render(
        request,
        "aa_discord_audit/run_list.html",
        {
            **base_ctx,
            "rows": rows,
            "states": list(AuditRunState),
            "selected_state": state_filter,
            "selected_from": from_date_raw,
            "selected_to": to_date_raw,
            # Active filters folded into the prev/next pagination links by
            # ``_pagination.html`` so a page step preserves the filter.
            "pagination_query": {
                "state": state_filter,
                "from": from_date_raw,
                "to": to_date_raw,
            },
            "summary": _dashboard_summary(),
            "has_initial_ack": InitialAuditAcknowledgement.objects.exists(),
            "policy_destructive": policy_has_destructive(
                policy_from_snapshot(AA_DISCORD_AUDIT_POLICY)
            ),
            # Fresh per-render nonce embedded in the launch form's hidden
            # field; ``run_launch_submit`` folds it into the run's
            # idempotency_key so a double-submit of this render dedups
            # while a reload starts a new run.
            "launch_idempotency_token": new_idempotency_token(),
            "terminal_states_csv": _FINISHED_STATES_CSV,
        },
    )


def _invocation_row(inv: AuditInvocation) -> _InvocationRow:
    """Pre-compute display values for one invocation_list row."""
    run = inv.audit_run
    return {
        "obj": inv,
        "trigger_badge": build_badge("trigger", inv.triggered_by),
        "refusal_badge": (
            build_badge("refusal", inv.refusal_reason)
            if inv.refusal_reason
            else None
        ),
        "run_state_badge": (
            build_badge("run_state", run.state) if run is not None else None
        ),
        "argv_text": redact_str(" ".join(inv.cli_argv or [])),
        "started_rel": relative_since(inv.started_at),
        "started_abs": _abs(inv.started_at, seconds=True),
    }


@login_required
@permission_required(VIEW_AUDITINVOCATION, raise_exception=True)
def invocation_list(request: HttpRequest) -> HttpResponse:
    """
    Paginated list of ``AuditInvocation`` rows.

    Surfaces the audit-the-auditor trail (every accepted / refused
    trigger, with redacted argv and refusal reason) as a web page
    so an operator can correlate "who tried what" without dropping
    into Django admin. Read-only by definition — the model is
    append-only at both the manager and admin layers.
    """
    queryset = AuditInvocation.objects.select_related(
        "triggered_by_user", "audit_run"
    ).order_by("-started_at", "-id")
    accepted_filter = request.GET.get("accepted") or ""
    if accepted_filter == "true":
        queryset = queryset.filter(accepted=True)
    elif accepted_filter == "false":
        queryset = queryset.filter(accepted=False)
    elif accepted_filter:
        # Unknown value -> empty result, mirrors ``_apply_enum_filter``.
        # The previous form silently widened to "no filter", masking a
        # mistyped URL as "all rows" — the operator could not tell the
        # filter was being ignored.
        queryset = queryset.none()
    queryset, refusal_filter = _apply_enum_filter(
        queryset,
        request,
        param="refusal_reason",
        field="refusal_reason",
        enum_cls=InvocationRefusal,
    )
    queryset, triggered_by_filter = _apply_enum_filter(
        queryset,
        request,
        param="triggered_by",
        field="triggered_by",
        enum_cls=RunTrigger,
    )
    base_ctx = build_list_context(request, queryset)
    rows = [_invocation_row(i) for i in _list_objects(base_ctx)]
    return render(
        request,
        "aa_discord_audit/invocation_list.html",
        {
            **base_ctx,
            "rows": rows,
            "refusal_reasons": list(InvocationRefusal),
            "triggers": list(RunTrigger),
            "selected_accepted": accepted_filter,
            "selected_refusal": refusal_filter,
            "selected_triggered_by": triggered_by_filter,
            "pagination_query": {
                "accepted": accepted_filter,
                "refusal_reason": refusal_filter,
                "triggered_by": triggered_by_filter,
            },
        },
    )


def _changelog_row(entry: ConfigChangeLog) -> _ChangelogRow:
    """Pre-compute the display values for one changelog row."""
    action_badge = build_badge("config_action", entry.action)
    return {
        "obj": entry,
        "action_css": action_badge["css"],
        "action_label": action_badge["label"],
        "created_rel": relative_since(entry.created_at),
        "created_abs": _abs(entry.created_at, seconds=True),
        "payload_json": pretty_json(entry.payload),
        "diff": diff_for_entry(entry),
    }


@login_required
@permission_required(VIEW_CONFIGCHANGELOG, raise_exception=True)
def changelog_list(request: HttpRequest) -> HttpResponse:
    """
    Paginated list of ``ConfigChangeLog`` rows.

    Each row records one operator edit to ``ManagedRoleException``,
    ``ProtectedDiscordMember``, ``BotAccountUid``, or
    ``FindingActionOverride`` (per ``signals._TRACKED_MODELS``).
    The ``payload`` JSON snapshot of the edit is rendered as a
    pretty-printed code block so an operator scanning the trail
    can read what changed without leaving the page.
    """
    queryset = ConfigChangeLog.objects.all().order_by("-created_at", "-id")
    # Echo the raw filter values back to the template AND fold them
    # into the queryset filter in a single pass. ``actor_username``
    # uses ``__icontains`` so partial matches work (e.g. "bor" finds
    # "boris"); the other two fields are enum-valued and require exact
    # matches to avoid spurious cross-field collisions.
    selected_text: dict[str, str] = {}
    for field in _CHANGELOG_TEXT_FILTERS:
        value = request.GET.get(field) or ""
        selected_text[field] = value
        if value:
            lookup = (
                f"{field}__icontains" if field == "actor_username" else field
            )
            queryset = queryset.filter(**{lookup: value})
    # Distinct ``model_name`` values populate the filter dropdown so
    # operators do not have to type. Cheap query — ``ConfigChangeLog``
    # tracks four models today; the result fits in a single roundtrip.
    model_choices = list(
        ConfigChangeLog.objects.order_by("model_name")
        .values_list("model_name", flat=True)
        .distinct()
    )
    base_ctx = build_list_context(request, queryset)
    rows = [_changelog_row(e) for e in _list_objects(base_ctx)]
    return render(
        request,
        "aa_discord_audit/changelog_list.html",
        {
            **base_ctx,
            "rows": rows,
            "model_choices": model_choices,
            "actions": ["create", "update", "delete"],
            "selected_model": selected_text["model_name"],
            "selected_action": selected_text["action"],
            "selected_actor": selected_text["actor_username"],
            "pagination_query": {
                "model_name": selected_text["model_name"],
                "action": selected_text["action"],
                "actor_username": selected_text["actor_username"],
            },
        },
    )


def _filter_findings(
    queryset: QuerySet[AuditFinding], request: HttpRequest
) -> tuple[QuerySet[AuditFinding], dict[str, str]]:
    """Apply ?category= / ?state= / ?action= filters; return (qs, selected)."""
    selected: dict[str, str] = {}
    for param, field, enum_cls in _FINDING_FILTERS:
        queryset, selected[param] = _apply_enum_filter(
            queryset, request, param=param, field=field, enum_cls=enum_cls
        )
    return queryset, selected


@login_required
@permission_required(VIEW_AUDITRUN, raise_exception=True)
def run_detail(request: HttpRequest, pk: int) -> HttpResponse:
    """Render one AuditRun with its paginated, filterable findings."""
    run = get_object_or_404(AuditRun, pk=pk)
    # AA-style: a viewer can see the run envelope but the findings
    # table is gated separately so a junior with ``view_auditrun``
    # does not auto-inherit per-uid disclosure. The perm gate runs
    # BEFORE any queryset shape work so a future filter pre-fetch
    # added below cannot leak counts past the gate.
    # ``request_user_has_perm`` wraps the django-stubs union narrowing
    # so the ``# type: ignore[union-attr]`` annotation lives in one
    # place (see ``permissions.py``).
    can_view_findings = request_user_has_perm(request, VIEW_AUDITFINDING)
    if can_view_findings:
        findings = AuditFinding.objects.filter(run=run).order_by(
            "category", "member_id"
        )
    else:
        findings = AuditFinding.objects.none()
    findings, selected_filters = _filter_findings(findings, request)
    # ``run_detail``'s findings table uses its own page size (thousands
    # of findings per run, vs ~25 runs per page in run_list).
    page = _paginate(request, findings, page_size=_FINDING_PAGE_SIZE)
    finding_rows = [_finding_row(f) for f in page]
    meta = {
        "state_badge": build_badge("run_state", run.state),
        "dry_run_badge": flag_badge("dry_run", value=run.dry_run),
        "lock_badge": flag_badge(
            "first_run_lock", value=run.first_run_lock_was_active
        ),
        "bypass_badge": flag_badge(
            "was_confirmation_bypassed",
            value=run.was_confirmation_bypassed,
        ),
        "error_redacted": _redact_or_blank(run.error_message),
    }
    # Summary counts come from the filtered findings queryset — when
    # ``can_view_findings`` is false that queryset is ``.none()`` so
    # all counts are 0, which is correct (no data disclosed).
    findings_summary = findings.aggregate(
        total=Count("pk"),
        kicked=Count("pk", filter=Q(was_kicked=True)),
        skipped=Count("pk", filter=Q(state=AuditFindingState.SKIPPED)),
        errors=Count("pk", filter=Q(state=AuditFindingState.FAILED)),
    )
    return render(
        request,
        "aa_discord_audit/run_detail.html",
        {
            "run": run,
            "page_obj": page,
            "categories": list(OrphanCategory),
            "finding_states": list(AuditFindingState),
            "actions": list(OrphanAction),
            "selected_filters": selected_filters,
            "can_view_findings": can_view_findings,
            "finding_rows": finding_rows,
            "meta": meta,
            "findings_summary": findings_summary,
            # Polling context for the run_detail JS client: keep the
            # context key in lockstep with the predicate it carries.
            # ``is_finished`` (the model predicate) excludes
            # ``INTERRUPTED`` — that state is FSM-immobile but still
            # resumable, so the JS client must keep polling on it.
            "is_finished": AuditRunState(run.state).is_finished,
            "terminal_states_csv": _FINISHED_STATES_CSV,
        },
    )


# Spreadsheet apps (Excel, LibreOffice Calc, Google Sheets) evaluate
# any cell beginning with one of these characters as a formula, so an
# attacker-controlled Discord display name like ``=WEBSERVICE("...")``
# or ``@SUM(cmd|...)`` becomes a remote-content-fetch / command-exec
# vector the moment an operator opens the CSV. OWASP-recommended
# defense: prefix a single-quote so the cell is treated as a literal.
# ``\t`` and ``\r`` are included because Calc interprets them as
# cell-break / row-break triggers respectively.
_CSV_FORMULA_TRIGGERS: Final[tuple[str, ...]] = (
    "=",
    "+",
    "-",
    "@",
    "\t",
    "\r",
)


def _csv_defang(value: object) -> object:
    """
    Neutralise CSV-formula injection for string cells.

    Non-string cells (ints, JSON literals produced by ``json.dumps``)
    pass through unchanged because spreadsheet apps don't interpret
    numeric or quoted-JSON content as formula triggers.
    """
    if isinstance(value, str) and value.startswith(_CSV_FORMULA_TRIGGERS):
        return "'" + value
    return value


def _finding_csv_rows(run: AuditRun) -> Iterator[list[object]]:
    """Yield the CSV header then one row per AuditFinding."""
    yield list(_CSV_HEADER)
    qs = AuditFinding.objects.filter(run=run).order_by("category", "member_id")
    for finding in qs.iterator(chunk_size=_CSV_CHUNK_SIZE):
        # ``redact_str`` on ``error_message`` is defense-in-depth: every
        # writer site already runs the message through
        # ``redact_exception_message`` before persistence, but a future
        # writer path that misses the helper would otherwise leak the
        # webhook URL into a downloadable CSV — a particularly hostile
        # leak channel because the file gets shared by email / chat.
        yield [
            finding.member_id,
            _csv_defang(finding.member_username),
            finding.category,
            finding.state,
            finding.action,
            "true" if finding.was_kicked else "false",
            # JSONField list fields render as JSON literals so a
            # comma in a role name does not collide with the CSV
            # separator and ``csv.writer``'s quoting stays minimal.
            # ``json.dumps`` output starts with ``[`` — outside the
            # formula-trigger set — so no defang needed.
            json.dumps(list(finding.role_ids_removed or [])),
            json.dumps(list(finding.role_names_removed or [])),
            finding.skipped_reason or "",
            _csv_defang(redact_str(finding.error_message or "")),
        ]


@login_required
@permission_required(VIEW_AUDITFINDING, raise_exception=True)
def finding_explain(request: HttpRequest, pk: int) -> HttpResponse:
    """
    Render a per-finding "why" page.

    Shows the historical outcome recorded on the ``AuditFinding``
    row (category, action, state, role_ids_removed, ...) alongside
    the *current* state of the operator-config inputs classify()
    consults — ``BotAccountUid``, ``ProtectedDiscordMember``, the
    ``DiscordUser`` link, and ``DiscordIdentity`` presence. The
    distinction matters: an operator change between the run and
    the page load (e.g. adding a ``BotAccountUid`` row) shows up
    as a divergence between the recorded category and the current
    inputs, which is precisely what the operator wants to see when
    investigating "why was this flagged?".

    No Discord API call is made; this view is read-only over the
    local DB so a director can drill into thousands of findings
    without warming the bot rate limit.
    """
    finding = get_object_or_404(
        AuditFinding.objects.select_related("run", "aa_user"),
        pk=pk,
    )
    member_id = finding.member_id
    in_bot_uids = BotAccountUid.objects.filter(uid=member_id).exists()
    is_protected = ProtectedDiscordMember.objects.filter(
        uid=member_id
    ).exists()
    identity = DiscordIdentity.objects.filter(uid=member_id).first()
    # ``DiscordUser`` lives in AA core; importing it eagerly at module
    # scope would couple the view module's load to Alliance Auth's
    # initialisation order. Lazy-import keeps the dependency at the
    # call site where it is actually used.
    from allianceauth.services.modules.discord.models import DiscordUser

    discord_user = DiscordUser.objects.filter(uid=member_id).first()
    run = finding.run
    return render(
        request,
        "aa_discord_audit/finding_explain.html",
        {
            "finding": finding,
            "run": run,
            "in_bot_uids": in_bot_uids,
            "is_protected": is_protected,
            "has_discord_user": discord_user is not None,
            "discord_user_user_id": (
                discord_user.user_id if discord_user is not None else None
            ),
            "has_identity": identity is not None,
            "identity": identity,
            "category_badge": build_badge(
                "finding_category", finding.category
            ),
            "state_badge": build_badge("finding_state", finding.state),
            "action_badge": build_badge("action", finding.action),
            "kicked_badge": flag_badge("was_kicked", value=finding.was_kicked),
            "error_redacted": _redact_or_blank(finding.error_message),
            "policy_json": pretty_json(run.policy_snapshot),
            "as_of": timezone.localtime(timezone.now()).strftime(
                _ABS_FMT_SECOND + " %Z"
            ),
        },
    )


@login_required
@permission_required(VIEW_AUDITFINDING, raise_exception=True)
def findings_csv(request: HttpRequest, pk: int) -> StreamingHttpResponse:
    """Stream the findings of one run as a CSV download."""
    del request
    run = get_object_or_404(AuditRun, pk=pk)
    writer = csv.writer(_CsvEcho())
    response = StreamingHttpResponse(
        (writer.writerow(row) for row in _finding_csv_rows(run)),
        content_type="text/csv",
    )
    response["Content-Disposition"] = (
        f'attachment; filename="audit_run_{pk}_findings.csv"'
    )
    return response
