"""
MCP JSON-RPC Protocol Chain Tests for Accordance Port.

Tests the actual MCP wire protocol (JSON-RPC over stdio) by starting the
Accordance MCP server as a subprocess, sending properly formatted JSON-RPC
requests, and validating responses. Exercises chaining multiple tool calls
in sequence to simulate an AI agent using the server.
"""

from __future__ import annotations

import json
import os
import subprocess
import sys
import threading
from pathlib import Path

import pytest

# ---------------------------------------------------------------------------
# Check fastmcp availability
# ---------------------------------------------------------------------------
try:
    from mcp.server.fastmcp import FastMCP  # noqa: F401
    _FASTMCP_AVAILABLE = True
except ImportError:
    _FASTMCP_AVAILABLE = False

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
SERVER_SCRIPT = Path(__file__).parent / "accordance_mcp_server.py"
PROJECT_ROOT = Path(__file__).parent

# ---------------------------------------------------------------------------
# MCPClient
# ---------------------------------------------------------------------------

class MCPClient:
    """Manages the MCP server subprocess and JSON-RPC communication."""

    def __init__(self):
        self.process = None
        self._next_id = 0
        self._stderr_thread = None

    def start(self, timeout=180):
        """Start server subprocess and perform MCP initialization handshake."""
        env = os.environ.copy()
        env["PYTHONUNBUFFERED"] = "1"

        self.process = subprocess.Popen(
            [sys.executable, str(SERVER_SCRIPT)],
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            bufsize=1,
            cwd=str(PROJECT_ROOT),
            env=env,
        )
        self._stderr_thread = threading.Thread(
            target=self._drain_stderr, daemon=True
        )
        self._stderr_thread.start()

        # MCP initialize handshake
        self._send({"jsonrpc": "2.0", "id": 0, "method": "initialize",
                     "params": {"protocolVersion": "2024-11-05",
                                "capabilities": {},
                                "clientInfo": {"name": "test-client",
                                               "version": "1.0.0"}}})
        response = self._receive(timeout=timeout)
        assert response is not None, "Server did not respond to initialize"
        assert "result" in response, f"Initialize failed: {response}"

        # Send initialized notification (MCP spec requires this after initialize)
        self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})

    def _drain_stderr(self):
        """Read and discard stderr to prevent buffer from blocking process."""
        try:
            while self.process and self.process.poll() is None:
                line = self.process.stderr.readline()
                if not line:
                    break
        except (ValueError, OSError):
            pass

    def _send(self, message):
        """Send a JSON-RPC message to the server."""
        line = json.dumps(message) + "\n"
        try:
            self.process.stdin.write(line)
            self.process.stdin.flush()
        except (BrokenPipeError, OSError) as e:
            raise RuntimeError(f"Failed to send to server: {e}") from e

    def _receive(self, timeout=120):
        """Read next JSON-RPC response from stdout, skipping notifications.

        The server writes startup logs to stdout (print statements, index
        progress) which can appear on the same line as JSON-RPC responses.
        We extract the JSON object by finding the ``{"jsonrpc":"..."`` pattern.
        """
        import re as _re
        _json_start = _re.compile(r'\{"jsonrpc"\s*:\s*"2\.0"')
        deadline = __import__("time").time() + timeout
        while __import__("time").time() < deadline:
            line = self.process.stdout.readline()
            if not line:
                return None
            # Find JSON-RPC object embedded in possibly-mixed output
            m = _json_start.search(line)
            if m:
                # Try to parse from the match start
                candidate = line[m.start():].strip()
                try:
                    msg = json.loads(candidate)
                except json.JSONDecodeError:
                    continue
                if "result" in msg or "error" in msg:
                    return msg
                continue
            # Also try full-line JSON for clean responses
            stripped = line.strip()
            if not stripped:
                continue
            try:
                msg = json.loads(stripped)
            except json.JSONDecodeError:
                continue
            if "result" in msg or "error" in msg:
                return msg
        return None

    def _rpc_call(self, method, params=None, timeout=30):
        """Send a JSON-RPC request and return the response."""
        request = {"jsonrpc": "2.0", "id": self._next_id,
                    "method": method, "params": params or {}}
        self._next_id += 1
        self._send(request)
        return self._receive(timeout=timeout)

    def call_tool(self, tool_name, arguments=None, timeout=30):
        """Call an MCP tool via tools/call and return the parsed response."""
        return self._rpc_call("tools/call", {
            "name": tool_name,
            "arguments": arguments or {},
        }, timeout=timeout)

    def list_tools(self, timeout=30):
        """List all available MCP tools via tools/list."""
        return self._rpc_call("tools/list", {}, timeout=timeout)

    def stop(self):
        """Terminate the server subprocess."""
        if self.process:
            try:
                self.process.terminate()
                self.process.wait(timeout=5)
            except (subprocess.TimeoutExpired, ProcessLookupError):
                try:
                    self.process.kill()
                    self.process.wait(timeout=3)
                except (subprocess.TimeoutExpired, ProcessLookupError):
                    pass

    @property
    def is_alive(self):
        """Check if the server process is still running."""
        return self.process is not None and self.process.poll() is None


