//! `hephaes mcp` — an MCP server over stdio (plan §4).
//!
//! We hand-roll the small MCP wire protocol (JSON-RPC 2.0 over newline-delimited
//! stdio) rather than depend on `rmcp`: the published `rmcp` (1.x) could not be
//! verified against its documented API in this build environment, so per the
//! plan's sanctioned fallback we implement the protocol directly. The tools call
//! the same `engine` functions, so migrating to `rmcp` later is contained.
//!
//! Read-only by design: `ask`, `list_pages`, `read_page`, `overview`, `status`.
//! `--allow-write` is reserved for future mutating tools.

use super::{base_overrides, repo_root};
use crate::cli::{GlobalArgs, McpArgs};
use crate::engine::{self, AskOptions, EngineCtx};
use anyhow::Result;
use hephaes_core::progress::NoopProgress;
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};

const PROTOCOL: &str = "2024-11-05";

pub async fn run(global: &GlobalArgs, args: McpArgs) -> Result<()> {
    let root = repo_root(args.path.clone())?;
    let ov = base_overrides(global);
    let ctx = EngineCtx::load(&root, &ov)?;
    serve(ctx, args.allow_write).await
}

/// stdio loop: one JSON-RPC message per line in, one response per request out.
async fn serve(ctx: EngineCtx, allow_write: bool) -> Result<()> {
    let _ = allow_write; // reserved for mutating tools (--allow-write)
    let mut lines = BufReader::new(tokio::io::stdin()).lines();
    let mut stdout = tokio::io::stdout();
    while let Some(line) = lines.next_line().await? {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let req: Value = match serde_json::from_str(line) {
            Ok(v) => v,
            Err(_) => continue, // ignore unparseable lines
        };
        if let Some(resp) = dispatch(&ctx, &req, allow_write).await {
            let mut s = serde_json::to_string(&resp).unwrap_or_default();
            s.push('\n');
            stdout.write_all(s.as_bytes()).await?;
            stdout.flush().await?;
        }
    }
    Ok(())
}

/// Returns `None` for notifications (no `id`), else a JSON-RPC response.
async fn dispatch(ctx: &EngineCtx, req: &Value, allow_write: bool) -> Option<Value> {
    let id = req.get("id").cloned()?; // notifications carry no id → no response
    let method = req.get("method").and_then(|m| m.as_str()).unwrap_or("");
    let resp = match handle(ctx, method, req.get("params"), allow_write).await {
        Ok(result) => json!({"jsonrpc": "2.0", "id": id, "result": result}),
        Err((code, message)) => {
            json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})
        }
    };
    Some(resp)
}

async fn handle(
    ctx: &EngineCtx,
    method: &str,
    params: Option<&Value>,
    _allow_write: bool,
) -> Result<Value, (i64, String)> {
    match method {
        "initialize" => {
            let pv = params
                .and_then(|p| p.get("protocolVersion"))
                .and_then(|v| v.as_str())
                .unwrap_or(PROTOCOL)
                .to_string();
            Ok(json!({
                "protocolVersion": pv,
                "capabilities": { "tools": { "listChanged": false } },
                "serverInfo": { "name": "hephaes", "version": env!("CARGO_PKG_VERSION") }
            }))
        }
        "ping" => Ok(json!({})),
        "tools/list" => Ok(json!({ "tools": tool_specs() })),
        "tools/call" => call_tool(ctx, params).await,
        other => Err((-32601, format!("method not found: {other}"))),
    }
}

async fn call_tool(ctx: &EngineCtx, params: Option<&Value>) -> Result<Value, (i64, String)> {
    let p = params.ok_or((-32602, "missing params".to_string()))?;
    let name = p
        .get("name")
        .and_then(|n| n.as_str())
        .ok_or((-32602, "missing tool name".to_string()))?;
    let args = p.get("arguments").cloned().unwrap_or_else(|| json!({}));

    let result = match name {
        "ask" => {
            let q = args.get("question").and_then(|v| v.as_str()).unwrap_or("");
            let from_source = args
                .get("from_source")
                .and_then(|v| v.as_bool())
                .unwrap_or(false);
            let opts = AskOptions {
                from_source,
                save: false,
            };
            match engine::ask(ctx, q, &opts, &NoopProgress).await {
                Ok(r) => text_result(&r.answer),
                Err(e) => err_result(&format!("{e:#}")),
            }
        }
        "list_pages" => {
            let txt = engine::list_pages(ctx)
                .iter()
                .map(|pg| format!("{} — {} [{}]", pg.rel, pg.title, pg.kind))
                .collect::<Vec<_>>()
                .join("\n");
            text_result(&txt)
        }
        "read_page" => {
            let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
            match engine::read_page(ctx, path) {
                Ok(c) => text_result(&c),
                Err(e) => err_result(&format!("{e:#}")),
            }
        }
        "overview" => match engine::read_overview(ctx) {
            Ok(c) => text_result(&c),
            Err(e) => err_result(&format!("{e:#}")),
        },
        "status" => text_result(&engine::status_text(ctx)),
        other => return Err((-32602, format!("unknown tool: {other}"))),
    };
    Ok(result)
}

