"""llmdebug CLI — View and manage debug snapshots."""

from __future__ import annotations

try:
    import click
except ImportError:
    import sys

    print(
        "llmdebug CLI requires extra dependencies.\nInstall with: pip install llmdebug[cli]",
        file=sys.stderr,
    )
    raise SystemExit(1) from None

import json
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any

from ._debug_session import coerce_crash_frame_index
from ._formatting import (
    build_source_text,
    format_enhanced_git_context_text,
    format_local_value,
)
from ._snapshot_loader import (
    find_all_snapshots as _find_all_snapshots_impl,
)
from ._snapshot_loader import (
    load_snapshot as _load_snapshot_impl,
)
from ._snapshot_loader import (
    load_snapshot_from_file as _load_snapshot_from_file_impl,
)
from ._snapshot_loader import (
    load_snapshot_raw as _load_snapshot_raw_impl,
)
from ._snapshot_loader import (
    resolve_snapshot_ref as _resolve_snapshot_ref_impl,
)

# Rich is optional — degrade gracefully to plain text.
# TYPE_CHECKING import ensures pyright sees the types as always available.
if TYPE_CHECKING:
    from rich.console import Console
    from rich.panel import Panel
    from rich.table import Table
    from rich.text import Text

try:
    from rich.console import Console
    from rich.panel import Panel
    from rich.table import Table
    from rich.text import Text

    HAS_RICH = True
except ImportError:
    HAS_RICH = False

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _get_console() -> Any | None:
    """Return a Rich Console if available, else None."""
    if HAS_RICH:
        return Console(stderr=False)
    return None


def _load_snapshot(path: str | None, out_dir: str) -> dict[str, Any]:
    """Load a snapshot from an explicit path or fall back to latest."""
    try:
        return _load_snapshot_impl(path, out_dir, _trust_path=True)
    except FileNotFoundError as e:
        click.echo(str(e), err=True)
        raise SystemExit(1) from e


def _load_snapshot_raw(path: str | None, out_dir: str) -> dict[str, Any]:
    """Load a raw snapshot payload (DebugSession envelope when present)."""
    try:
        return _load_snapshot_raw_impl(path, out_dir, _trust_path=True)
    except FileNotFoundError as e:
        click.echo(str(e), err=True)
        raise SystemExit(1) from e


def _load_snapshot_from_file(path: Path) -> dict[str, Any]:
    """Load and auto-expand a single snapshot file."""
    try:
        return _load_snapshot_from_file_impl(path)
    except (FileNotFoundError, ImportError) as e:
        click.echo(str(e), err=True)
        raise SystemExit(1) from e


def _find_all_snapshots(out_dir: str) -> list[dict[str, Any]]:
    """Scan output dir and return snapshot metadata sorted newest-first."""
    return _find_all_snapshots_impl(out_dir)


def _iter_valid_frame_items(snapshot: dict[str, Any]) -> list[tuple[int, dict[str, Any]]]:
    """Return `(index, frame)` pairs for dict-valued frames only."""
    frames = snapshot.get("frames")
    if not isinstance(frames, list):
        return []
    return [(i, frame) for i, frame in enumerate(frames) if isinstance(frame, dict)]


def _format_timestamp(ts_str: str | None) -> str:
    """Format an ISO timestamp into a short human-readable form."""
    if not ts_str:
        return "?"
    try:
        dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
        return dt.strftime("%Y-%m-%d %H:%M:%S")
    except (ValueError, AttributeError):
        return ts_str[:19] if len(ts_str) >= 19 else ts_str


# ---------------------------------------------------------------------------
# Rich display helpers
# ---------------------------------------------------------------------------


# build_source_text and format_local_value imported from ._formatting


