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

Provides tools for programmatic access to debug snapshots and RCA state:
- 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
- llmdebug_git_context: On-demand enhanced git metadata for crash triage
- llmdebug_diff: Compare two snapshots with structured delta
- llmdebug_hypothesize: Generate ranked root-cause hypotheses
- llmdebug_rca_status / llmdebug_rca_history: RCA state visibility

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

from __future__ import annotations

import json
import logging
import os
import sys
from typing import Any, cast

# 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 ._debug_session import coerce_crash_frame_index
from ._formatting import (
    format_enhanced_git_context_text,
)
from ._presentation import format_frame_text
from ._snapshot_loader import (
    find_all_snapshots,
    load_snapshot,
    load_snapshot_raw,
    resolve_snapshot_ref,
)
from .git_context import get_enhanced_git_context
from .rca_state import (
    advance_rca_attempt,
    build_failure_signature,
    derive_session_id,
    get_rca_history,
    get_rca_status,
)
from .snapshot_diff import diff_snapshots, format_diff_text, structured_diff_summary
from .snapshot_types import FrameDict, SnapshotDict

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

mcp = FastMCP("llmdebug")


# format_enhanced_git_context_text imported from ._formatting as format_enhanced_git_context_text


def _effective_gate_mode(gate_mode: str | None) -> str:
    candidate = (gate_mode or os.getenv("LLMDEBUG_RCA_GATE_MODE", "soft")).strip().lower()
    if candidate in {"off", "soft", "strict"}:
        return candidate
    return "soft"


def _extract_evidence_refs(snapshot: dict[str, Any]) -> list[str]:
    snapshot_t = cast(SnapshotDict, snapshot)
    refs: list[str] = []
    exc = snapshot_t.get("exception", {})
    if isinstance(exc, dict):  # pyright: ignore[reportUnnecessaryIsInstance]
        exc_type = str(exc.get("type") or "")
        exc_msg = str(exc.get("message") or "")
        if exc_type:
            refs.append(f"exception:{exc_type}")
        if exc_msg:
            refs.append(f"exception_message:{exc_msg[:80]}")

    frames = snapshot_t.get("frames", [])
    if isinstance(frames, list) and frames:  # pyright: ignore[reportUnnecessaryIsInstance]
        crash_idx = coerce_crash_frame_index(snapshot)
        frame: Any | None = None
        if 0 <= crash_idx < len(frames) and isinstance(frames[crash_idx], dict):
            frame = cast(FrameDict, frames[crash_idx])
        elif isinstance(frames[0], dict):
            frame = cast(FrameDict, frames[0])
        if frame:
            file_rel = str(frame.get("file_rel", frame.get("file", "?")))
            line = int(frame.get("line") or 0)
            func = str(frame.get("function") or "?")
            refs.append(f"crash_frame:{file_rel}:{line}:{func}")

    pytest_block = snapshot_t.get("pytest", {})
    if isinstance(pytest_block, dict):  # pyright: ignore[reportUnnecessaryIsInstance]
        nodeid = str(pytest_block.get("nodeid") or "")
        if nodeid:
            refs.append(f"pytest_nodeid:{nodeid}")
    return refs


def _advance_rca(
    *,
    out_dir: str,
    snapshot: dict[str, Any] | None,
    action: str,
    gate_mode: str | None,
    evidence_refs: list[str] | None = None,
    hypothesis: str | None = None,
    experiment_plan: str | None = None,
    proposed_change_summary: str | None = None,
    verification_result: dict[str, Any] | None = None,
    exploratory: bool = False,
    override_reason: str | None = None,
    fallback_stdout: str = "",
    fallback_stderr: str = "",
) -> dict[str, Any] | None:
    try:
        session_id = derive_session_id(snapshot, out_dir=out_dir, fallback="mcp")
        signature = build_failure_signature(
            snapshot,
            stdout=fallback_stdout,
            stderr=fallback_stderr,
        )
        return advance_rca_attempt(
            out_dir=out_dir,
            session_id=session_id,
            action=action,
            failure_signature=signature,
            evidence_refs=evidence_refs or [],
            hypothesis=hypothesis,
            experiment_plan=experiment_plan,
            proposed_change_summary=proposed_change_summary,
            verification_result=verification_result,
            gate_mode=_effective_gate_mode(gate_mode),
            exploratory=exploratory,
            override_reason=override_reason,
        )
    except Exception as exc:  # pragma: no cover - defensive path
        logger.warning("RCA state update failed: %s: %s", type(exc).__name__, exc)
        return None


