"""
Field-level before/after diff for ConfigChangeLog ``update`` rows.

The persisted ``payload`` already redacts secrets at write time
(``signals._snapshot`` -> ``_redact_snapshot_value``); the assembled
diff is passed through ``redact_str`` again at render as
defense-in-depth. The "previous row" is looked up independently of the
changelog page's GET filters (true prior state, not prior-visible), so
a diff stays correct even when the operator filters to
``action=update``.

The prior-row lookup is one query per ``update`` row rendered. The
ConfigChangeLog table is an operator-config forensic log (rare
edits), so the row count stays small in practice; batch-prefetching
is deferred until a deployment shows a large changelog.
"""

from __future__ import annotations

import json
from typing import Literal, TypeAlias, TypedDict

from django.db.models import Q

from ._display import pretty_json
from ._redaction import redact_str
from .models import ConfigChangeLog


class _DeletedDiff(TypedDict):
    """Render model for a ``delete`` changelog action."""

    kind: Literal["deleted"]


class _SnapshotDiff(TypedDict):
    """Render model for a ``create`` or an ``update`` with no prior row."""

    kind: Literal["snapshot"]
    json: str


class _DiffDiff(TypedDict):
    """Render model for an ``update`` action with a resolvable prior row."""

    kind: Literal["diff"]
    changed: dict[str, dict[str, str]]


_ChangelogDiff: TypeAlias = _DeletedDiff | _SnapshotDiff | _DiffDiff


def _prior_row(entry: ConfigChangeLog) -> ConfigChangeLog | None:
    """The chronologically-previous row for the same target object."""
    # Annotated local: mypy reads ``.first()`` as ``Any`` (no django-stubs
    # plugin) and the declared type pins the return without a cast, which
    # basedpyright would otherwise flag as redundant.
    prior: ConfigChangeLog | None = (
        ConfigChangeLog.objects.filter(
            model_name=entry.model_name, object_pk=entry.object_pk
        )
        .filter(
            Q(created_at__lt=entry.created_at)
            | Q(created_at=entry.created_at, pk__lt=entry.pk)
        )
        .order_by("-created_at", "-pk")
        .first()
    )
    return prior


def _pretty(value: object) -> str:
    """Indented, redacted JSON for a snapshot block."""
    return redact_str(pretty_json(value))


def _render_value(value: object) -> str:
    """Stringify a diff cell as JSON (scalars verbatim), then redact."""
    text = (
        value
        if isinstance(value, str)
        else json.dumps(value, ensure_ascii=False, sort_keys=True)
    )
    return redact_str(text)


def diff_for_entry(entry: ConfigChangeLog) -> _ChangelogDiff:
    """
    Render model for one changelog row's payload.

    Returns ``{"kind": ...}`` where kind is one of:

    - ``"deleted"``  -- delete action, no snapshot exists.
    - ``"snapshot"`` -- create, or update with no prior row; full JSON.
    - ``"diff"``     -- update with a prior row; per-field before/after.
    """
    if entry.action == "delete":
        return {"kind": "deleted"}
    snapshot = entry.payload.get("snapshot", {})
    if entry.action != "update":
        return {"kind": "snapshot", "json": _pretty(snapshot)}
    prior = _prior_row(entry)
    if prior is None or "snapshot" not in prior.payload:
        return {"kind": "snapshot", "json": _pretty(snapshot)}
    before = prior.payload["snapshot"]
    # Empty/absent ``fields_changed`` falls back to the full key union;
    # the signals writer only sets it when non-empty (signals.py guard).
    keys = entry.payload.get("fields_changed") or sorted(
        set(before) | set(snapshot)
    )
    changed = {
        k: {
            "before": _render_value(before.get(k)),
            "after": _render_value(snapshot.get(k)),
        }
        for k in keys
        if before.get(k) != snapshot.get(k)
    }
    return {"kind": "diff", "changed": changed}