def _print_exception_panel(snapshot: dict[str, Any], console: Any) -> None:
    """Print exception info as a Rich panel or plain text."""
    exc = snapshot.get("exception", {})
    exc_type = exc.get("type", "UnknownError")
    exc_msg = exc.get("message", "")

    if console:
        title_text = Text(f"{exc_type}: {exc_msg}", style="bold red")
        console.print(Panel(title_text, title="Exception", border_style="red"))

        # Error category / suggestion
        ecat = exc.get("error_category", {})
        if ecat:
            cat = ecat.get("category", "")
            sug = ecat.get("suggestion", "")
            if cat or sug:
                body = Text()
                if cat:
                    body.append(f"Category: {cat}\n", style="yellow")
                if sug:
                    body.append(sug, style="dim")
                console.print(Panel(body, title="Hint", border_style="yellow"))
    else:
        click.echo(f">>> Exception: {exc_type}: {exc_msg}")
        ecat = exc.get("error_category", {})
        if ecat:
            cat = ecat.get("category", "")
            sug = ecat.get("suggestion", "")
            if cat:
                click.echo(f"    Category: {cat}")
            if sug:
                click.echo(f"    Hint: {sug}")


def _print_frame(
    frame: dict[str, Any],
    index: int,
    is_crash: bool,
    console: Any,
    *,
    show_locals: bool = True,
) -> None:
    """Print a single stack frame."""
    file_rel = frame.get("file_rel", frame.get("file", "?"))
    line = frame.get("line", "?")
    func = frame.get("function", "?")
    location = f"{file_rel}:{line} in {func}"

    source = frame.get("source", {})
    source_text = build_source_text(source, frame.get("line", 0)) if source else ""

    locals_dict = frame.get("locals", {})
    locals_meta = frame.get("locals_meta", {})

    if console:
        border = "red" if is_crash else "dim"
        title = f"{'[CRASH] ' if is_crash else ''}Frame #{index}: {location}"

        parts: list[Any] = []

        # Source snippet
        if source_text:
            parts.append(Text(source_text))

        # Locals table
        if show_locals and isinstance(locals_dict, dict) and locals_dict:
            table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1))
            table.add_column("Variable", style="cyan", no_wrap=True)
            table.add_column("Type", style="dim")
            table.add_column("Value")

            for k, v in locals_dict.items():
                # Skip pytest internal variables
                if k.startswith("@py_"):
                    continue
                name, type_str, val_str = format_local_value(k, v, locals_meta.get(k))
                table.add_row(name, type_str, val_str)

            parts.append(table)

        # Print first available part as panel
        for i, part in enumerate(parts):
            if i > 0:
                console.print()
            console.print(Panel(part, title=title, border_style=border))
            return

        if not parts:
            code = frame.get("code", "")
            console.print(Panel(Text(f"  {code}"), title=title, border_style=border))
    else:
        prefix = ">>> " if is_crash else "    "
        click.echo(f"{prefix}Frame #{index}: {location}")
        if source_text:
            for sline in source_text.split("\n"):
                click.echo(f"    {sline}")
        if show_locals and isinstance(locals_dict, dict) and locals_dict:
            click.echo("    Locals:")
            for k, v in locals_dict.items():
                if k.startswith("@py_"):
                    continue
                name, type_str, val_str = format_local_value(k, v, locals_meta.get(k))
                type_part = f" ({type_str})" if type_str else ""
                click.echo(f"      {name} = {val_str}{type_part}")