fn tool_specs() -> Value {
    json!([
        {
            "name": "ask",
            "description": "Ask a question against the wiki; returns a cited markdown answer.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "question": { "type": "string" },
                    "from_source": { "type": "boolean", "description": "Allow reading source files if the wiki is insufficient" }
                },
                "required": ["question"]
            }
        },
        {
            "name": "list_pages",
            "description": "List wiki pages (path, title, kind).",
            "inputSchema": { "type": "object", "properties": {} }
        },
        {
            "name": "read_page",
            "description": "Read a wiki page by path relative to the wiki dir.",
            "inputSchema": {
                "type": "object",
                "properties": { "path": { "type": "string" } },
                "required": ["path"]
            }
        },
        {
            "name": "overview",
            "description": "Read the overview ('start here') page.",
            "inputSchema": { "type": "object", "properties": {} }
        },
        {
            "name": "status",
            "description": "Provider/wiki/drift status summary.",
            "inputSchema": { "type": "object", "properties": {} }
        }
    ])
}

fn text_result(s: &str) -> Value {
    json!({ "content": [{ "type": "text", "text": s }], "isError": false })
}

fn err_result(s: &str) -> Value {
    json!({ "content": [{ "type": "text", "text": s }], "isError": true })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::{Config, PerCommandModels, ProviderId};
    use std::path::PathBuf;

    fn cfg() -> Config {
        Config {
            provider: ProviderId::Anthropic,
            model: Some("mock".to_string()),
            models: PerCommandModels::default(),
            wiki_dir: "wiki".to_string(),
            ollama_url: "http://localhost:11434".to_string(),
            concurrency: 1,
            include: vec![],
            exclude: vec![],
            max_file_bytes: 1 << 20,
        }
    }

    fn fixture(name: &str) -> (PathBuf, EngineCtx) {
        let dir = std::env::temp_dir().join(format!("hephaes-mcp-{name}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(dir.join("wiki")).unwrap();
        std::fs::write(
            dir.join("wiki/index.md"),
            "# Index\n\n- overview.md — Overview\n",
        )
        .unwrap();
        std::fs::write(dir.join("wiki/overview.md"), "# Overview\nThe project.\n").unwrap();
        let ctx = EngineCtx::new(dir.clone(), cfg());
        (dir, ctx)
    }

    #[tokio::test]
    async fn initialize_returns_server_info() {
        let (dir, ctx) = fixture("init");
        let req = json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}});
        let resp = dispatch(&ctx, &req, false).await.unwrap();
        assert_eq!(resp["result"]["serverInfo"]["name"], "hephaes");
        assert_eq!(resp["result"]["protocolVersion"], "2024-11-05");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn tools_list_has_expected_tools() {
        let (dir, ctx) = fixture("list");
        let req = json!({"jsonrpc":"2.0","id":2,"method":"tools/list"});
        let resp = dispatch(&ctx, &req, false).await.unwrap();
        let names: Vec<String> = resp["result"]["tools"]
            .as_array()
            .unwrap()
            .iter()
            .map(|t| t["name"].as_str().unwrap().to_string())
            .collect();
        for n in ["ask", "list_pages", "read_page", "overview", "status"] {
            assert!(names.iter().any(|x| x == n), "missing tool {n}");
        }
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn status_tool_returns_text() {
        let (dir, ctx) = fixture("status");
        let req = json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"status","arguments":{}}});
        let resp = dispatch(&ctx, &req, false).await.unwrap();
        assert_eq!(resp["result"]["isError"], false);
        assert!(resp["result"]["content"][0]["text"]
            .as_str()
            .unwrap()
            .contains("repository:"));
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn read_page_escape_is_tool_error() {
        let (dir, ctx) = fixture("read");
        let req = json!({"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"read_page","arguments":{"path":"../escape.md"}}});
        let resp = dispatch(&ctx, &req, false).await.unwrap();
        assert_eq!(resp["result"]["isError"], true);
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn notification_has_no_response() {
        let (dir, ctx) = fixture("notif");
        let req = json!({"jsonrpc":"2.0","method":"notifications/initialized"});
        assert!(dispatch(&ctx, &req, false).await.is_none());
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn unknown_method_is_jsonrpc_error() {
        let (dir, ctx) = fixture("unk");
        let req = json!({"jsonrpc":"2.0","id":5,"method":"bogus"});
        let resp = dispatch(&ctx, &req, false).await.unwrap();
        assert_eq!(resp["error"]["code"], -32601);
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn unknown_tool_is_jsonrpc_error() {
        let (dir, ctx) = fixture("unktool");
        let req = json!({"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"nope","arguments":{}}});
        let resp = dispatch(&ctx, &req, false).await.unwrap();
        assert_eq!(resp["error"]["code"], -32602);
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn ask_tool_with_no_question_is_tool_error() {
        // No question → engine errors (no key / empty question) before any
        // network; the tool surfaces it as isError, not a transport error.
        let (dir, ctx) = fixture("askerr");
        let req = json!({"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"ask","arguments":{}}});
        let resp = dispatch(&ctx, &req, false).await.unwrap();
        assert_eq!(resp["result"]["isError"], true);
        let _ = std::fs::remove_dir_all(&dir);
    }
}
