"""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

from ._debug_session import normalize_snapshot_view

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

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

ChangeKind = Literal["modified", "added", "removed", "type_changed", "shape_changed"]
HypothesisDeltaKind = Literal["unknown", "unchanged", "refined", "replaced", "added", "removed"]


@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 EvidenceDelta:
    new_frames: tuple[str, ...] = ()
    dropped_frames: tuple[str, ...] = ()
    message_changed: bool = False


@dataclass(frozen=True)
class ChangeDelta:
    touched_files: tuple[str, ...] = ()
    touched_functions: tuple[str, ...] = ()


@dataclass(frozen=True)
class VerificationDelta:
    old_status: str = "unknown"
    new_status: str = "unknown"
    status_changed: bool = False


@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
    failure_signature_same: bool = False
    evidence_delta: EvidenceDelta = field(default_factory=EvidenceDelta)
    change_delta: ChangeDelta = field(default_factory=ChangeDelta)
    hypothesis_delta: HypothesisDeltaKind = "unknown"
    verification_delta: VerificationDelta = field(default_factory=VerificationDelta)


# ---------------------------------------------------------------------------
# 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 _frame_id(frame: dict[str, Any]) -> str:
    file_rel = frame.get("file_rel", frame.get("file", "?"))
    func = frame.get("function", "?")
    line = frame.get("line", "?")
    return f"{file_rel}:{line} in {func}"


def _diff_evidence(
    old_view: dict[str, Any],
    new_view: dict[str, Any],
) -> EvidenceDelta:
    old_frames = old_view.get("frames") or []
    new_frames = new_view.get("frames") or []
    old_ids = {_frame_id(frame) for frame in old_frames if isinstance(frame, dict)}
    new_ids = {_frame_id(frame) for frame in new_frames if isinstance(frame, dict)}
    old_msg = str((old_view.get("exception") or {}).get("message", ""))
    new_msg = str((new_view.get("exception") or {}).get("message", ""))
    return EvidenceDelta(
        new_frames=tuple(sorted(new_ids - old_ids)),
        dropped_frames=tuple(sorted(old_ids - new_ids)),
        message_changed=old_msg != new_msg,
    )


def _diff_change_delta(
    old_view: dict[str, Any],
    new_view: dict[str, Any],
    *,
    location_changed: bool,
) -> ChangeDelta:
    old_frames = old_view.get("frames") or []
    new_frames = new_view.get("frames") or []

    old_files = {
        str(frame.get("file_rel", frame.get("file", "?")))
        for frame in old_frames
        if isinstance(frame, dict)
    }
    new_files = {
        str(frame.get("file_rel", frame.get("file", "?")))
        for frame in new_frames
        if isinstance(frame, dict)
    }
    touched_files = sorted(old_files ^ new_files)
    if not touched_files and location_changed:
        old_cfi = int(old_view.get("crash_frame_index", 0))
        new_cfi = int(new_view.get("crash_frame_index", 0))
        if 0 <= old_cfi < len(old_frames) and isinstance(old_frames[old_cfi], dict):
            touched_files.append(
                str(old_frames[old_cfi].get("file_rel", old_frames[old_cfi].get("file", "?")))
            )
        if 0 <= new_cfi < len(new_frames) and isinstance(new_frames[new_cfi], dict):
            touched_files.append(
                str(new_frames[new_cfi].get("file_rel", new_frames[new_cfi].get("file", "?")))
            )
        touched_files = sorted({path for path in touched_files if path})

    old_funcs = {str(frame.get("function", "?")) for frame in old_frames if isinstance(frame, dict)}
    new_funcs = {str(frame.get("function", "?")) for frame in new_frames if isinstance(frame, dict)}
    touched_functions = sorted(old_funcs ^ new_funcs)
    if not touched_functions and location_changed:
        old_cfi = int(old_view.get("crash_frame_index", 0))
        new_cfi = int(new_view.get("crash_frame_index", 0))
        if 0 <= old_cfi < len(old_frames) and isinstance(old_frames[old_cfi], dict):
            touched_functions.append(str(old_frames[old_cfi].get("function", "?")))
        if 0 <= new_cfi < len(new_frames) and isinstance(new_frames[new_cfi], dict):
            touched_functions.append(str(new_frames[new_cfi].get("function", "?")))
        touched_functions = sorted({fn for fn in touched_functions if fn})

    return ChangeDelta(
        touched_files=tuple(touched_files),
        touched_functions=tuple(touched_functions),
    )


def _extract_hypothesis(view: dict[str, Any]) -> str:
    if isinstance(view.get("hypothesis"), str):
        return str(view.get("hypothesis"))
    rca = view.get("rca")
    if isinstance(rca, dict) and isinstance(rca.get("hypothesis"), str):
        return str(rca.get("hypothesis"))
    return ""


def _classify_hypothesis_delta(old_value: str, new_value: str) -> HypothesisDeltaKind:
    old = old_value.strip()
    new = new_value.strip()
    if not old and not new:
        return "unknown"
    if old == new:
        return "unchanged"
    if not old and new:
        return "added"
    if old and not new:
        return "removed"
    if old and new and old in new:
        return "refined"
    return "replaced"


def _extract_verification_status(view: dict[str, Any]) -> str:
    if isinstance(view.get("verification_result"), dict):
        status = str(view["verification_result"].get("status") or "").strip()
        if status:
            return status
    rca = view.get("rca")
    if isinstance(rca, dict) and isinstance(rca.get("verification_result"), dict):
        status = str(rca["verification_result"].get("status") or "").strip()
        if status:
            return status
    return "unknown"


def _failure_signature(view: dict[str, Any]) -> str:
    exc = view.get("exception", {})
    exc_type = str(exc.get("type", ""))
    exc_msg = str(exc.get("message", ""))
    frames = view.get("frames") or []
    raw_cfi = view.get("crash_frame_index", 0)
    crash_idx = raw_cfi if isinstance(raw_cfi, int) else 0
    location = "?"
    if (
        isinstance(frames, list)
        and frames
        and 0 <= crash_idx < len(frames)
        and isinstance(frames[crash_idx], dict)
    ):
        location = _frame_id(frames[crash_idx])
    return f"{exc_type}|{exc_msg}|{location}"


def diff_snapshots(old: dict[str, Any], new: dict[str, Any]) -> SnapshotDiffResult:
    """Compare two full snapshots and return a structured diff."""
    old_view = normalize_snapshot_view(old)
    new_view = normalize_snapshot_view(new)

    old_exc = old_view.get("exception", {})
    new_exc = new_view.get("exception", {})
    exc_diff = diff_exception(old_exc, new_exc)

    old_frames = old_view.get("frames") or []
    new_frames = new_view.get("frames") or []
    raw_old_cfi = old_view.get("crash_frame_index", 0)
    old_cfi = raw_old_cfi if isinstance(raw_old_cfi, int) else 0
    raw_new_cfi = new_view.get("crash_frame_index", 0)
    new_cfi = raw_new_cfi if isinstance(raw_new_cfi, int) else 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_view.get("git") or {}
    new_git = new_view.get("git") or {}
    git_changed = old_git.get("commit", "") != new_git.get("commit", "")

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

    evidence_delta = _diff_evidence(old_view, new_view)
    change_delta = _diff_change_delta(old_view, new_view, location_changed=loc_diff.changed)
    old_hypothesis = _extract_hypothesis(old_view)
    new_hypothesis = _extract_hypothesis(new_view)
    hypothesis_delta = _classify_hypothesis_delta(old_hypothesis, new_hypothesis)
    old_verification_status = _extract_verification_status(old_view)
    new_verification_status = _extract_verification_status(new_view)
    verification_delta = VerificationDelta(
        old_status=old_verification_status,
        new_status=new_verification_status,
        status_changed=old_verification_status != new_verification_status,
    )
    failure_signature_same = _failure_signature(old_view) == _failure_signature(new_view)

    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_view.get("name", "?"),
        new_name=new_view.get("name", "?"),
        same_test=same_test,
        failure_signature_same=failure_signature_same,
        evidence_delta=evidence_delta,
        change_delta=change_delta,
        hypothesis_delta=hypothesis_delta,
        verification_delta=verification_delta,
    )


# ---------------------------------------------------------------------------
# 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 structured_diff_summary(result: SnapshotDiffResult) -> dict[str, Any]:
    """Return machine-oriented structured diff summary for prompts/MCP."""
    return {
        "attempt_prev": result.old_name,
        "attempt_curr": result.new_name,
        "failure_signature_same": result.failure_signature_same,
        "evidence_delta": {
            "new_frames": list(result.evidence_delta.new_frames),
            "dropped_frames": list(result.evidence_delta.dropped_frames),
            "message_changed": result.evidence_delta.message_changed,
        },
        "change_delta": {
            "touched_files": list(result.change_delta.touched_files),
            "touched_functions": list(result.change_delta.touched_functions),
        },
        "hypothesis_delta": result.hypothesis_delta,
        "verification_delta": {
            "old_status": result.verification_delta.old_status,
            "new_status": result.verification_delta.new_status,
            "status_changed": result.verification_delta.status_changed,
        },
    }


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}")

    structured = structured_diff_summary(result)
    sections.append(
        "## Structured Delta\n"
        f"failure_signature_same: {structured['failure_signature_same']}\n"
        f"hypothesis_delta: {structured['hypothesis_delta']}\n"
        "verification_status: "
        f"{structured['verification_delta']['old_status']} -> "
        f"{structured['verification_delta']['new_status']}"
    )

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