def _print_frame_rich(
    frame: dict[str, Any],
    index: int,
    is_crash: bool,
    console: Any,
    *,
    show_locals: bool = True,
) -> None:
    """Print a single stack frame with Rich rendering (source + locals in one panel)."""
    file_rel = frame.get("file_rel", frame.get("file", "?"))
    line = frame.get("line", "?")
    func = frame.get("function", "?")
    location = f"{file_rel}:{line} in {func}"

    source = frame.get("source", {})
    source_text = build_source_text(source, frame.get("line", 0)) if source else ""

    locals_dict = frame.get("locals", {})
    locals_meta = frame.get("locals_meta", {})

    border = "red" if is_crash else "dim"
    title = f"{'[CRASH] ' if is_crash else ''}Frame #{index}: {location}"

    # Source
    if source_text:
        console.print(Panel(Text(source_text), title=title, border_style=border))
    else:
        code = frame.get("code", "")
        console.print(Panel(Text(f"  {code}"), title=title, border_style=border))

    # Locals table
    if show_locals and isinstance(locals_dict, dict) and locals_dict:
        table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1))
        table.add_column("Variable", style="cyan", no_wrap=True)
        table.add_column("Type", style="dim")
        table.add_column("Value")

        for k, v in locals_dict.items():
            if k.startswith("@py_"):
                continue
            name, type_str, val_str = format_local_value(k, v, locals_meta.get(k))
            # Highlight empty array shapes in red
            array_keys = ("__array__", "__dataframe__", "__series__")
            if HAS_RICH and isinstance(v, dict) and any(v.get(ak) for ak in array_keys):
                shape = v.get("shape", [])
                if shape and shape[0] == 0:
                    val_str = f"[red]{val_str}[/red]"
            table.add_row(name, type_str, val_str)

        console.print(table)


def _print_footer(snapshot: dict[str, Any], console: Any) -> None:
    """Print git info and repro command footer."""
    parts: list[str] = []

    git = snapshot.get("git", {})
    if git:
        commit = git.get("commit", "")
        branch = git.get("branch", "")
        dirty = " *dirty*" if git.get("dirty") else ""
        if commit:
            parts.append(f"git: {commit} {branch}{dirty}")

    pt = snapshot.get("pytest", {})
    repro = pt.get("repro")
    if repro and isinstance(repro, list):
        # Make repro command relative (use python -m pytest ...)
        cmd_parts = []
        for r in repro:
            if r.endswith("/python3") or r.endswith("/python"):
                cmd_parts.append("python")
            else:
                cmd_parts.append(r)
        parts.append(f"repro: {' '.join(cmd_parts)}")

    if not parts:
        return

    footer = " | ".join(parts)
    if console:
        console.print(Text(footer, style="dim"))
    else:
        click.echo(f"--- {footer}")


# Git formatting functions imported from ._formatting


# ---------------------------------------------------------------------------
# Click commands
# ---------------------------------------------------------------------------


@click.group(invoke_without_command=True)
@click.option("--dir", "out_dir", default=".llmdebug", help="Snapshot directory")
@click.pass_context
def main(ctx: click.Context, out_dir: str) -> None:
    """llmdebug - View and manage debug snapshots."""
    ctx.ensure_object(dict)
    ctx.obj["out_dir"] = out_dir
    if ctx.invoked_subcommand is None:
        ctx.invoke(show, out_dir=out_dir)


