"""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
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast

from ._debug_session import coerce_crash_frame_index
from ._formatting import (
    build_source_text,
    format_enhanced_git_context_text,
    format_local_value,
)
from ._presentation import format_frame_text
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

HAS_RICH: bool = False  # uppercase triggers pyright's Final inference; ignore redefinition below
try:
    from rich.console import Console
    from rich.panel import Panel
    from rich.table import Table
    from rich.text import Text

    HAS_RICH = True  # pyright: ignore[reportConstantRedefinition]
except ImportError:
    pass

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


def _get_console() -> Any | None:
    """Return a Rich Console if available, else None."""
    if HAS_RICH and _stdout_is_tty():
        return Console(stderr=False, no_color=(os.getenv("NO_COLOR") is not None))
    return None


def _stdout_is_tty() -> bool:
    """Return True when stdout is attached to an interactive terminal."""
    stream = click.get_text_stream("stdout")
    isatty = getattr(stream, "isatty", None)
    if not callable(isatty):
        return False
    try:
        return bool(isatty())
    except Exception:
        return False


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]:  # pyright: ignore[reportUnusedFunction] — used in tests
    """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]]:  # pyright: ignore[reportUnusedFunction] — used in tests
    """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 []
    frames_list = cast(list[Any], frames)
    return [
        (i, cast(dict[str, Any], frame))
        for i, frame in enumerate(frames_list)
        if isinstance(frame, dict)
    ]


def _format_timestamp(ts_str: str | None) -> str:  # pyright: ignore[reportUnusedFunction] — used in tests
    """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


def _truncate_text(value: Any, max_len: int) -> str:
    """Truncate text to `max_len` using ASCII ellipsis."""
    text = str(value)
    if max_len <= 0:
        return ""
    if len(text) <= max_len:
        return text
    if max_len <= 3:
        return text[:max_len]
    return text[: max_len - 3] + "..."


def _truncate_tail(value: Any, max_len: int) -> str:
    """Truncate long paths/locations from the left to preserve the tail."""
    text = str(value)
    if max_len <= 0:
        return ""
    if len(text) <= max_len:
        return text
    if max_len <= 3:
        return text[-max_len:]
    return "..." + text[-(max_len - 3) :]


# ---------------------------------------------------------------------------
# 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"))
    else:
        click.echo(f">>> Exception: {exc_type}: {exc_msg}")


