"""llmdebug MCP server — expose crash snapshots to IDE agents.

Provides 4 tools for programmatic access to debug snapshots:
- llmdebug_diagnose: Concise crash summary optimized for LLM consumption
- llmdebug_show: Full expanded JSON snapshot
- llmdebug_list: List available snapshots
- llmdebug_frame: Detailed view of a specific stack frame

Run with: llmdebug-mcp (stdio transport for IDE integration)
"""

from __future__ import annotations

import json
import logging
import sys
from typing import Any

# Guard import — mcp is optional
try:
    from mcp.server.fastmcp import FastMCP
except ImportError:
    print(
        "llmdebug MCP server requires: pip install llmdebug[mcp]",
        file=sys.stderr,
    )
    raise SystemExit(1) from None

from ._snapshot_loader import find_all_snapshots, load_snapshot, resolve_snapshot_ref
from .snapshot_diff import diff_snapshots, format_diff_text

logger = logging.getLogger("llmdebug.mcp")

mcp = FastMCP("llmdebug")


# ---------------------------------------------------------------------------
# Text formatting helpers (plain-text, no Rich dependency)
# ---------------------------------------------------------------------------


def _build_source_text(source: dict[str, Any], crash_line: int) -> str:
    """Build a source snippet with arrow on crash line."""
    snippet = source.get("snippet", [])
    lines: list[str] = []
    for entry in snippet:
        lineno = entry.get("lineno", 0)
        code = entry.get("code", "")
        marker = " \u2192 " if lineno == crash_line else "   "
        lines.append(f"{marker}{lineno:>4} | {code}")
    return "\n".join(lines)


def _format_local_value(key: str, value: Any, meta: dict[str, Any] | None) -> str:
    """Format a single local variable as a one-line string."""
    type_str = ""
    if meta:
        type_str = meta.get("type", "")
        if "." in type_str:
            type_str = type_str.rsplit(".", 1)[-1]

    # Array-like values
    if isinstance(value, dict):
        for array_key in ("__array__", "__dataframe__", "__series__"):
            if value.get(array_key):
                dtype = value.get("dtype", "")
                shape = value.get("shape", [])
                extra = f" dtype={dtype}" if dtype else ""
                return f"  {key} = {shape}{extra}  ({array_key.strip('_')})"

    value_str = str(value) if not isinstance(value, str) else value
    if len(value_str) > 120:
        value_str = value_str[:117] + "..."
    type_part = f"  ({type_str})" if type_str else ""
    return f"  {key} = {value_str}{type_part}"


def _format_frame_text(frame: dict[str, Any], index: int, is_crash: bool) -> str:
    """Format a single stack frame as plain text."""
    file_rel = frame.get("file_rel", frame.get("file", "?"))
    line = frame.get("line", "?")
    func = frame.get("function", "?")
    prefix = "[CRASH] " if is_crash else ""

    parts: list[str] = [f"{prefix}Frame #{index}: {file_rel}:{line} in {func}"]

    # Source
    source = frame.get("source", {})
    if source:
        parts.append("")
        parts.append("Source:")
        parts.append(_build_source_text(source, frame.get("line", 0)))

    # Locals
    locals_dict = frame.get("locals", {})
    locals_meta = frame.get("locals_meta", {})
    if locals_dict:
        parts.append("")
        parts.append("Locals:")
        for k, v in locals_dict.items():
            if k.startswith("@py_"):
                continue
            parts.append(_format_local_value(k, v, locals_meta.get(k)))

    return "\n".join(parts)


# ---------------------------------------------------------------------------
# MCP Tools
# ---------------------------------------------------------------------------