@main.command()
@click.argument("snapshot_path", required=False)
@click.option("--full", is_flag=True, help="Show all frames (shortcut for --detail full)")
@click.option("--json", "as_json", is_flag=True, help="Output raw expanded JSON")
@click.option(
    "--detail",
    type=click.Choice(["crash", "full", "context"]),
    default=None,
    help="Detail level: crash (minimal), full (all frames), context (everything)",
)
@click.option(
    "--raw-session",
    is_flag=True,
    help="Output raw DebugSession envelope JSON (ignores detail/full formatting).",
)
@click.option("--dir", "out_dir", default=".llmdebug", help="Snapshot directory")
def show(
    snapshot_path: str | None,
    full: bool,
    as_json: bool,
    detail: str | None,
    raw_session: bool,
    out_dir: str,
) -> None:
    """Display a debug snapshot (default: latest)."""
    if raw_session:
        snapshot = _load_snapshot_raw(snapshot_path, out_dir)
        click.echo(json.dumps(snapshot, indent=2, ensure_ascii=False, default=str))
        return

    # Resolve detail level: --detail overrides --full; default is "crash"
    if detail is None:
        detail = "full" if full else "crash"

    snapshot = _load_snapshot(snapshot_path, out_dir)

    # Apply detail filter
    from .detail_filter import filter_snapshot

    filtered = filter_snapshot(snapshot, detail)  # type: ignore[arg-type]

    if as_json:
        click.echo(json.dumps(filtered, indent=2, ensure_ascii=False, default=str))
        return

    console = _get_console()

    # 1. Exception panel
    _print_exception_panel(filtered, console)

    # 2. Frames
    frame_items = _iter_valid_frame_items(filtered)
    crash_idx = coerce_crash_frame_index(filtered)

    if detail in ("full", "context"):
        for i, frame in frame_items:
            if console:
                _print_frame_rich(frame, i, i == crash_idx, console)
            else:
                _print_frame(frame, i, i == crash_idx, console)
    else:
        # "crash" level — show only the crash frame
        if frame_items:
            crash_pair = next(
                ((i, frame) for i, frame in frame_items if i == crash_idx),
                frame_items[0],
            )
            idx, crash_frame = crash_pair
            if console:
                _print_frame_rich(crash_frame, idx, True, console)
            else:
                _print_frame(crash_frame, idx, True, console)

            # Show hint about more frames from the unfiltered snapshot
            all_frames_obj = snapshot.get("frames", [])
            all_frames = all_frames_obj if isinstance(all_frames_obj, list) else []
            if len(all_frames) > 1:
                remaining = len(all_frames) - 1
                plural = "s" if remaining != 1 else ""
                hint = f"  ({remaining} more frame{plural} — use --detail full to show all)"
                if console:
                    console.print(Text(hint, style="dim"))
                else:
                    click.echo(hint)

    # 3. Footer
    if console:
        console.print()
    _print_footer(snapshot, console)