def _print_frame(
    frame: dict[str, Any],
    index: int,
    is_crash: bool,
    console: Any,
    *,
    show_locals: bool = True,
) -> None:
    """Print a single stack frame."""
    if not console:
        rendered = format_frame_text(
            frame, index, is_crash, show_locals=show_locals, crash_tag=False
        )
        lines = rendered.splitlines()
        if not lines:
            return
        prefix = ">>> " if is_crash else "    "
        click.echo(f"{prefix}{lines[0]}")
        for line in lines[1:]:
            click.echo(f"    {line}")
        return

    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:
            ld = cast(dict[str, Any], locals_dict)
            lm = cast(dict[str, Any], locals_meta)
            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 ld.items():
                # Skip pytest internal variables
                if k.startswith("@py_"):
                    continue
                name, type_str, val_str = format_local_value(k, v, lm.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))


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:
        ld = cast(dict[str, Any], locals_dict)
        lm = cast(dict[str, Any], locals_meta)
        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 ld.items():
            if k.startswith("@py_"):
                continue
            name, type_str, val_str = format_local_value(k, v, lm.get(k))
            # Highlight empty array shapes in red
            array_keys = ("__array__", "__dataframe__", "__series__")
            if (
                HAS_RICH
                and isinstance(v, dict)
                and any(cast(dict[str, Any], v).get(ak) for ak in array_keys)
            ):
                shape = cast(dict[str, Any], v).get("shape", cast(list[Any], []))
                if shape and cast(list[Any], 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: list[str] = []
        for r in cast(list[Any], repro):
            r_str = str(r)
            if r_str.endswith("/python3") or r_str.endswith("/python"):
                cmd_parts.append("python")
            else:
                cmd_parts.append(r_str)
        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: list[Any] = (
                cast(list[Any], all_frames_obj)
                if isinstance(all_frames_obj, list)
                else cast(list[Any], [])
            )
            if len(all_frames) > 1:
                remaining = len(all_frames) - 1
                plural = "s" if remaining != 1 else ""
                hint = f"  ({remaining} more frame{plural}; --detail full)"
                if _stdout_is_tty() and console:
                    console.print(Text(hint, style="dim"))
                elif _stdout_is_tty():
                    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_impl(out_dir, limit=limit)

    if not entries:
        click.echo(f"No snapshots found in {out_dir}")
        if _stdout_is_tty():
            click.echo("Run a failing test to create a snapshot, or pass --dir to another folder.")
        return

    console = _get_console()
    name_width = 20
    exception_width = 18
    location_width = 28

    if console:
        table = Table(title=f"Snapshots ({len(entries)} shown, UTC)", show_lines=False)
        table.add_column("#", style="dim", width=3)
        table.add_column("Timestamp", style="cyan", no_wrap=True)
        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")
            name = _truncate_text(entry["name"], name_width)
            exc_type = _truncate_text(entry["exc_type"], exception_width)
            loc = _truncate_tail(f"{entry['file']}:{entry['line']}", location_width)
            table.add_row(
                str(i),
                ts,
                name,
                exc_type,
                loc,
            )
        console.print(table)
    else:
        click.echo(f"Snapshots ({len(entries)} shown, UTC):")
        for i, entry in enumerate(entries, 1):
            ts = datetime.fromtimestamp(entry["mtime"], tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
            name = _truncate_text(entry["name"], name_width)
            exc_type = _truncate_text(entry["exc_type"], exception_width)
            loc = _truncate_tail(f"{entry['file']}:{entry['line']}", location_width)
            click.echo(f"  {i}. [{ts}] {name}  {exc_type}  {loc}")


def _compute_delete_list(
    out_path: Path,
    snapshot_files: list[Path],
    keep: int,
    delete_all: bool,
) -> list[Path]:
    """Compute the list of files to delete without performing any I/O side effects."""
    to_delete: list[Path] = []
    if delete_all:
        to_delete = list(snapshot_files)
        for name in ("latest.json", "latest.toon", ".llmdebug.lock"):
            extra = out_path / name
            if extra.exists():
                to_delete.append(extra)
        to_delete.extend(
            p
            for p in out_path.iterdir()
            if p.is_file() and p.suffix == ".txt" and ".traceback" in p.name
        )
    else:
        for snap in snapshot_files[keep:] if keep < len(snapshot_files) else []:
            to_delete.append(snap)
            tb_file = snap.with_suffix("").with_suffix(".traceback.txt")
            if tb_file.exists():
                to_delete.append(tb_file)
    return to_delete


def _perform_deletions(to_delete: list[Path]) -> tuple[int, int]:
    """Delete files and return (deleted_count, failed_count)."""
    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)
    return deleted, failed


@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}", err=True)
        return

    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,
    )

    to_delete = _compute_delete_list(out_path, snapshot_files, keep, delete_all)
    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, failed = _perform_deletions(to_delete)
    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: list[Any] = (
        cast(list[Any], frame_list_obj) if isinstance(frame_list_obj, list) else cast(list[Any], [])
    )
    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_raw = frame_list[index]
        if not isinstance(frame_raw, dict):
            click.echo(f"Frame index {index} is malformed in snapshot.", err=True)
            raise SystemExit(1)
        frame_d = cast(dict[str, Any], frame_raw)
        if console:
            _print_frame_rich(frame_d, index, index == crash_idx, console)
        else:
            _print_frame(frame_d, index, index == crash_idx, console)
    else:
        for i, frame_item in enumerate(frame_list):
            if not isinstance(frame_item, dict):
                continue
            frame_d = cast(dict[str, Any], frame_item)
            if console:
                _print_frame_rich(frame_d, i, i == crash_idx, console)
                console.print()
            else:
                _print_frame(frame_d, 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))


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

    # 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":
                new_val: Any = c.new_value
                new_val_list = (
                    cast(list[Any], new_val) if isinstance(new_val, list) else cast(list[Any], [])
                )
                empty = (
                    " !! EMPTY"
                    if (isinstance(new_val, list) and new_val_list and new_val_list[0] == 0)
                    else ""
                )
                table.add_row(
                    Text(f"SHAPE{empty}", style="red" if empty else "yellow"),
                    c.name,
                    str(c.old_value),
                    str(cast(Any, new_val)),
                )

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