"""Snapshot diffing — compare two debug snapshots to show what changed.

Pure logic module with zero external dependencies. Used by CLI and MCP server.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Literal

# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------

_ARRAY_KEYS = ("__array__", "__dataframe__", "__series__")

ChangeKind = Literal["modified", "added", "removed", "type_changed", "shape_changed"]


@dataclass(frozen=True)
class ExceptionDiff:
    changed: bool
    old_type: str
    new_type: str
    old_message: str
    new_message: str
    old_category: str
    new_category: str


@dataclass(frozen=True)
class LocationDiff:
    changed: bool
    old_location: str  # "file.py:42 in func_name"
    new_location: str


@dataclass(frozen=True)
class LocalChange:
    kind: ChangeKind
    name: str
    old_value: Any = None
    new_value: Any = None
    old_type: str = ""
    new_type: str = ""


@dataclass(frozen=True)
class LocalsDiff:
    changes: tuple[LocalChange, ...] = ()
    unchanged_count: int = 0


@dataclass(frozen=True)
class SnapshotDiffResult:
    exception: ExceptionDiff
    location: LocationDiff
    locals_diff: LocalsDiff
    git_changed: bool
    old_git: dict[str, Any] = field(default_factory=dict)
    new_git: dict[str, Any] = field(default_factory=dict)
    old_name: str = ""
    new_name: str = ""
    same_test: bool = True


# ---------------------------------------------------------------------------
# Diff functions
# ---------------------------------------------------------------------------


def diff_exception(old_exc: dict[str, Any], new_exc: dict[str, Any]) -> ExceptionDiff:
    """Compare exception type, message, and error category."""
    old_type = old_exc.get("type", "")
    new_type = new_exc.get("type", "")
    old_msg = old_exc.get("message", "")
    new_msg = new_exc.get("message", "")
    old_cat = (old_exc.get("error_category") or {}).get("category", "")
    new_cat = (new_exc.get("error_category") or {}).get("category", "")

    changed = old_type != new_type or old_msg != new_msg or old_cat != new_cat
    return ExceptionDiff(
        changed=changed,
        old_type=old_type,
        new_type=new_type,
        old_message=old_msg,
        new_message=new_msg,
        old_category=old_cat,
        new_category=new_cat,
    )


def _format_location(frame: dict[str, Any]) -> str:
    """Build 'file:line in function' from a frame dict."""
    file_rel = frame.get("file_rel", frame.get("file", "?"))
    line = frame.get("line", "?")
    func = frame.get("function", "?")
    return f"{file_rel}:{line} in {func}"


def diff_location(
    old_frames: list[dict[str, Any]],
    new_frames: list[dict[str, Any]],
    old_crash_idx: int,
    new_crash_idx: int,
) -> LocationDiff:
    """Compare crash location between two snapshots."""
    old_loc = (
        _format_location(old_frames[old_crash_idx])
        if old_frames and 0 <= old_crash_idx < len(old_frames)
        else "?"
    )
    new_loc = (
        _format_location(new_frames[new_crash_idx])
        if new_frames and 0 <= new_crash_idx < len(new_frames)
        else "?"
    )
    return LocationDiff(changed=old_loc != new_loc, old_location=old_loc, new_location=new_loc)


def _is_array_summary(value: Any) -> bool:
    """Check if value is an llmdebug array/dataframe/series summary dict."""
    return isinstance(value, dict) and any(value.get(k) for k in _ARRAY_KEYS)


def _get_shape(value: dict[str, Any]) -> list[Any] | None:
    """Extract shape from an array summary, or None."""
    shape = value.get("shape")
    return list(shape) if isinstance(shape, (list, tuple)) else None


def diff_locals(
    old_frame: dict[str, Any],
    new_frame: dict[str, Any],
) -> LocalsDiff:
    """Compare local variables between two crash frames."""
    old_locals = old_frame.get("locals", {})
    new_locals = new_frame.get("locals", {})
    old_meta = old_frame.get("locals_meta", {})
    new_meta = new_frame.get("locals_meta", {})

    old_keys = {k for k in old_locals if not k.startswith("@py_")}
    new_keys = {k for k in new_locals if not k.startswith("@py_")}

    changes: list[LocalChange] = []
    unchanged = 0

    # Removed
    for k in sorted(old_keys - new_keys):
        old_type = (old_meta.get(k) or {}).get("type", "")
        changes.append(LocalChange(
            kind="removed", name=k,
            old_value=old_locals[k], new_value=None,
            old_type=old_type, new_type="",
        ))

    # Added
    for k in sorted(new_keys - old_keys):
        new_type = (new_meta.get(k) or {}).get("type", "")
        changes.append(LocalChange(
            kind="added", name=k,
            old_value=None, new_value=new_locals[k],
            old_type="", new_type=new_type,
        ))

    # Shared keys — compare values
    for k in sorted(old_keys & new_keys):
        old_val = old_locals[k]
        new_val = new_locals[k]
        old_type = (old_meta.get(k) or {}).get("type", "")
        new_type = (new_meta.get(k) or {}).get("type", "")

        # Array summaries: compare shape/dtype specifically
        if _is_array_summary(old_val) and _is_array_summary(new_val):
            old_shape = _get_shape(old_val)
            new_shape = _get_shape(new_val)
            old_dtype = old_val.get("dtype", "")
            new_dtype = new_val.get("dtype", "")

            if old_shape != new_shape:
                changes.append(LocalChange(
                    kind="shape_changed", name=k,
                    old_value=old_shape, new_value=new_shape,
                    old_type=old_dtype, new_type=new_dtype,
                ))
                continue
            if old_dtype != new_dtype or old_val != new_val:
                changes.append(LocalChange(
                    kind="modified", name=k,
                    old_value=old_val, new_value=new_val,
                    old_type=old_type, new_type=new_type,
                ))
                continue
            unchanged += 1
            continue

        # Type changed
        if old_type and new_type and old_type != new_type:
            changes.append(LocalChange(
                kind="type_changed", name=k,
                old_value=old_val, new_value=new_val,
                old_type=old_type, new_type=new_type,
            ))
            continue

        # Value comparison
        if old_val != new_val:
            changes.append(LocalChange(
                kind="modified", name=k,
                old_value=old_val, new_value=new_val,
                old_type=old_type, new_type=new_type,
            ))
        else:
            unchanged += 1

    return LocalsDiff(changes=tuple(changes), unchanged_count=unchanged)


def diff_snapshots(old: dict[str, Any], new: dict[str, Any]) -> SnapshotDiffResult:
    """Compare two full snapshots and return a structured diff."""
    old_exc = old.get("exception", {})
    new_exc = new.get("exception", {})
    exc_diff = diff_exception(old_exc, new_exc)

    old_frames = old.get("frames") or []
    new_frames = new.get("frames") or []
    old_cfi = old.get("crash_frame_index", 0)
    new_cfi = new.get("crash_frame_index", 0)
    loc_diff = diff_location(old_frames, new_frames, old_cfi, new_cfi)

    # Locals diff from crash frames
    old_crash = old_frames[old_cfi] if old_frames and 0 <= old_cfi < len(old_frames) else {}
    new_crash = new_frames[new_cfi] if new_frames and 0 <= new_cfi < len(new_frames) else {}
    locals_diff = diff_locals(old_crash, new_crash)

    # Git
    old_git = old.get("git") or {}
    new_git = new.get("git") or {}
    git_changed = old_git.get("commit", "") != new_git.get("commit", "")

    # Test identity
    old_nodeid = (old.get("pytest") or {}).get("nodeid", "")
    new_nodeid = (new.get("pytest") or {}).get("nodeid", "")
    same_test = old_nodeid == new_nodeid

    return SnapshotDiffResult(
        exception=exc_diff,
        location=loc_diff,
        locals_diff=locals_diff,
        git_changed=git_changed,
        old_git=old_git,
        new_git=new_git,
        old_name=old.get("name", "?"),
        new_name=new.get("name", "?"),
        same_test=same_test,
    )


# ---------------------------------------------------------------------------
# Text formatter
# ---------------------------------------------------------------------------


def _truncate(value: Any, max_len: int = 80) -> str:
    """Stringify and truncate a value for display."""
    s = str(value)
    return s[:max_len - 3] + "..." if len(s) > max_len else s


def format_diff_text(result: SnapshotDiffResult) -> str:
    """Render a SnapshotDiffResult as structured text for MCP/CLI fallback."""
    # Check if everything is identical
    if (
        not result.exception.changed
        and not result.location.changed
        and not result.locals_diff.changes
        and not result.git_changed
    ):
        return "No changes detected. Snapshots are identical."

    sections: list[str] = []

    # Header
    sections.append(f"## Snapshot Diff\nold: {result.old_name}\nnew: {result.new_name}")

    # Exception
    exc = result.exception
    if exc.changed:
        lines = [f"## Exception\nCHANGED: {exc.old_type} -> {exc.new_type}"]
        if exc.old_message != exc.new_message:
            lines.append(f"  old: {_truncate(exc.old_message)}")
            lines.append(f"  new: {_truncate(exc.new_message)}")
        if exc.old_category != exc.new_category:
            lines.append(f"  category: {exc.old_category} -> {exc.new_category}")
        sections.append("\n".join(lines))
    else:
        sections.append(f"## Exception\nUNCHANGED: {exc.old_type}: {_truncate(exc.old_message)}")

    # Location
    loc = result.location
    if loc.changed:
        sections.append(f"## Crash Location\nMOVED: {loc.old_location} -> {loc.new_location}")
    else:
        sections.append(f"## Crash Location\nUNCHANGED: {loc.old_location}")

    # Locals
    ld = result.locals_diff
    if ld.changes or ld.unchanged_count > 0:
        lines = ["## Locals (crash frame)"]
        for c in ld.changes:
            if c.kind == "modified":
                old = _truncate(c.old_value)
                new = _truncate(c.new_value)
                lines.append(f"MODIFIED: {c.name}  {old} -> {new}")
            elif c.kind == "added":
                lines.append(f"ADDED: {c.name} = {_truncate(c.new_value)}")
            elif c.kind == "removed":
                lines.append(f"REMOVED: {c.name}  (was: {_truncate(c.old_value)})")
            elif c.kind == "type_changed":
                lines.append(
                    f"TYPE CHANGED: {c.name}  {c.old_type} -> {c.new_type}"
                    f"  ({_truncate(c.old_value)} -> {_truncate(c.new_value)})"
                )
            elif c.kind == "shape_changed":
                empty_flag = ""
                if isinstance(c.new_value, list) and c.new_value and c.new_value[0] == 0:
                    empty_flag = "  !! EMPTY"
                lines.append(
                    f"SHAPE CHANGED: {c.name}  {c.old_value} -> {c.new_value}{empty_flag}"
                )
        if ld.unchanged_count > 0:
            lines.append(f"({ld.unchanged_count} unchanged)")
        sections.append("\n".join(lines))

    # Git
    if result.git_changed:
        old_commit = result.old_git.get("commit", "?")
        new_commit = result.new_git.get("commit", "?")
        sections.append(f"## Git\ncommit: {old_commit} -> {new_commit}")

    return "\n\n".join(sections)