def _extract_text(result):
    """Extract text content from an MCP tools/call result dict."""
    if isinstance(result, dict) and "content" in result:
        for item in result["content"]:
            if isinstance(item, dict) and item.get("type") == "text":
                return item.get("text", "")
    # Fallback for tools/list or unexpected formats
    return json.dumps(result) if result else ""


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest.fixture(scope="module")
def mcp_client():
    """Module-scoped fixture: start server once, reuse across all tests."""
    if not _FASTMCP_AVAILABLE:
        pytest.skip("FastMCP not installed")
    if not SERVER_SCRIPT.exists():
        pytest.skip(f"Server script not found: {SERVER_SCRIPT}")

    client = MCPClient()
    try:
        client.start(timeout=180)
    except Exception as e:
        client.stop()
        pytest.skip(f"Server failed to start: {e}")

    yield client
    client.stop()


# ===========================================================================
# 1. Server startup and initialization
# ===========================================================================

def test_01_server_initialize(mcp_client):
    """Start the server, verify it's alive after initialize handshake."""
    assert mcp_client.is_alive, "Server process should be running"


# ===========================================================================
# 2. List all tools — verify count and key Catholic tools
# ===========================================================================

def test_02_list_tools(mcp_client):
    """tools/list — verify 51 tools and key Catholic tools present."""
    r = mcp_client.list_tools()
    assert "result" in r, f"tools/list failed: {r}"
    tools = r["result"].get("tools", [])
    names = {t["name"] for t in tools}
    assert len(tools) == 51, f"Expected 51 tools, got {len(tools)}"
    for name in ["get_nabre_verse", "daily_mass_readings",
                  "compare_catholic_translations"]:
        assert name in names, f"Missing Catholic tool: {name}"


# ===========================================================================
# 3. Basic verse lookup
# ===========================================================================

def test_03_verse_lookup(mcp_client):
    """tools/call get_verse_text ESVS John 3:16 — verify verse text."""
    r = mcp_client.call_tool("get_verse_text",
                              {"module_name": "ESVS", "reference": "John 3:16"})
    assert "result" in r, f"No result in response: {r}"
    text = _extract_text(r["result"])
    assert len(text) > 10, f"Short/empty text: {text[:80]}"


# ===========================================================================
# 4. NABRE Catholic translation
# ===========================================================================

def test_04_nabre_verse(mcp_client):
    """tools/call get_nabre_verse John 6:53 — verify text returned."""
    r = mcp_client.call_tool("get_nabre_verse",
                              {"reference": "John 6:53"})
    assert "result" in r, f"No result: {r}"
    text = _extract_text(r["result"])
    assert len(text) > 10, f"No NABRE text: {text[:80]}"


# ===========================================================================
# 5. Daily Mass readings
# ===========================================================================

def test_05_daily_mass_readings(mcp_client):
    """tools/call daily_mass_readings 2026-04-05 — verify Easter readings."""
    r = mcp_client.call_tool("daily_mass_readings",
                              {"date": "2026-04-05"})
    assert "result" in r, f"daily_mass_readings failed: {r}"
    text = _extract_text(r["result"])
    assert "Easter" in text or "Reading" in text or "Gospel" in text, text[:200]


# ===========================================================================
# 6. Strong's resolution chain (2 sequential calls)
# ===========================================================================

def test_06_strongs_chain(mcp_client):
    """get_strongs_for_verse → lookup_strongs_definition on first Strong's."""
    r1 = mcp_client.call_tool("get_strongs_for_verse",
                               {"reference": "Genesis 1:1"})
    assert "result" in r1, f"get_strongs_for_verse failed: {r1}"
    text1 = _extract_text(r1["result"])
    # Extract first Strong's number: [Hxxxx]
    import re
    m = re.search(r"\[(H\d+)\]", text1)
    assert m is not None, f"No Strong's number found in: {text1[:120]}"
    strongs = m.group(1)
    r2 = mcp_client.call_tool("lookup_strongs_definition",
                               {"strongs_number": strongs})
    assert "result" in r2, f"lookup_strongs_definition failed: {r2}"
    text2 = _extract_text(r2["result"])
    assert len(text2) > 5, f"No definition for {strongs}: {text2[:80]}"