def _advance_rca_with_snapshot_refs(
    *,
    out_dir: str,
    snapshot: dict[str, Any] | None,
    action: str,
    gate_mode: str | None,
    exploratory: bool = False,
    extra_evidence_refs: list[str] | None = None,
    hypothesis: str | None = None,
    experiment_plan: str | None = None,
    proposed_change_summary: str | None = None,
    verification_result: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
    refs = _extract_evidence_refs(snapshot) if isinstance(snapshot, dict) else []
    if extra_evidence_refs:
        refs.extend(extra_evidence_refs)
    return _advance_rca(
        out_dir=out_dir,
        snapshot=snapshot,
        action=action,
        gate_mode=gate_mode,
        evidence_refs=refs,
        hypothesis=hypothesis,
        experiment_plan=experiment_plan,
        proposed_change_summary=proposed_change_summary,
        verification_result=verification_result,
        exploratory=exploratory,
    )


_VALID_RESPONSE_FORMATS = {"text", "json"}
_VALID_EVIDENCE_SCHEMAS = {"summary", "full"}
_RCA_PROMPT_CONTRACT = [
    "Observed failure (cite evidence)",
    "Root-cause hypothesis (falsifiable)",
    "Why evidence supports it",
    "Minimal fix plan",
    "Verification plan",
    "Diff vs previous failed attempt",
]


def _validate_response_format(response_format: str) -> str | None:
    if response_format not in _VALID_RESPONSE_FORMATS:
        return f"Invalid response_format {response_format!r}. Use 'text' or 'json'."
    return None


def _validate_evidence_schema(evidence_schema: str) -> str | None:
    if evidence_schema not in _VALID_EVIDENCE_SCHEMAS:
        return f"Invalid evidence_schema {evidence_schema!r}. Use 'summary' or 'full'."
    return None


def _rca_view(rca_record: dict[str, Any]) -> dict[str, Any]:
    return {
        "attempt_id": rca_record.get("attempt_id"),
        "session_id": rca_record.get("session_id"),
        "state": rca_record.get("state"),
        "failure_signature": rca_record.get("failure_signature"),
        "repeat_count": rca_record.get("repeat_count"),
        "allowed_actions": rca_record.get("allowed_actions", []),
        "next_required": rca_record.get("next_required", []),
        "gate_feedback": rca_record.get("gate_feedback", {}),
        "warnings": rca_record.get("gate_warnings", []),
    }


def _default_evidence_fields() -> dict[str, Any]:
    return {
        "exception": {"type": "", "message": "", "category": "", "suggestion": ""},
        "crash_frame": {"index": -1, "file_rel": "", "line": 0, "function": "", "code": ""},
        "locals_summary": [],
        "repro": {"argv": [], "nodeid": ""},
        "evidence_ids": [],
        "sections": [],
    }


def _render_local_preview(value: Any) -> str:
    # Use a separate binding so the isinstance narrowing doesn't affect `value` below.
    _v: Any = value
    if isinstance(_v, dict) and "__array__" in _v:
        value_d = cast(dict[str, Any], _v)
        shape = value_d.get("shape")
        dtype = value_d.get("dtype")
        arr_type = value_d.get("__array__", "array")
        return f"<{arr_type} shape={shape} dtype={dtype}>"
    try:
        rendered = json.dumps(value, ensure_ascii=False, default=str)
    except Exception:
        rendered = repr(value)
    if len(rendered) > 120:
        rendered = rendered[:117] + "..."
    return rendered


def _locals_summary(frame: dict[str, Any], *, max_items: int = 10) -> list[dict[str, str]]:
    out: list[dict[str, str]] = []
    locals_obj = frame.get("locals")
    locals_meta = frame.get("locals_meta")
    if not isinstance(locals_obj, dict):
        return out
    locals_typed = cast(dict[str, Any], locals_obj)
    _empty: dict[str, Any] = {}
    meta_dict = cast(dict[str, Any], locals_meta) if isinstance(locals_meta, dict) else _empty
    for name, value in list(locals_typed.items())[:max_items]:
        key = str(name)
        if key.startswith("@"):
            continue
        type_name = ""
        meta = meta_dict.get(key)
        if isinstance(meta, dict):
            type_name = str(cast(dict[str, Any], meta).get("type") or "")
        out.append(
            {
                "name": key,
                "type": type_name,
                "preview": _render_local_preview(value),
            }
        )
    return out


def _core_evidence_from_snapshot(snapshot: dict[str, Any] | None) -> dict[str, Any]:
    evidence = _default_evidence_fields()
    if not isinstance(snapshot, dict):
        return evidence

    exc_raw = snapshot.get("exception")
    if isinstance(exc_raw, dict):
        exc = cast(dict[str, Any], exc_raw)
        category = ""
        suggestion = ""
        ecat_raw = exc.get("error_category")
        if isinstance(ecat_raw, dict):
            ecat = cast(dict[str, Any], ecat_raw)
            category = str(ecat.get("category") or "")
            suggestion = str(ecat.get("suggestion") or "")
        evidence["exception"] = {
            "type": str(exc.get("type") or ""),
            "message": str(exc.get("message") or ""),
            "category": category,
            "suggestion": suggestion,
        }

    frames_raw = snapshot.get("frames")
    frames = cast(list[Any], frames_raw) if isinstance(frames_raw, list) else cast(list[Any], [])
    crash_idx = coerce_crash_frame_index(snapshot) if frames else -1
    if 0 <= crash_idx < len(frames) and isinstance(frames[crash_idx], dict):
        frame = cast(dict[str, Any], frames[crash_idx])
        evidence["crash_frame"] = {
            "index": crash_idx,
            "file_rel": str(frame.get("file_rel", frame.get("file", "")) or ""),
            "line": int(frame.get("line") or 0),
            "function": str(frame.get("function") or ""),
            "code": str(frame.get("code") or ""),
        }
        evidence["locals_summary"] = _locals_summary(frame)

    pytest_raw = snapshot.get("pytest")
    if isinstance(pytest_raw, dict):
        pytest_obj = cast(dict[str, Any], pytest_raw)
        argv_raw = pytest_obj.get("repro")
        evidence["repro"] = {
            "argv": list(cast(list[Any], argv_raw)) if isinstance(argv_raw, list) else [],
            "nodeid": str(pytest_obj.get("nodeid") or ""),
        }

    evidence["evidence_ids"] = _extract_evidence_refs(snapshot)
    return evidence


def build_text_response(
    body: str,
    *,
    rca_record: dict[str, Any] | None = None,
    structured_delta: dict[str, Any] | None = None,
    with_rca: bool = True,
) -> str:
    sections = [body]
    if structured_delta is not None:
        structured_text = json.dumps(
            structured_delta,
            indent=2,
            ensure_ascii=False,
        )
        sections.append("## Structured Diff\n" + structured_text)
    if with_rca and rca_record is not None:
        sections.append(
            "## RCA\n" + json.dumps(_rca_view(rca_record), indent=2, ensure_ascii=False)
        )
        sections.append(
            "## RCA Prompt Contract\n"
            + "\n".join(f"{idx}) {line}" for idx, line in enumerate(_RCA_PROMPT_CONTRACT, start=1))
        )
    return "\n\n".join(sections)


def build_json_response(
    *,
    tool: str,
    body: str,
    rca_record: dict[str, Any] | None,
    structured_delta: dict[str, Any] | None,
    evidence_schema: str,
    with_rca: bool,
    evidence_payload: dict[str, Any] | None = None,
    evidence_sections: list[dict[str, Any]] | None = None,
) -> str:
    stable = _default_evidence_fields()
    evidence: dict[str, Any] = dict(stable)
    evidence["summary"] = body

    payload_dict = evidence_payload if isinstance(evidence_payload, dict) else {}
    for key in stable:
        if key in payload_dict:
            evidence[key] = payload_dict[key]
    if evidence_sections is not None:
        evidence["sections"] = evidence_sections
    if evidence_schema == "full":
        if structured_delta is not None:
            evidence["diff"] = structured_delta
        evidence.update({key: value for key, value in payload_dict.items() if key not in stable})

    payload: dict[str, Any] = {
        "tool": tool,
        "format_version": "2.0",
        "evidence": evidence,
        "metadata": {
            "response_format": "json",
            "with_rca": with_rca,
            "evidence_schema": evidence_schema,
        },
    }
    if with_rca and rca_record is not None:
        payload["rca"] = {
            "record": rca_record,
            "view": _rca_view(rca_record),
            "prompt_contract": list(_RCA_PROMPT_CONTRACT),
        }
    return json.dumps(payload, indent=2, ensure_ascii=False, default=str)


def _render_tool_response(
    *,
    tool: str,
    body: str,
    response_format: str,
    evidence_schema: str,
    with_rca: bool,
    rca_record: dict[str, Any] | None = None,
    structured_delta: dict[str, Any] | None = None,
    evidence_payload: dict[str, Any] | None = None,
    evidence_sections: list[dict[str, Any]] | None = None,
) -> str:
    if response_format == "json":
        return build_json_response(
            tool=tool,
            body=body,
            rca_record=rca_record,
            structured_delta=structured_delta,
            evidence_schema=evidence_schema,
            with_rca=with_rca,
            evidence_payload=evidence_payload,
            evidence_sections=evidence_sections,
        )
    return build_text_response(
        body,
        rca_record=rca_record,
        structured_delta=structured_delta,
        with_rca=with_rca,
    )


def _load_snapshot_safe(
    snapshot_path: str | None,
    out_dir: str,
    tool_name: str,
    *,
    raw: bool = False,
) -> dict[str, Any] | str:
    """Load a snapshot, returning an error message string on failure."""
    try:
        loaded = (
            load_snapshot_raw(snapshot_path, out_dir)
            if raw
            else load_snapshot(snapshot_path, out_dir)
        )
        return loaded
    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 %s: %s: %s", tool_name, type(e).__name__, e)
        return f"Error loading snapshot: {type(e).__name__}: {e}"


# ---------------------------------------------------------------------------
# llmdebug_diagnose section builders
# ---------------------------------------------------------------------------


def _diagnose_exception_section(snapshot: dict[str, Any]) -> str:
    _empty: dict[str, Any] = {}
    exc = cast(dict[str, Any], snapshot.get("exception") or _empty)
    exc_type = exc.get("type", "UnknownError")
    exc_msg = exc.get("message", "")
    return f"## Exception\n{exc_type}: {exc_msg}"


def _diagnose_hint_section(snapshot: dict[str, Any]) -> str | None:
    _empty: dict[str, Any] = {}
    exc = cast(dict[str, Any], snapshot.get("exception") or _empty)
    ecat_raw = exc.get("error_category")
    if not ecat_raw:
        return None
    hint_parts: list[str] = []
    ecat = cast(dict[str, Any], ecat_raw) if isinstance(ecat_raw, dict) else _empty
    cat = ecat.get("category", "")
    sug = ecat.get("suggestion", "")
    if cat:
        hint_parts.append(f"Category: {cat}")
    if sug:
        hint_parts.append(f"Suggestion: {sug}")
    return "## Hint\n" + "\n".join(hint_parts) if hint_parts else None


def _diagnose_crash_frame_section(snapshot: dict[str, Any], detail: str) -> str | None:
    frames_raw = snapshot.get("frames")
    frames = cast(list[Any], frames_raw) if isinstance(frames_raw, list) else cast(list[Any], [])
    if not frames:
        return None
    crash_idx = coerce_crash_frame_index(snapshot)
    crash_frame_raw = frames[crash_idx]
    parts: list[str] = []
    if isinstance(crash_frame_raw, dict):
        crash_frame = cast(dict[str, Any], crash_frame_raw)
        parts.append(
            f"## Crash Frame (#{crash_idx})\n"
            + format_frame_text(crash_frame, crash_idx, True, crash_tag=True)
        )
    if detail in ("full", "context") and len(frames) > 1:
        for i, frame_raw in enumerate(frames):
            if i == crash_idx or not isinstance(frame_raw, dict):
                continue
            frame = cast(dict[str, Any], frame_raw)
            parts.append(f"## Frame #{i}\n" + format_frame_text(frame, i, False, crash_tag=True))
    elif len(frames) > 1:
        parts.append(f"({len(frames) - 1} more frame(s) — use llmdebug_frame to inspect)")
    return "\n\n".join(parts) if parts else None


def _diagnose_context_section(snapshot: dict[str, Any], detail: str) -> str | None:
    if detail not in ("full", "context"):
        return None
    _empty: dict[str, Any] = {}
    parts: list[str] = []
    pt = cast(dict[str, Any], snapshot.get("pytest") or _empty)
    repro_raw = pt.get("repro")
    if repro_raw and isinstance(repro_raw, list):
        repro_list = cast(list[Any], repro_raw)
        cmd_parts = [
            "python" if str(r).endswith("/python3") or str(r).endswith("/python") else str(r)
            for r in repro_list
        ]
        parts.append(f"## Repro\n{' '.join(cmd_parts)}")
    git = cast(dict[str, Any], snapshot.get("git") or _empty)
    if git:
        commit = git.get("commit", "")
        branch = git.get("branch", "")
        dirty = " *dirty*" if git.get("dirty") else ""
        if commit:
            parts.append(f"## Git\n{commit} {branch}{dirty}")
    return "\n\n".join(parts) if parts else None


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


@mcp.tool()
def llmdebug_diagnose(
    out_dir: str = ".llmdebug",
    detail: str = "context",
    gate_mode: str = "",
    exploratory: bool = False,
    response_format: str = "json",
    with_rca: bool = False,
    evidence_schema: str = "summary",
) -> 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)
        gate_mode: RCA gate mode override: off|soft|strict (default from env or soft)
        exploratory: Mark this step as exploratory (used by strict gate override logic)
        response_format: "json" (default) or "text"
        with_rca: Include RCA block (default: false)
        evidence_schema: "summary" (default) or "full" for JSON payload detail
    """
    format_error = _validate_response_format(response_format)
    if format_error is not None:
        return format_error
    schema_error = _validate_evidence_schema(evidence_schema)
    if schema_error is not None:
        return schema_error

    result = _load_snapshot_safe(None, out_dir, "llmdebug_diagnose")
    if isinstance(result, str):
        return result
    snapshot = result
    core_evidence = _core_evidence_from_snapshot(snapshot)

    sections: list[str | None] = [
        _diagnose_exception_section(snapshot),
        _diagnose_hint_section(snapshot),
        _diagnose_crash_frame_section(snapshot, detail),
        _diagnose_context_section(snapshot, detail),
    ]
    body = "\n\n".join(s for s in sections if s is not None)
    rca_record = (
        _advance_rca_with_snapshot_refs(
            out_dir=out_dir,
            snapshot=snapshot,
            action="bind_evidence",
            gate_mode=gate_mode,
            exploratory=exploratory,
        )
        if with_rca
        else None
    )
    return _render_tool_response(
        tool="llmdebug_diagnose",
        body=body,
        response_format=response_format,
        evidence_schema=evidence_schema,
        with_rca=with_rca,
        rca_record=rca_record,
        evidence_payload={
            **core_evidence,
            "snapshot": snapshot,
            "detail": detail,
        },
    )


@mcp.tool()
def llmdebug_show(
    out_dir: str = ".llmdebug",
    snapshot_path: str = "",
    detail: str = "context",
    raw_session: bool = False,
    with_rca: bool = False,
    gate_mode: str = "",
    exploratory: bool = False,
    response_format: str = "json",
    evidence_schema: str = "summary",
) -> 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)
        raw_session: Return raw DebugSession envelope when available.
        with_rca: Return JSON envelope with snapshot + RCA block.
        gate_mode: RCA gate mode override: off|soft|strict.
        exploratory: Mark this step as exploratory.
        response_format: "json" (default) or "text".
        evidence_schema: "summary" (default) or "full" for JSON payload detail.
    """
    format_error = _validate_response_format(response_format)
    if format_error is not None:
        return format_error
    schema_error = _validate_evidence_schema(evidence_schema)
    if schema_error is not None:
        return schema_error

    result = _load_snapshot_safe(
        snapshot_path or None,
        out_dir,
        "llmdebug_show",
        raw=raw_session,
    )
    if isinstance(result, str):
        return result
    snapshot = result

    # Apply detail filter
    if not raw_session and detail in ("crash", "full", "context"):
        try:
            from .detail_filter import filter_snapshot

            snapshot = filter_snapshot(snapshot, detail)  # type: ignore[arg-type]
        except Exception as e:  # nosec B110 — graceful filter fallback
            logger.warning(
                "filter_snapshot(%r) failed, returning unfiltered: %s: %s",
                detail,
                type(e).__name__,
                e,
            )

    if response_format == "text" and not with_rca:
        return json.dumps(snapshot, indent=2, ensure_ascii=False, default=str)

    normalized_snapshot = snapshot
    if raw_session:
        try:
            from ._debug_session import normalize_snapshot_view

            normalized_snapshot = normalize_snapshot_view(snapshot)
        except Exception as e:
            logger.warning(
                "normalize_snapshot_view failed, using raw: %s: %s",
                type(e).__name__,
                e,
            )
            normalized_snapshot = snapshot

    rca_record = (
        _advance_rca_with_snapshot_refs(
            out_dir=out_dir,
            snapshot=normalized_snapshot if isinstance(normalized_snapshot, dict) else None,  # pyright: ignore[reportUnnecessaryIsInstance]
            action="bind_evidence",
            gate_mode=gate_mode,
            exploratory=exploratory,
        )
        if with_rca
        else None
    )
    core_evidence = _core_evidence_from_snapshot(
        normalized_snapshot if isinstance(normalized_snapshot, dict) else None  # pyright: ignore[reportUnnecessaryIsInstance]
    )
    payload = {
        "snapshot": snapshot,
        "rca": rca_record,
    }
    if response_format == "text":
        return json.dumps(payload, indent=2, ensure_ascii=False, default=str)

    return _render_tool_response(
        tool="llmdebug_show",
        body="Expanded snapshot payload",
        response_format=response_format,
        evidence_schema=evidence_schema,
        with_rca=with_rca,
        rca_record=rca_record,
        evidence_payload={
            **core_evidence,
            "snapshot": snapshot,
            "normalized_snapshot": normalized_snapshot,
            "raw_session": raw_session,
            "detail": detail,
        },
    )