@main.command(name="list")
@click.option("--limit", "-n", default=10, type=int, help="Max snapshots to show")
@click.option("--dir", "out_dir", default=".llmdebug", help="Snapshot directory")
def list_cmd(limit: int, out_dir: str) -> None:
    """List available snapshots."""
    entries = _find_all_snapshots(out_dir)

    if not entries:
        click.echo(f"No snapshots found in {out_dir}")
        return

    entries = entries[:limit]
    console = _get_console()

    if console:
        table = Table(title=f"Snapshots ({len(entries)} shown)", show_lines=False)
        table.add_column("#", style="dim", width=3)
        table.add_column("Timestamp", style="cyan")
        table.add_column("Name")
        table.add_column("Exception", style="red")
        table.add_column("Location", style="dim")

        for i, entry in enumerate(entries, 1):
            ts = datetime.fromtimestamp(entry["mtime"], tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
            table.add_row(
                str(i),
                ts,
                str(entry["name"]),
                str(entry["exc_type"]),
                f"{entry['file']}:{entry['line']}",
            )
        console.print(table)
    else:
        click.echo(f"Snapshots ({len(entries)} shown):")
        for i, entry in enumerate(entries, 1):
            ts = datetime.fromtimestamp(entry["mtime"], tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
            loc = f"{entry['file']}:{entry['line']}"
            click.echo(f"  {i}. [{ts}] {entry['name']}  {entry['exc_type']}  {loc}")


@main.command()
@click.option(
    "--keep",
    "-k",
    default=5,
    type=click.IntRange(min=0),
    help="Keep N most recent snapshots",
)
@click.option("--all", "delete_all", is_flag=True, help="Delete all snapshots")
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
@click.option("--dir", "out_dir", default=".llmdebug", help="Snapshot directory")
def clean(keep: int, delete_all: bool, yes: bool, out_dir: str) -> None:
    """Remove old snapshots."""
    out_path = Path(out_dir)
    if not out_path.is_dir():
        click.echo(f"Directory not found: {out_dir}")
        return

    # Find all snapshot files
    snapshot_files = sorted(
        (
            p
            for p in out_path.iterdir()
            if p.is_file()
            and p.suffix in {".json", ".toon"}
            and not p.name.startswith("latest")
            and not p.name.endswith(".tmp")
        ),
        key=lambda p: p.stat().st_mtime,
        reverse=True,  # newest first
    )

    to_delete: list[Path] = []
    if delete_all:
        to_delete = list(snapshot_files)
        # Also include latest.* and lock file
        for name in ("latest.json", "latest.toon", ".llmdebug.lock"):
            extra = out_path / name
            if extra.exists():
                to_delete.append(extra)
        # Include traceback files
        to_delete.extend(
            p
            for p in out_path.iterdir()
            if p.is_file() and p.suffix == ".txt" and ".traceback" in p.name
        )
    else:
        to_delete_snapshots = snapshot_files[keep:] if keep < len(snapshot_files) else []
        for snap in to_delete_snapshots:
            to_delete.append(snap)
            # Also delete associated traceback file
            tb_file = snap.with_suffix("").with_suffix(".traceback.txt")
            if tb_file.exists():
                to_delete.append(tb_file)

    if not to_delete:
        click.echo("Nothing to delete.")
        return

    msg = f"Will delete {len(to_delete)} file(s)"
    if not delete_all:
        msg += f" (keeping {min(keep, len(snapshot_files))} snapshot(s))"
    click.echo(msg)

    if not yes and not click.confirm("Continue?"):
        click.echo("Aborted.")
        return

    deleted = 0
    failed = 0
    for p in to_delete:
        try:
            p.unlink()
            deleted += 1
        except Exception as e:
            failed += 1
            click.echo(f"  Warning: could not delete {p.name}: {e}", err=True)

    msg = f"Deleted {deleted} file(s)."
    if failed:
        msg += f" Failed to delete {failed} file(s)."
    click.echo(msg)


@main.command()
@click.argument("snapshot_path", required=False)
@click.option("--index", "-i", type=int, default=None, help="Show specific frame by index")
@click.option("--dir", "out_dir", default=".llmdebug", help="Snapshot directory")
def frames(snapshot_path: str | None, index: int | None, out_dir: str) -> None:
    """Show stack frames in detail."""
    snapshot = _load_snapshot(snapshot_path, out_dir)
    frame_list_obj = snapshot.get("frames", [])
    frame_list = frame_list_obj if isinstance(frame_list_obj, list) else []
    crash_idx = coerce_crash_frame_index(snapshot)
    console = _get_console()

    if not frame_list:
        click.echo("No frames in snapshot.")
        return

    if index is not None:
        if index < 0 or index >= len(frame_list):
            click.echo(f"Frame index {index} out of range (0-{len(frame_list) - 1})", err=True)
            raise SystemExit(1)
        frame = frame_list[index]
        if not isinstance(frame, dict):
            click.echo(f"Frame index {index} is malformed in snapshot.", err=True)
            raise SystemExit(1)
        if console:
            _print_frame_rich(frame, index, index == crash_idx, console)
        else:
            _print_frame(frame, index, index == crash_idx, console)
    else:
        for i, frame in enumerate(frame_list):
            if not isinstance(frame, dict):
                continue
            if console:
                _print_frame_rich(frame, i, i == crash_idx, console)
                console.print()
            else:
                _print_frame(frame, i, i == crash_idx, console)
                click.echo()


@main.command()
@click.argument("old_ref", required=False)
@click.argument("new_ref", required=False)
@click.option("--json", "as_json", is_flag=True, help="Output diff as JSON")
@click.option("--dir", "out_dir", default=".llmdebug", help="Snapshot directory")
def diff(old_ref: str | None, new_ref: str | None, as_json: bool, out_dir: str) -> None:
    """Compare two snapshots to show what changed.

    With no arguments, compares the two most recent snapshots.
    References: file path, #N (list index), "latest", "previous".

    \b
    Examples:
        llmdebug diff                    # latest vs previous
        llmdebug diff #2 #1             # by list index
        llmdebug diff old.json new.json  # by path
    """
    from dataclasses import asdict

    from .snapshot_diff import diff_snapshots, format_diff_text

    try:
        old = _resolve_snapshot_ref_impl(old_ref or "previous", out_dir)
        new = _resolve_snapshot_ref_impl(new_ref or "latest", out_dir)
    except (FileNotFoundError, ValueError) as e:
        click.echo(str(e), err=True)
        raise SystemExit(1) from e

    result = diff_snapshots(old, new)

    if as_json:
        click.echo(json.dumps(asdict(result), indent=2, ensure_ascii=False, default=str))
        return

    console = _get_console()

    if console:
        _print_diff_rich(result, console)
    else:
        click.echo(format_diff_text(result))


@main.command(name="git-context")
@click.argument("snapshot_ref", required=False)
@click.option("--json", "as_json", is_flag=True, help="Output enhanced git context as JSON")
@click.option(
    "--max-commits",
    type=click.IntRange(1, 50),
    default=5,
    show_default=True,
    help="Maximum number of recent commits to include.",
)
@click.option("--dir", "out_dir", default=".llmdebug", help="Snapshot directory")
def git_context(snapshot_ref: str | None, as_json: bool, max_commits: int, out_dir: str) -> None:
    """Show on-demand enhanced git context for a snapshot."""
    from .git_context import get_enhanced_git_context

    if snapshot_ref:
        try:
            snapshot = _resolve_snapshot_ref_impl(snapshot_ref, out_dir)
        except (FileNotFoundError, ValueError) as e:
            click.echo(str(e), err=True)
            raise SystemExit(1) from e
    else:
        snapshot = _load_snapshot(None, out_dir)

    context = get_enhanced_git_context(snapshot, out_dir=out_dir, max_commits=max_commits)
    if context is None:
        click.echo(
            "Enhanced git context unavailable (not in a git repository or git is unavailable)."
        )
        return

    if as_json:
        click.echo(json.dumps(context, indent=2, ensure_ascii=False, default=str))
        return

    click.echo(format_enhanced_git_context_text(context))


@main.command()
@click.argument("snapshot_path", required=False)
@click.option("--json", "as_json", is_flag=True, help="Output hypotheses as JSON array")
@click.option("--dir", "out_dir", default=".llmdebug", help="Snapshot directory")
def hypothesize(snapshot_path: str | None, as_json: bool, out_dir: str) -> None:
    """Generate debugging hypotheses from a snapshot."""
    from dataclasses import asdict

    from .hypotheses import format_hypotheses_text, generate_hypotheses

    snapshot = _load_snapshot(snapshot_path, out_dir)
    hypotheses = generate_hypotheses(snapshot)

    if as_json:
        click.echo(json.dumps([asdict(h) for h in hypotheses], indent=2, ensure_ascii=False))
        return

    console = _get_console()

    if not hypotheses:
        msg = "No hypotheses generated — the snapshot may lack sufficient data."
        if console:
            console.print(Text(msg, style="dim"))
        else:
            click.echo(msg)
        return

    if console:
        for i, h in enumerate(hypotheses, 1):
            conf_pct = int(h.confidence * 100)
            # Color by confidence: green >= 85%, yellow >= 70%, dim otherwise
            if h.confidence >= 0.85:
                border_style = "green"
                conf_style = "bold green"
            elif h.confidence >= 0.70:
                border_style = "yellow"
                conf_style = "bold yellow"
            else:
                border_style = "dim"
                conf_style = "dim"

            body = Text()
            body.append(f"{h.description}\n\n", style="white")
            body.append("Evidence:\n", style="bold")
            for e in h.evidence:
                body.append(f"  - {e}\n")
            body.append("\nSuggestion: ", style="bold")
            body.append(h.suggestion)

            title = Text()
            title.append(f"#{i} ", style="bold")
            title.append(f"[{conf_pct}%] ", style=conf_style)
            title.append(h.pattern, style="italic")

            console.print(Panel(body, title=title, border_style=border_style))
    else:
        click.echo(format_hypotheses_text(hypotheses))


def _print_diff_rich(result: Any, console: Any) -> None:
    """Render a SnapshotDiffResult using Rich panels and tables."""
    from .snapshot_diff import _truncate

    # Header panel
    header = Text()
    header.append("old: ", style="dim")
    header.append(result.old_name, style="bold")
    header.append("  new: ", style="dim")
    header.append(result.new_name, style="bold")
    if not result.same_test:
        header.append("  (different test)", style="yellow")
    console.print(Panel(header, title="Snapshot Diff", border_style="cyan"))

    # Exception
    exc = result.exception
    if exc.changed:
        body = Text()
        body.append("CHANGED: ", style="bold yellow")
        body.append(exc.old_type, style="red")
        body.append(" -> ", style="dim")
        body.append(exc.new_type, style="green")
        if exc.old_message != exc.new_message:
            body.append(f"\n  old: {_truncate(exc.old_message)}", style="red")
            body.append(f"\n  new: {_truncate(exc.new_message)}", style="green")
        if exc.old_category != exc.new_category:
            body.append(f"\n  category: {exc.old_category} -> {exc.new_category}", style="dim")
        console.print(Panel(body, title="Exception", border_style="yellow"))
    else:
        body = Text()
        body.append("UNCHANGED: ", style="dim")
        body.append(f"{exc.old_type}: {_truncate(exc.old_message)}")
        console.print(Panel(body, title="Exception", border_style="dim"))

    # Location
    loc = result.location
    if loc.changed:
        body = Text()
        body.append("MOVED: ", style="bold yellow")
        body.append(loc.old_location, style="red")
        body.append(" -> ", style="dim")
        body.append(loc.new_location, style="green")
        console.print(Panel(body, title="Crash Location", border_style="yellow"))
    else:
        console.print(
            Panel(
                Text(f"UNCHANGED: {loc.old_location}", style="dim"),
                title="Crash Location",
                border_style="dim",
            )
        )

    # Locals table
    ld = result.locals_diff
    if ld.changes:
        table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1))
        table.add_column("Status", no_wrap=True)
        table.add_column("Variable", style="cyan", no_wrap=True)
        table.add_column("Old")
        table.add_column("New")

        for c in ld.changes:
            if c.kind == "modified":
                table.add_row(
                    Text("MODIFIED", style="yellow"),
                    c.name,
                    _truncate(c.old_value),
                    _truncate(c.new_value),
                )
            elif c.kind == "added":
                table.add_row(
                    Text("ADDED", style="green"),
                    c.name,
                    "",
                    _truncate(c.new_value),
                )
            elif c.kind == "removed":
                table.add_row(
                    Text("REMOVED", style="red"),
                    c.name,
                    _truncate(c.old_value),
                    "",
                )
            elif c.kind == "type_changed":
                table.add_row(
                    Text("TYPE", style="magenta"),
                    c.name,
                    f"{_truncate(c.old_value)} ({c.old_type})",
                    f"{_truncate(c.new_value)} ({c.new_type})",
                )
            elif c.kind == "shape_changed":
                empty = (
                    " !! EMPTY"
                    if (isinstance(c.new_value, list) and c.new_value and c.new_value[0] == 0)
                    else ""
                )
                table.add_row(
                    Text(f"SHAPE{empty}", style="red" if empty else "yellow"),
                    c.name,
                    str(c.old_value),
                    str(c.new_value),
                )

        console.print(Panel(table, title="Locals (crash frame)", border_style="cyan"))

    if ld.unchanged_count > 0:
        console.print(Text(f"  ({ld.unchanged_count} unchanged)", style="dim"))

    # Git
    if result.git_changed:
        old_commit = result.old_git.get("commit", "?")
        new_commit = result.new_git.get("commit", "?")
        body = Text()
        body.append(f"commit: {old_commit}", style="red")
        body.append(" -> ", style="dim")
        body.append(new_commit, style="green")
        console.print(Panel(body, title="Git", border_style="dim"))