@mcp.tool()
def llmdebug_diagnose(out_dir: str = ".llmdebug", detail: str = "context") -> str:
    """Get a concise diagnosis of the latest test failure.

    Returns a structured summary with: exception type/message,
    error category/suggestion, crash frame with source and locals,
    repro command, and git context. Optimized for LLM consumption.

    Args:
        out_dir: Snapshot directory (default: .llmdebug)
        detail: Detail level — "crash" (exception + crash frame only),
                "full" (+ all frames), or "context" (+ repro/git, default)
    """
    try:
        snapshot = load_snapshot(None, out_dir)
    except FileNotFoundError:
        return (
            f"No snapshots found in {out_dir}. "
            "Run your tests with llmdebug installed to capture snapshots."
        )
    except (ValueError, ImportError, json.JSONDecodeError, OSError) as e:
        return f"Error loading snapshot: {type(e).__name__}: {e}"
    except Exception as e:
        logger.error("Unexpected error in llmdebug_diagnose: %s: %s", type(e).__name__, e)
        return f"Error loading snapshot: {type(e).__name__}: {e}"

    sections: list[str] = []

    # Exception (always included)
    exc = snapshot.get("exception", {})
    exc_type = exc.get("type", "UnknownError")
    exc_msg = exc.get("message", "")
    sections.append(f"## Exception\n{exc_type}: {exc_msg}")

    # Hint (error category — always included)
    ecat = exc.get("error_category", {})
    if ecat:
        cat = ecat.get("category", "")
        sug = ecat.get("suggestion", "")
        hint_parts: list[str] = []
        if cat:
            hint_parts.append(f"Category: {cat}")
        if sug:
            hint_parts.append(f"Suggestion: {sug}")
        if hint_parts:
            sections.append("## Hint\n" + "\n".join(hint_parts))

    # Crash frame (always included)
    frames = snapshot.get("frames", [])
    crash_idx = snapshot.get("crash_frame_index", 0)
    if frames:
        crash_frame = frames[crash_idx] if crash_idx < len(frames) else frames[0]
        idx = crash_idx if crash_idx < len(frames) else 0
        sections.append(f"## Crash Frame (#{idx})\n" + _format_frame_text(crash_frame, idx, True))

        # Additional frames only at "full" or "context" level
        if detail in ("full", "context") and len(frames) > 1:
            for i, frame in enumerate(frames):
                if i == idx:
                    continue
                sections.append(
                    f"## Frame #{i}\n" + _format_frame_text(frame, i, False)
                )
        elif len(frames) > 1:
            sections.append(f"({len(frames) - 1} more frame(s) — use llmdebug_frame to inspect)")

    # Context-level sections: repro, git, env
    if detail in ("full", "context"):
        pt = snapshot.get("pytest", {})
        repro = pt.get("repro")
        if repro and isinstance(repro, list):
            cmd_parts = []
            for r in repro:
                if r.endswith("/python3") or r.endswith("/python"):
                    cmd_parts.append("python")
                else:
                    cmd_parts.append(r)
            sections.append(f"## Repro\n{' '.join(cmd_parts)}")

        git = snapshot.get("git", {})
        if git:
            commit = git.get("commit", "")
            branch = git.get("branch", "")
            dirty = " *dirty*" if git.get("dirty") else ""
            if commit:
                sections.append(f"## Git\n{commit} {branch}{dirty}")

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


@mcp.tool()
def llmdebug_show(
    out_dir: str = ".llmdebug",
    snapshot_path: str = "",
    detail: str = "context",
) -> str:
    """Show the debug snapshot as expanded JSON.

    Returns snapshot data with all frames, locals, and metadata.
    Use llmdebug_diagnose first for a concise summary,
    then llmdebug_show if you need the full picture.

    Args:
        out_dir: Snapshot directory (default: .llmdebug)
        snapshot_path: Path to a specific snapshot file. If empty, uses latest.
        detail: Detail level — "crash", "full", or "context" (default, everything)
    """
    try:
        snapshot = load_snapshot(snapshot_path or None, out_dir)
    except FileNotFoundError:
        return (
            f"No snapshots found in {out_dir}. "
            "Run your tests with llmdebug installed to capture snapshots."
        )
    except (ValueError, ImportError, json.JSONDecodeError, OSError) as e:
        return f"Error loading snapshot: {type(e).__name__}: {e}"
    except Exception as e:
        logger.error("Unexpected error in llmdebug_show: %s: %s", type(e).__name__, e)
        return f"Error loading snapshot: {type(e).__name__}: {e}"

    # Apply detail filter
    if detail in ("crash", "full", "context"):
        try:
            from .detail_filter import filter_snapshot
            snapshot = filter_snapshot(snapshot, detail)  # type: ignore[arg-type]
        except Exception:
            pass  # Fall back to unfiltered

    return json.dumps(snapshot, indent=2, ensure_ascii=False, default=str)


@mcp.tool()
def llmdebug_list(out_dir: str = ".llmdebug", limit: int = 10) -> str:
    """List available debug snapshots, newest first.

    Returns snapshot metadata: timestamp, name, exception type,
    message, and file location. Use snapshot_path from the results
    with llmdebug_show to inspect a specific snapshot.

    Args:
        out_dir: Snapshot directory (default: .llmdebug)
        limit: Maximum number of snapshots to return (default: 10)
    """
    try:
        entries = find_all_snapshots(out_dir)
    except OSError as e:
        return f"Error scanning snapshots: {type(e).__name__}: {e}"
    except Exception as e:
        logger.error("Unexpected error in llmdebug_list: %s: %s", type(e).__name__, e)
        return f"Error scanning snapshots: {type(e).__name__}: {e}"

    if not entries:
        return f"No snapshots found in {out_dir}."

    entries = entries[:limit]

    lines: list[str] = [f"Found {len(entries)} snapshot(s) in {out_dir}:\n"]
    for i, entry in enumerate(entries, 1):
        from datetime import datetime, timezone

        ts = datetime.fromtimestamp(entry["mtime"], tz=timezone.utc).strftime(
            "%Y-%m-%d %H:%M:%S"
        )
        loc = f"{entry['file']}:{entry['line']}"
        msg = entry.get("exc_message", "")
        msg_part = f" — {msg}" if msg else ""
        lines.append(
            f"  {i}. [{ts}] {entry['name']}  "
            f"{entry['exc_type']}{msg_part}  ({loc})"
        )
        lines.append(f"     path: {entry['path']}")

    return "\n".join(lines)