@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, limit=limit)
    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}."

    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']}  {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 = "",
    gate_mode: str = "",
    exploratory: bool = False,
    response_format: str = "json",
    with_rca: bool = False,
    evidence_schema: str = "summary",
) -> 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.
        gate_mode: RCA gate mode override: off|soft|strict.
        exploratory: Mark this step as exploratory.
        response_format: "json" (default) or "text".
        with_rca: Include RCA block (default: false).
        evidence_schema: "summary" (default) or "full" for JSON payload detail.
    """
    format_error = _validate_response_format(response_format)
    if format_error is not None:
        return format_error
    schema_error = _validate_evidence_schema(evidence_schema)
    if schema_error is not None:
        return schema_error

    result = _load_snapshot_safe(snapshot_path or None, out_dir, "llmdebug_frame")
    if isinstance(result, str):
        return result
    snapshot = result

    frames_raw = snapshot.get("frames")
    frames = cast(list[Any], frames_raw) if isinstance(frames_raw, list) else cast(list[Any], [])
    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)."
        )

    frame_raw = frames[index]
    if not isinstance(frame_raw, dict):
        return f"Frame index {index} is malformed in snapshot."
    frame = cast(dict[str, Any], frame_raw)
    crash_idx = coerce_crash_frame_index(snapshot)
    is_crash = index == crash_idx
    core_evidence = _core_evidence_from_snapshot(snapshot)
    core_evidence["crash_frame"] = {
        "index": index,
        "file_rel": str(frame.get("file_rel", frame.get("file", "")) or ""),
        "line": int(frame.get("line") or 0),
        "function": str(frame.get("function") or ""),
        "code": str(frame.get("code") or ""),
    }
    core_evidence["locals_summary"] = _locals_summary(frame)
    evidence_ids = list(core_evidence.get("evidence_ids") or [])
    evidence_ids.append(f"frame:{index}")
    core_evidence["evidence_ids"] = list(dict.fromkeys(evidence_ids))

    body = format_frame_text(frame, index, is_crash, crash_tag=True)
    rca_record = (
        _advance_rca_with_snapshot_refs(
            out_dir=out_dir,
            snapshot=snapshot,
            action="bind_evidence",
            gate_mode=gate_mode,
            exploratory=exploratory,
        )
        if with_rca
        else None
    )
    return _render_tool_response(
        tool="llmdebug_frame",
        body=body,
        response_format=response_format,
        evidence_schema=evidence_schema,
        with_rca=with_rca,
        rca_record=rca_record,
        evidence_payload={
            **core_evidence,
            "snapshot": snapshot,
            "frame_index": index,
            "frame": frame,
        },
    )


@mcp.tool()
def llmdebug_git_context(
    out_dir: str = ".llmdebug",
    snapshot_path: str = "",
    max_commits: int = 5,
    gate_mode: str = "",
    exploratory: bool = False,
    response_format: str = "json",
    with_rca: bool = False,
    evidence_schema: str = "summary",
) -> str:
    """Show on-demand enhanced git metadata for a snapshot.

    Returns metadata-only context:
    - crash-line blame metadata
    - recent commit metadata
    - crash-file diffstat metadata
    """
    format_error = _validate_response_format(response_format)
    if format_error is not None:
        return format_error
    schema_error = _validate_evidence_schema(evidence_schema)
    if schema_error is not None:
        return schema_error

    result = _load_snapshot_safe(snapshot_path or None, out_dir, "llmdebug_git_context")
    if isinstance(result, str):
        return result
    snapshot = result
    core_evidence = _core_evidence_from_snapshot(snapshot)

    try:
        max_commits_parsed = int(max_commits)
    except (TypeError, ValueError):
        return f"Invalid max_commits value: {max_commits!r}. Expected an integer between 1 and 50."
    max_commits = max(1, min(max_commits_parsed, 50))
    enhanced = get_enhanced_git_context(snapshot, out_dir=out_dir, max_commits=max_commits)
    if enhanced is None:
        body = "Enhanced git context unavailable (not in a git repository or git is unavailable)."
        rca_record = (
            _advance_rca_with_snapshot_refs(
                out_dir=out_dir,
                snapshot=snapshot,
                action="bind_evidence",
                gate_mode=gate_mode,
                exploratory=exploratory,
            )
            if with_rca
            else None
        )
        return _render_tool_response(
            tool="llmdebug_git_context",
            body=body,
            response_format=response_format,
            evidence_schema=evidence_schema,
            with_rca=with_rca,
            rca_record=rca_record,
            evidence_payload={**core_evidence, "snapshot": snapshot},
        )

    evidence_refs: list[str] = []
    head_raw = enhanced.get("head")
    if isinstance(head_raw, dict):
        head = cast(dict[str, Any], head_raw)
        commit = str(head.get("commit") or "").strip()
        branch = str(head.get("branch") or "").strip()
        if commit:
            evidence_refs.append(f"git_head_commit:{commit}")
        if branch:
            evidence_refs.append(f"git_branch:{branch}")

    crash_file_raw = enhanced.get("crash_file")
    if isinstance(crash_file_raw, dict):
        crash_file = cast(dict[str, Any], crash_file_raw)
        file_rel = str(crash_file.get("file_rel") or "").strip()
        if file_rel:
            evidence_refs.append(f"git_crash_file:{file_rel}")
        blame_raw = crash_file.get("blame")
        if isinstance(blame_raw, dict):
            blame = cast(dict[str, Any], blame_raw)
            blame_commit = str(blame.get("commit") or "").strip()
            if blame_commit:
                evidence_refs.append(f"git_blame_commit:{blame_commit}")

    body = format_enhanced_git_context_text(enhanced)
    rca_record = (
        _advance_rca_with_snapshot_refs(
            out_dir=out_dir,
            snapshot=snapshot,
            action="bind_evidence",
            gate_mode=gate_mode,
            extra_evidence_refs=evidence_refs,
            exploratory=exploratory,
        )
        if with_rca
        else None
    )
    return _render_tool_response(
        tool="llmdebug_git_context",
        body=body,
        response_format=response_format,
        evidence_schema=evidence_schema,
        with_rca=with_rca,
        rca_record=rca_record,
        evidence_payload={
            **core_evidence,
            "snapshot": snapshot,
            "enhanced_git_context": enhanced,
        },
    )


@mcp.tool()
def llmdebug_diff(
    old_ref: str = "previous",
    new_ref: str = "latest",
    out_dir: str = ".llmdebug",
    gate_mode: str = "",
    exploratory: bool = False,
    response_format: str = "json",
    with_rca: bool = False,
    evidence_schema: str = "summary",
) -> 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)
        gate_mode: RCA gate mode override: off|soft|strict.
        exploratory: Mark this step as exploratory.
        response_format: "json" (default) or "text".
        with_rca: Include RCA block (default: false).
        evidence_schema: "summary" (default) or "full" for JSON payload detail.
    """
    format_error = _validate_response_format(response_format)
    if format_error is not None:
        return format_error
    schema_error = _validate_evidence_schema(evidence_schema)
    if schema_error is not None:
        return schema_error

    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)
    structured = structured_diff_summary(result)
    verification_status = "fail" if result.failure_signature_same else "inconclusive"
    verification_result = {
        "status": verification_status,
        "checks": ["llmdebug_diff"],
    }
    previous_refs = [f"previous:{ref}" for ref in _extract_evidence_refs(old)]
    core_evidence = _core_evidence_from_snapshot(new)
    evidence_ids = list(core_evidence.get("evidence_ids") or [])
    evidence_ids.extend(previous_refs)
    core_evidence["evidence_ids"] = list(dict.fromkeys(evidence_ids))
    rca_record = (
        _advance_rca_with_snapshot_refs(
            out_dir=out_dir,
            snapshot=new,
            action="verify",
            gate_mode=gate_mode,
            extra_evidence_refs=previous_refs,
            verification_result=verification_result,
            proposed_change_summary=(
                "Compared previous and latest failures; inspected exception/location/locals deltas."
            ),
            exploratory=exploratory,
        )
        if with_rca
        else None
    )
    body = format_diff_text(result)
    return _render_tool_response(
        tool="llmdebug_diff",
        body=body,
        response_format=response_format,
        evidence_schema=evidence_schema,
        with_rca=with_rca,
        rca_record=rca_record,
        structured_delta=structured,
        evidence_payload={
            **core_evidence,
            "old_snapshot": old,
            "new_snapshot": new,
        },
    )