# ===========================================================================
# 7. Commentary search chain (2 sequential calls)
# ===========================================================================

def test_07_commentary_chain(mcp_client):
    """search_commentaries John 3:16 → get_commentary_text JFB."""
    r1 = mcp_client.call_tool("search_commentaries",
                               {"reference": "John 3:16"})
    assert "result" in r1, f"search_commentaries failed: {r1}"
    text1 = _extract_text(r1["result"])
    # If JFB is present, chain to get full text
    if "JFB" in text1:
        r2 = mcp_client.call_tool("get_commentary_text",
                                   {"module": "JFB", "reference": "John 3:16"})
        assert "result" in r2, f"get_commentary_text failed: {r2}"
        text2 = _extract_text(r2["result"])
        assert len(text2) > 5, f"No JFB text: {text2[:80]}"


# ===========================================================================
# 8. Interlinear display
# ===========================================================================

def test_08_interlinear_display(mcp_client):
    """tools/call interlinear_display Genesis 1:1 — verify word-level output."""
    r = mcp_client.call_tool("interlinear_display",
                              {"reference": "Genesis 1:1"})
    assert "result" in r, f"interlinear_display failed: {r}"
    text = _extract_text(r["result"])
    assert "Word" in text and "Strong" in text, \
        f"No table headers in interlinear output: {text[:200]}"


# ===========================================================================
# 9. Catholic canon enumeration
# ===========================================================================

def test_09_catholic_canon(mcp_client):
    """tools/call get_book_list catholic — verify 73 books."""
    r = mcp_client.call_tool("get_book_list", {"canon": "catholic"})
    assert "result" in r, f"get_book_list failed: {r}"
    text = _extract_text(r["result"])
    assert "73" in text or "Tobit" in text or "Judith" in text, \
        f"No Catholic canon indicators: {text[:200]}"


# ===========================================================================
# 10. Error recovery — invalid reference should not crash server
# ===========================================================================

def test_10_error_recovery(mcp_client):
    """Invalid reference returns structured error; server stays alive."""
    r1 = mcp_client.call_tool("get_verse_text",
                               {"module_name": "KJV", "reference": "Zzzzz 99:99"})
    # Must get a response (not a crash)
    assert r1 is not None, "Server crashed on invalid reference (no response)"
    # Server must still be alive and handle another call
    r2 = mcp_client.call_tool("list_translations", {})
    assert "result" in r2, f"Server not healthy after error: {r2}"
    text2 = _extract_text(r2["result"])
    assert len(text2) > 0, "list_translations returned empty after error"


# ===========================================================================
# 11. Cross-reference chain (2 sequential calls)
# ===========================================================================

def test_11_crossref_chain(mcp_client):
    """find_cross_references John 3:16 → get_verse_text on first reference."""
    r1 = mcp_client.call_tool("find_cross_references",
                               {"reference": "John 3:16"})
    assert "result" in r1, f"find_cross_references failed: {r1}"
    text1 = _extract_text(r1["result"])
    # Extract first verse reference: e.g., "Numbers 21:9"
    import re
    m = re.search(r"- ([\w ]+ \d+:\d+)", text1)
    if m is not None:
        ref = m.group(1)
        r2 = mcp_client.call_tool("get_verse_text",
                                   {"module_name": "KJV", "reference": ref})
        assert "result" in r2, f"Chained get_verse_text failed: {r2}"
        text2 = _extract_text(r2["result"])
        assert len(text2) > 5, f"No text for {ref}: {text2[:80]}"


# ===========================================================================
# 12. Spanish translation (LBLA)
# ===========================================================================

def test_12_spanish_translation(mcp_client):
    """compare_translations LBLA John 3:16 — verify Spanish rendering."""
    r = mcp_client.call_tool("compare_translations",
                              {"reference": "John 3:16",
                               "translations": "LBLA"})
    assert "result" in r, f"compare_translations failed: {r}"
    text = _extract_text(r["result"])
    assert len(text) > 10, f"No LBLA text: {text[:120]}"


if __name__ == "__main__":
    sys.exit(pytest.main([__file__, "-v", "--tb=short"]))