@mcp.tool()
def llmdebug_frame(
    index: int = 0,
    out_dir: str = ".llmdebug",
    snapshot_path: str = "",
) -> str:
    """Show a specific stack frame in detail.

    Returns source code, all local variables with types and values,
    and function arguments. Frame 0 is the crash site. Use higher
    indices to inspect callers.

    Args:
        index: Frame index (0 = crash site, higher = callers)
        out_dir: Snapshot directory (default: .llmdebug)
        snapshot_path: Path to a specific snapshot file. If empty, uses latest.
    """
    try:
        snapshot = load_snapshot(snapshot_path or None, out_dir)
    except FileNotFoundError:
        return (
            f"No snapshots found in {out_dir}. "
            "Run your tests with llmdebug installed to capture snapshots."
        )
    except (ValueError, ImportError, json.JSONDecodeError, OSError) as e:
        return f"Error loading snapshot: {type(e).__name__}: {e}"
    except Exception as e:
        logger.error("Unexpected error in llmdebug_frame: %s: %s", type(e).__name__, e)
        return f"Error loading snapshot: {type(e).__name__}: {e}"

    frames = snapshot.get("frames", [])
    if not frames:
        return "No frames in snapshot."

    if index < 0 or index >= len(frames):
        return (
            f"Frame index {index} out of range. "
            f"Available frames: 0-{len(frames) - 1} ({len(frames)} total)."
        )

    crash_idx = snapshot.get("crash_frame_index", 0)
    frame = frames[index]
    is_crash = index == crash_idx

    return _format_frame_text(frame, index, is_crash)


@mcp.tool()
def llmdebug_diff(
    old_ref: str = "previous",
    new_ref: str = "latest",
    out_dir: str = ".llmdebug",
) -> str:
    """Compare two snapshots to show what changed between debugging iterations.

    Returns changes in: exception type/message, crash location,
    local variable values, array shapes, and git context.
    Default: compares the two most recent snapshots.

    Args:
        old_ref: Older snapshot — "latest", "previous", "#N" (list index), or file path.
        new_ref: Newer snapshot — same format as old_ref.
        out_dir: Snapshot directory (default: .llmdebug)
    """
    try:
        old = resolve_snapshot_ref(old_ref, out_dir)
        new = resolve_snapshot_ref(new_ref, out_dir)
    except (FileNotFoundError, ValueError) as e:
        return str(e)
    except (ImportError, json.JSONDecodeError, OSError) as e:
        return f"Error loading snapshots: {type(e).__name__}: {e}"
    except Exception as e:
        logger.error("Unexpected error in llmdebug_diff: %s: %s", type(e).__name__, e)
        return f"Error loading snapshots: {type(e).__name__}: {e}"

    result = diff_snapshots(old, new)
    return format_diff_text(result)


@mcp.tool()
def llmdebug_hypothesize(
    out_dir: str = ".llmdebug",
    snapshot_path: str = "",
) -> str:
    """Generate ranked debugging hypotheses from a snapshot.

    Pattern-matches on exception type, local variable values, array shapes,
    and metadata to produce ranked hypotheses with confidence scores,
    evidence, and suggested next steps.

    Args:
        out_dir: Snapshot directory (default: .llmdebug)
        snapshot_path: Path to a specific snapshot file. If empty, uses latest.
    """
    try:
        snapshot = load_snapshot(snapshot_path or None, out_dir)
    except FileNotFoundError:
        return (
            f"No snapshots found in {out_dir}. "
            "Run your tests with llmdebug installed to capture snapshots."
        )
    except (ValueError, ImportError, json.JSONDecodeError, OSError) as e:
        return f"Error loading snapshot: {type(e).__name__}: {e}"
    except Exception as e:
        logger.error("Unexpected error in llmdebug_hypothesize: %s: %s", type(e).__name__, e)
        return f"Error loading snapshot: {type(e).__name__}: {e}"

    from .hypotheses import format_hypotheses_text, generate_hypotheses

    hypotheses = generate_hypotheses(snapshot)
    return format_hypotheses_text(hypotheses)


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------


def main() -> None:
    """Run the llmdebug MCP server."""
    logging.basicConfig(
        stream=sys.stderr,
        level=logging.WARNING,
        format="%(name)s: %(message)s",
    )
    mcp.run(transport="stdio")


if __name__ == "__main__":
    main()
