"""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, Final, cast

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

from .models import (
    AuditFinding,
    AuditFindingState,
    AuditInvocation,
    AuditRun,
    AuditRunState,
    BotAccountUid,
    ConfigChangeLog,
    DiscordIdentity,
    InvocationRefusal,
    OrphanAction,
    OrphanCategory,
    ProtectedDiscordMember,
    RunTrigger,
)
from .permissions import (
    VIEW_AUDITFINDING,
    VIEW_AUDITINVOCATION,
    VIEW_AUDITRUN,
    VIEW_CONFIGCHANGELOG,
)

if TYPE_CHECKING:
    from collections.abc import Iterator
    from enum import Enum

    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

# 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",
    "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 _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_str, to_str, 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_str = request.GET.get("from") or ""
    to_str = request.GET.get("to") or ""
    out: dict[str, object] = {}
    tz = timezone.get_current_timezone()
    if from_str:
        try:
            from_dt = datetime.strptime(from_str, "%Y-%m-%d").replace(
                tzinfo=tz
            )
        except ValueError:
            return from_str, to_str, _INVALID_DATE_FILTER
        out["started_at__gte"] = from_dt
    if to_str:
        try:
            to_dt = datetime.strptime(to_str, "%Y-%m-%d").replace(
                tzinfo=tz
            ) + timedelta(days=_DATE_RANGE_END_OFFSET_DAYS)
        except ValueError:
            return from_str, to_str, _INVALID_DATE_FILTER
        out["started_at__lt"] = to_dt
    return from_str, to_str, 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


# 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.all().order_by("-started_at", "-id")
    queryset, state_filter = _apply_enum_filter(
        queryset,
        request,
        param="state",
        field="state",
        enum_cls=AuditRunState,
    )
    from_str, to_str, 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))
    paginator = Paginator(queryset, _LIST_PAGE_SIZE)
    page = paginator.get_page(request.GET.get("page"))
    return render(
        request,
        "aa_discord_audit/run_list.html",
        {
            "page_obj": page,
            "states": list(AuditRunState),
            "selected_state": state_filter,
            "selected_from": from_str,
            "selected_to": to_str,
        },
    )


@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"
    ).order_by("-started_at", "-id")
    accepted_filter = request.GET.get("accepted") or ""
    accepted_map = {"true": True, "false": False}
    if accepted_filter in accepted_map:
        queryset = queryset.filter(accepted=accepted_map[accepted_filter])
    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,
    )
    paginator = Paginator(queryset, _LIST_PAGE_SIZE)
    page = paginator.get_page(request.GET.get("page"))
    return render(
        request,
        "aa_discord_audit/invocation_list.html",
        {
            "page_obj": page,
            "refusal_reasons": list(InvocationRefusal),
            "triggers": list(RunTrigger),
            "selected_accepted": accepted_filter,
            "selected_refusal": refusal_filter,
            "selected_triggered_by": triggered_by_filter,
        },
    )


@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")
    selected_text: dict[str, str] = {}
    for field in _CHANGELOG_TEXT_FILTERS:
        value = request.GET.get(field) or ""
        selected_text[field] = value
        if value:
            queryset = queryset.filter(**{field: value})
    model_filter = selected_text["model_name"]
    action_filter = selected_text["action"]
    actor_filter = selected_text["actor_username"]
    paginator = Paginator(queryset, _LIST_PAGE_SIZE)
    page = paginator.get_page(request.GET.get("page"))
    # 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()
    )
    return render(
        request,
        "aa_discord_audit/changelog_list.html",
        {
            "page_obj": page,
            "model_choices": model_choices,
            "actions": ["create", "update", "delete"],
            "selected_model": model_filter,
            "selected_action": action_filter,
            "selected_actor": actor_filter,
        },
    )


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, raw = _apply_enum_filter(
            queryset, request, param=param, field=field, enum_cls=enum_cls
        )
        selected[param] = raw
    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)
    findings = AuditFinding.objects.filter(run=run).order_by(
        "category", "member_id"
    )
    # ``request.user`` is typed as ``AbstractBaseUser | AnonymousUser``
    # by django-stubs; ``has_perm`` exists on both runtime classes
    # AA hands us (PermissionsMixin via the configured user model;
    # AnonymousUser's own has_perm returns False).
    can_view_findings = request.user.has_perm(VIEW_AUDITFINDING)  # type: ignore[union-attr]
    if not can_view_findings:
        # 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.
        findings = findings.none()
    findings, selected_filters = _filter_findings(findings, request)
    paginator = Paginator(findings, _FINDING_PAGE_SIZE)
    page = paginator.get_page(request.GET.get("page"))
    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,
        },
    )


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):
        yield [
            finding.member_id,
            finding.member_username,
            finding.category,
            finding.state,
            finding.action,
            "true" if finding.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(list(finding.role_ids_removed or [])),
            json.dumps(list(finding.role_names_removed or [])),
            finding.skipped_reason or "",
            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()
    return render(
        request,
        "aa_discord_audit/finding_explain.html",
        {
            "finding": finding,
            "run": finding.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,
        },
    )


@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