@mcp.tool()
def llmdebug_hypothesize(
    out_dir: str = ".llmdebug",
    snapshot_path: str = "",
    gate_mode: str = "",
    exploratory: bool = False,
    response_format: str = "json",
    with_rca: bool = False,
    evidence_schema: str = "summary",
) -> 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.
        gate_mode: RCA gate mode override: off|soft|strict.
        exploratory: Mark this step as exploratory.
        response_format: "json" (default) or "text".
        with_rca: Include RCA block (default: false).
        evidence_schema: "summary" (default) or "full" for JSON payload detail.
    """
    format_error = _validate_response_format(response_format)
    if format_error is not None:
        return format_error
    schema_error = _validate_evidence_schema(evidence_schema)
    if schema_error is not None:
        return schema_error

    result = _load_snapshot_safe(snapshot_path or None, out_dir, "llmdebug_hypothesize")
    if isinstance(result, str):
        return result
    snapshot = result
    core_evidence = _core_evidence_from_snapshot(snapshot)

    from .hypotheses import format_hypotheses_text, generate_hypotheses

    hypotheses = generate_hypotheses(snapshot)
    top_hypothesis = hypotheses[0].description if hypotheses else ""
    experiment_plan = hypotheses[0].suggestion if hypotheses else ""
    hypothesis_refs = list(hypotheses[0].evidence[:3]) if hypotheses else []
    rca_record = (
        _advance_rca_with_snapshot_refs(
            out_dir=out_dir,
            snapshot=snapshot,
            action="hypothesize",
            gate_mode=gate_mode,
            extra_evidence_refs=hypothesis_refs,
            hypothesis=top_hypothesis,
            experiment_plan=experiment_plan,
            exploratory=exploratory,
        )
        if with_rca
        else None
    )
    body = format_hypotheses_text(hypotheses)
    return _render_tool_response(
        tool="llmdebug_hypothesize",
        body=body,
        response_format=response_format,
        evidence_schema=evidence_schema,
        with_rca=with_rca,
        rca_record=rca_record,
        evidence_payload={
            **core_evidence,
            "snapshot": snapshot,
            "hypotheses": [
                {
                    "confidence": item.confidence,
                    "pattern": item.pattern,
                    "description": item.description,
                    "evidence": list(item.evidence),
                    "suggestion": item.suggestion,
                }
                for item in hypotheses
            ],
        },
    )


@mcp.tool()
def llmdebug_rca_status(
    out_dir: str = ".llmdebug",
    session_id: str = "",
    snapshot_path: str = "",
) -> str:
    """Return latest RCA state for a session (or latest overall if session unspecified)."""
    resolved_session = session_id.strip()
    if not resolved_session and snapshot_path:
        try:
            snap = load_snapshot(snapshot_path, out_dir)
            resolved_session = derive_session_id(snap, out_dir=out_dir, fallback="mcp")
        except Exception as e:
            logger.warning("session ID resolution failed: %s: %s", type(e).__name__, e)
            resolved_session = ""
    elif not resolved_session:
        try:
            snap = load_snapshot(None, out_dir)
            resolved_session = derive_session_id(snap, out_dir=out_dir, fallback="mcp")
        except Exception as e:
            logger.warning("session ID resolution failed: %s: %s", type(e).__name__, e)
            resolved_session = ""

    status = get_rca_status(out_dir=out_dir, session_id=resolved_session or None)
    if status is None:
        if resolved_session:
            return f"No RCA attempts found for session {resolved_session!r} in {out_dir}."
        return f"No RCA attempts found in {out_dir}."
    return json.dumps(status, indent=2, ensure_ascii=False)


@mcp.tool()
def llmdebug_rca_history(
    out_dir: str = ".llmdebug",
    session_id: str = "",
    limit: int = 20,
) -> str:
    """Return RCA history (newest-first) for a session or globally."""
    history = get_rca_history(out_dir=out_dir, session_id=session_id.strip() or None, limit=limit)
    if not history:
        if session_id.strip():
            return f"No RCA attempts found for session {session_id!r} in {out_dir}."
        return f"No RCA attempts found in {out_dir}."
    return json.dumps(history, indent=2, ensure_ascii=False)


@mcp.tool()
def llmdebug_rca_advance(
    action: str,
    out_dir: str = ".llmdebug",
    session_id: str = "",
    snapshot_path: str = "",
    failure_signature: str = "",
    evidence_refs: str = "",
    hypothesis: str = "",
    experiment_plan: str = "",
    proposed_change_summary: str = "",
    verification_status: str = "",
    verification_checks: str = "",
    gate_mode: str = "",
    exploratory: bool = False,
    override_reason: str = "",
) -> str:
    """Manually advance RCA state machine for custom agent workflows."""
    snapshot: dict[str, Any] | None = None
    if snapshot_path:
        try:
            snapshot = load_snapshot(snapshot_path, out_dir)
        except Exception as exc:
            return f"Error loading snapshot: {type(exc).__name__}: {exc}"
    elif not session_id or not failure_signature:
        try:
            snapshot = load_snapshot(None, out_dir)
        except Exception:
            snapshot = None

    resolved_session = session_id.strip()
    if not resolved_session:
        resolved_session = derive_session_id(snapshot, out_dir=out_dir, fallback="manual")
    signature = failure_signature.strip() or build_failure_signature(snapshot)

    refs = [item.strip() for item in evidence_refs.split(",") if item.strip()]
    verification_result: dict[str, Any] | None = None
    if verification_status.strip():
        verification_result = {
            "status": verification_status.strip(),
            "checks": [item.strip() for item in verification_checks.split(",") if item.strip()],
        }

    try:
        record = advance_rca_attempt(
            out_dir=out_dir,
            session_id=resolved_session,
            action=action,
            failure_signature=signature,
            evidence_refs=refs,
            hypothesis=hypothesis.strip() or None,
            experiment_plan=experiment_plan.strip() or None,
            proposed_change_summary=proposed_change_summary.strip() or None,
            verification_result=verification_result,
            gate_mode=_effective_gate_mode(gate_mode),
            exploratory=exploratory,
            override_reason=override_reason.strip() or None,
        )
    except Exception as exc:
        return f"Error updating RCA state: {type(exc).__name__}: {exc}"
    return json.dumps(record, indent=2, ensure_ascii=False)


# ---------------------------------------------------------------------------
# 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()
