//! `wai mcp` (catalogue `mcp.start`): WAI's operations as tools for a coding assistant, served
//! over the Model Context Protocol on standard input and output, one JSON-RPC message per line.
//! Standard output carries the protocol alone; anything else goes to standard error.
//!
//! It speaks both eras of the protocol, chosen by how each request opens:
//!
//! - **2026-07-28**, stateless: there is no handshake. Every request names its revision and the
//!   client's capabilities in `_meta` (`io.modelcontextprotocol/protocolVersion` and
//!   `io.modelcontextprotocol/clientCapabilities`, both required, or the request is refused
//!   `-32602`), and is served from what it carries alone. A revision this server does not serve
//!   per request is refused `-32022` (`UnsupportedProtocolVersion`) with the revisions it does in
//!   `data.supported`, never the one asked for, and the revisions `initialize` negotiates in
//!   `data.initialize`: a client that retries with one of `supported`, as that error asks, is
//!   served, and one of the earlier era opens with `initialize` instead.
//!   `server/discover` states them, the server's capabilities and its instructions. Every result
//!   carries `resultType` and the server's identity in its `_meta`; `server/discover` and
//!   `tools/list` carry `ttlMs` and `cacheScope`. `ping` is not a method of this revision.
//! - **2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05**, negotiated by `initialize`, as
//!   before: a request without the revision in its `_meta` is served under them. 2025-11-25, the
//!   last revision with a handshake, changed nothing these tools use: their names keep its
//!   naming rule, their input schemas are JSON Schema 2020-12, its default dialect, a tool's own
//!   error (bad input among them) is a result with `isError`, as it asks, and the server declares
//!   none of the capabilities it added (tasks), so a client of it is answered in it rather than
//!   moved down to 2025-06-18.
//!
//! A tool is an operation of the toolchain's catalogue, named by it: the operation's name with
//! `_` for the dot (`receipt.verify` is `receipt_verify`), never a name of its own. The tools
//! (`tools.rs`, one table: an operation is one line there) come in toolsets, and a server offers
//! the toolsets it is started with:
//!
//! - `core` (the default): inspect envelopes and packages, read the capability registry, verify
//!   receipts, claims, live-room sessions and audit bundles, package a media file, list and run
//!   conformance corpora, search the specification's text;
//! - `dev`: a development room on loopback (`dev_room.rs`): start and stop it, mint grants for
//!   test principals, join and publish as them, and read the session, each on the target
//!   `local` alone;
//! - `app` (a build with `app`; `app.rs`): Create's guide, a program checked, streamed into the
//!   person's open Create page through a bridge on 127.0.0.1, and exported as a project.
//!
//! Each answers with a structured result. Nothing leaves the machine, and the only tool that
//! takes a service's address refuses every one that is not on this machine.
//!
//! ```text
//! wai mcp [--toolset <set>[,<set>...]] [--read-only] [--project <dir>] [--spec-root <wai-dir>] [--room-bin <path>]
//! ```
//!
//! `--toolset` names the toolsets to offer (`core` when none is named; repeated or
//! comma-separated). `--spec-root` (or `WAI_SPEC_ROOT`) is the `wai/` directory whose
//! specification text and conformance corpora the tools read; by default the one this binary
//! was built from, when it is still there. `--room-bin` (or `WAI_ROOM_BIN`) is the room service
//! program the development room runs (`wai-room`); by default the one beside this binary,
//! then on `PATH`. `--project` is the directory the server may write in (the packaging tool's
//! envelopes), by default the one it starts in; it writes nowhere else. `--read-only` offers only
//! the tools that read or run a verifier: nothing that writes a file, starts a process that
//! serves, or asks a service for a grant.

use serde_json::{json, Value};
use std::io::{BufRead, Write};
use std::path::PathBuf;

#[cfg(feature = "app")]
#[path = "app.rs"]
mod app;
#[path = "dev_room.rs"]
mod dev_room;
#[path = "tools.rs"]
mod tools;

/// The stateless revisions this server speaks, newest first: each request names one in its
/// `_meta` ([`META_VERSION`]).
const MODERN: &[&str] = &["2026-07-28"];
/// The revisions an `initialize` request negotiates, newest first: one it does not know is
/// answered in the first.
const LEGACY: &[&str] = &["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];

/// The `_meta` keys of a stateless request and result (MCP 2026-07-28, *General fields*).
const META_VERSION: &str = "io.modelcontextprotocol/protocolVersion";
const META_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities";
const META_SERVER: &str = "io.modelcontextprotocol/serverInfo";
/// `UnsupportedProtocolVersion` (MCP 2026-07-28, *Error codes*).
const UNSUPPORTED_VERSION: i64 = -32022;
/// How long a client may hold `server/discover`'s and `tools/list`'s answers: what they state is
/// fixed for the process's life, by the options it was started with.
const TTL_MS: u64 = 3_600_000;

/// Every revision this server speaks, newest first: the stateless ones, then those `initialize`
/// negotiates.
fn supported() -> Vec<&'static str> {
    MODERN.iter().chain(LEGACY).copied().collect()
}

/// The server's name and version, as `initialize` and every stateless result give them.
fn server_info() -> Value {
    json!({"name": "wai", "title": "WAI", "version": env!("CARGO_PKG_VERSION")})
}

/// What the server tells an assistant when it connects, a sentence for each toolset it offers.
/// It names no tool: the tools' table is the one place their names are written.
fn instructions(toolsets: &[tools::Toolset]) -> String {
    let mut s = String::from("WAI's operations as tools, on this machine; each tool is an operation of the WAI toolchain, named by it.");
    if toolsets.contains(&tools::Toolset::Core) {
        s.push_str(" Inspect a .wai envelope or a package directory; list and show the registered capabilities and their licence \
classes; verify a delivery receipt, a claim, a live-room session or an audit bundle (each answer is the verifier's verdict and its \
reason code: quote it rather than judging the input yourself); package a media file into a WAI envelope; list and run conformance \
corpora through their independent verifiers; check a project, write its lock and build it; and search the specification's text, which \
is the authority on every rule.");
    }
    if toolsets.contains(&tools::Toolset::Dev) {
        s.push_str(" The development tools, each marked DEVELOPMENT ONLY, start a relay and a room service on loopback with keys \
held in memory, mint grants for test principals, join and publish as them, and read the room's session for verification; they act \
on the target local alone and refuse any service that is not on this machine.");
    }
    if toolsets.contains(&tools::Toolset::App) {
        s.push_str(" Create's tools give the line language and the WAI widgets a WAI app is written in (read app_guide first), \
check a program, stream it into the person's open Create page through a bridge on 127.0.0.1 that the page reads with a pairing \
code, and export it as a project.");
    }
    s.push_str(" No tool sends anything off the machine.");
    s
}

/// The server's state across calls: where it reads the specification, the room service it
/// runs, the development room while one runs, and the name the client gave.
pub(crate) struct Server {
    pub(crate) toolsets: Vec<tools::Toolset>,
    pub(crate) read_only: bool,
    pub(crate) project: PathBuf,
    pub(crate) spec_root: Option<PathBuf>,
    pub(crate) spec_root_from: &'static str,
    pub(crate) room_bin: Option<PathBuf>,
    pub(crate) dev: Option<dev_room::Dev>,
    pub(crate) principals: std::collections::BTreeMap<String, dev_room::Principal>,
    pub(crate) client: Option<String>,
    /// The bridge Create pages read programs from, while the `app` toolset is offered and the
    /// server is not read-only.
    #[cfg(feature = "app")]
    pub(crate) bridge: Option<wai_app::bridge::Bridge>,
}

impl Server {
    fn new(toolsets: Vec<tools::Toolset>, spec_root: Option<PathBuf>, room_bin: Option<PathBuf>, project: PathBuf, read_only: bool) -> Server {
        let (spec_root, spec_root_from) = match spec_root {
            Some(p) => (Some(p), "--spec-root"),
            None => match std::env::var_os("WAI_SPEC_ROOT") {
                Some(p) => (Some(PathBuf::from(p)), "WAI_SPEC_ROOT"),
                None => {
                    let built = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
                    if built.join("SPEC.md").is_file() { (Some(built), "the tree this binary was built from") } else { (None, "none") }
                }
            },
        };
        let room_bin = room_bin.or_else(|| std::env::var_os("WAI_ROOM_BIN").map(PathBuf::from));
        Server {
            toolsets,
            read_only,
            project,
            spec_root,
            spec_root_from,
            room_bin,
            dev: None,
            principals: Default::default(),
            client: None,
            #[cfg(feature = "app")]
            bridge: None,
        }
    }

    /// `p` (relative to the project directory) as a path to write, when it is inside the
    /// project directory, its parent directory exists, and it is not a link.
    pub(crate) fn inside_project(&self, p: &std::path::Path) -> Result<PathBuf, String> {
        let abs = if p.is_absolute() { p.to_path_buf() } else { self.project.join(p) };
        let name = abs.file_name().ok_or_else(|| format!("{}: not a file name", p.display()))?;
        let parent = abs.parent().unwrap_or(&self.project).canonicalize().map_err(|e| format!("{}: {e}", p.display()))?;
        if !parent.starts_with(&self.project) {
            return Err(format!("{} is outside the project directory {}; this server writes only inside it", p.display(), self.project.display()));
        }
        let target = parent.join(name);
        if std::fs::symlink_metadata(&target).is_ok_and(|m| m.file_type().is_symlink()) {
            return Err(format!("{} is a link; this server writes only plain files inside the project directory", target.display()));
        }
        Ok(target)
    }

    /// The specification's root, or why there is none.
    pub(crate) fn spec_root(&self) -> Result<PathBuf, String> {
        let root = self.spec_root.as_ref().ok_or(
            "no specification tree: start the server with --spec-root <the repository's wai/ directory>, or set WAI_SPEC_ROOT",
        )?;
        if !root.join("SPEC.md").is_file() {
            return Err(format!("{} (from {}) holds no SPEC.md: it is not a wai/ directory", root.display(), self.spec_root_from));
        }
        root.canonicalize().map_err(|e| format!("{}: {e}", root.display()))
    }

    /// One JSON-RPC message's answer; `None` for a notification.
    pub(crate) fn handle(&mut self, msg: &Value) -> Option<Value> {
        if msg.is_array() {
            return Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "a batch is not taken (MCP 2025-06-18 and 2026-07-28): send one message at a time"}}));
        }
        // A response or a notification from the client (no id): nothing to answer.
        let id = msg.get("id").cloned()?;
        let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
        let params = msg.get("params").cloned().unwrap_or(json!({}));
        if method.is_empty() {
            return None;
        }
        let ok = |result: Value| Some(json!({"jsonrpc": "2.0", "id": id, "result": result}));
        let err = |code: i64, message: String, data: Option<Value>| {
            let mut e = json!({"code": code, "message": message});
            if let Some(d) = data {
                e["data"] = d;
            }
            Some(json!({"jsonrpc": "2.0", "id": id, "error": e}))
        };
        // `initialize` opens a legacy session; a request whose `_meta` names a revision is a
        // stateless one; any other is served under the legacy revisions, as before.
        let meta = params.get("_meta");
        if method != "initialize"
            && let Some(asked) = meta.and_then(|m| m.get(META_VERSION))
        {
            return match self.stateless(method, &params, asked) {
                Ok(mut result) => {
                    result["resultType"] = json!("complete");
                    let mut m = serde_json::Map::new();
                    m.insert(META_SERVER.into(), server_info());
                    result["_meta"] = Value::Object(m);
                    ok(result)
                }
                Err((code, message, data)) => err(code, message, data),
            };
        }
        match method {
            "initialize" => {
                self.client = params.pointer("/clientInfo/name").and_then(Value::as_str).map(String::from);
                let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(LEGACY[0]);
                let version = LEGACY.iter().find(|v| **v == asked).copied().unwrap_or(LEGACY[0]);
                ok(json!({
                    "protocolVersion": version,
                    "capabilities": {"tools": {"listChanged": false}},
                    "serverInfo": server_info(),
                    "instructions": instructions(&self.toolsets),
                }))
            }
            "ping" => ok(json!({})),
            "tools/list" => ok(json!({"tools": tools::list(self)})),
            "tools/call" => match self.call(&params) {
                Ok(r) => ok(r),
                Err(m) => err(-32602, m, None),
            },
            "server/discover" => err(-32602, format!("server/discover is a request of MCP 2026-07-28: name the revision in its _meta ({META_VERSION}), or open with initialize for {}", LEGACY.join(", ")), None),
            other => err(-32601, format!("no method {other}"), None),
        }
    }

    /// A request of a stateless revision (MCP 2026-07-28), served from what it carries alone: its
    /// result without `resultType` and `_meta`, which [`Server::handle`] adds, or its error's
    /// code, message and data.
    fn stateless(&mut self, method: &str, params: &Value, asked: &Value) -> Result<Value, (i64, String, Option<Value>)> {
        let Some(version) = asked.as_str() else {
            return Err((-32602, format!("_meta: {META_VERSION} is a string"), None));
        };
        if !MODERN.contains(&version) {
            let why = if LEGACY.contains(&version) {
                format!("unsupported protocol version: {version} is negotiated by initialize ({}), not named in a request's _meta ({})", LEGACY.join(", "), MODERN.join(", "))
            } else {
                format!("unsupported protocol version {version}: a request names {}, or initialize negotiates {}", MODERN.join(", "), LEGACY.join(", "))
            };
            // `supported` is what a request may name, which the one asked for is not, so a client
            // that retries with one of them is served; the earlier era's go apart, by name.
            return Err((UNSUPPORTED_VERSION, why, Some(json!({"supported": MODERN, "requested": version, "initialize": LEGACY}))));
        }
        if !params.pointer("/_meta").and_then(|m| m.get(META_CAPABILITIES)).is_some_and(Value::is_object) {
            return Err((-32602, format!("_meta: {META_CAPABILITIES} (an object) is required on every request of MCP {version}"), None));
        }
        match method {
            "server/discover" => Ok(json!({
                "supportedVersions": supported(),
                "capabilities": {"tools": {}},
                "instructions": instructions(&self.toolsets),
                "ttlMs": TTL_MS,
                "cacheScope": "public",
            })),
            "tools/list" => Ok(json!({"tools": tools::list(self), "ttlMs": TTL_MS, "cacheScope": "public"})),
            "tools/call" => self.call(params).map_err(|m| (-32602, m, None)),
            "ping" => Err((-32601, format!("no method ping in MCP {version}, which removed it"), None)),
            other => Err((-32601, format!("no method {other}"), None)),
        }
    }

    /// `tools/call`'s result, the same in every revision: the tool's structured result and its
    /// text, or its error as text with `isError`. `Err` for a call that names no tool offered or
    /// whose arguments are not an object.
    fn call(&mut self, params: &Value) -> Result<Value, String> {
        let name = params.get("name").and_then(Value::as_str).unwrap_or("");
        let args = params.get("arguments").cloned().unwrap_or(json!({}));
        if !args.is_object() {
            return Err("arguments: an object".into());
        }
        match tools::call(self, name, &args) {
            None => Err(format!("no tool {name}; tools/list names them")),
            Some(Ok(v)) => {
                let text = serde_json::to_string_pretty(&v).unwrap_or_default();
                Ok(json!({"content": [{"type": "text", "text": text}], "structuredContent": v, "isError": false}))
            }
            Some(Err(e)) => Ok(json!({"content": [{"type": "text", "text": e}], "isError": true})),
        }
    }
}

impl Drop for Server {
    fn drop(&mut self) {
        if let Some(d) = self.dev.take() {
            d.stop();
        }
    }
}

fn usage() -> String {
    format!(
        "usage: wai mcp [--toolset <set>[,<set>...]] [--read-only] [--project <dir>] [--spec-root <wai-dir>] [--room-bin <path>]\n\
         toolsets: {} (core when none is named)",
        tools::Toolset::ALL.iter().map(|t| t.name()).collect::<Vec<_>>().join(", ")
    )
}

/// What `wai mcp` is started with.
struct Options {
    toolsets: Vec<tools::Toolset>,
    spec_root: Option<PathBuf>,
    room_bin: Option<PathBuf>,
    project: PathBuf,
    read_only: bool,
}

fn options(args: &[String]) -> Result<Options, String> {
    let mut toolsets: Vec<tools::Toolset> = Vec::new();
    let mut spec_root = None;
    let mut room_bin = None;
    let mut project = None;
    let mut read_only = false;
    let mut i = 0;
    while i < args.len() {
        match (args[i].as_str(), args.get(i + 1)) {
            ("--read-only", _) => {
                read_only = true;
                i += 1;
                continue;
            }
            ("--toolset", Some(v)) => {
                for name in v.split(',').map(str::trim).filter(|n| !n.is_empty()) {
                    let t = tools::Toolset::named(name).ok_or_else(|| format!("--toolset {name}: no such toolset\n{}", usage()))?;
                    if !toolsets.contains(&t) {
                        toolsets.push(t);
                    }
                }
            }
            ("--spec-root", Some(v)) => spec_root = Some(PathBuf::from(v)),
            ("--room-bin", Some(v)) => room_bin = Some(PathBuf::from(v)),
            ("--project", Some(v)) => project = Some(PathBuf::from(v)),
            _ => return Err(usage()),
        }
        i += 2;
    }
    if toolsets.is_empty() {
        toolsets.push(tools::Toolset::Core);
    }
    let project = project.map_or_else(std::env::current_dir, Ok).and_then(|p| p.canonicalize()).map_err(|e| format!("the project directory: {e}"))?;
    Ok(Options { toolsets, spec_root, room_bin, project, read_only })
}

/// One tool that only reads, run once for the command line's door to the same operation: one
/// implementation behind both doors. `spec_root` is what `--spec-root` is to the server, and the
/// specification's text is found the same way without it.
pub(crate) fn run_once(op: &str, args: &Value, spec_root: Option<PathBuf>) -> Result<Value, String> {
    let project = std::env::current_dir().map_err(|e| format!("the current directory: {e}"))?;
    let mut server = Server::new(vec![tools::Toolset::Core], spec_root, None, project, true);
    tools::call(&mut server, &op.replace('.', "_"), args).unwrap_or_else(|| Err(format!("{op}: wai mcp offers no such tool to read with")))
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The operations with a door here and a tool there take the catalogue's input in both: the
    /// tool's arguments are the catalogue's, less what the command line alone takes (the server has
    /// its own `--spec-root`).
    #[test]
    fn an_operation_of_both_doors_takes_the_catalogues_input_in_both() {
        for id in ["docs.search", "registry.show", "toolchain.doctor"] {
            let o = wai::catalogue::op(id).expect("a catalogue operation");
            assert!(o.mcp && o.cli.is_some(), "{id} has both doors");
            let t = tools::TOOLS.iter().find(|t| t.op == id).expect("a tool");
            let schema = (t.schema)();
            let mut have: Vec<&str> = schema["properties"].as_object().unwrap().keys().map(String::as_str).collect();
            let mut want: Vec<&str> = o.input.iter().map(|a| a.name).filter(|n| *n != "spec_root").collect();
            have.sort();
            want.sort();
            assert_eq!(have, want, "{id}: the tool's arguments are the catalogue's input");
            let mut req: Vec<&str> = schema["required"].as_array().unwrap().iter().map(|r| r.as_str().unwrap()).collect();
            let mut want_req: Vec<&str> = o.input.iter().filter(|a| a.required).map(|a| a.name).collect();
            req.sort();
            want_req.sort();
            assert_eq!(req, want_req, "{id}: the required arguments");
        }
    }
}

/// `wai mcp --dry-run`: the tools it would serve, and nothing served (no bridge started).
pub fn plan(args: &[String]) -> Result<bool, String> {
    let o = options(args)?;
    let sets: Vec<&str> = o.toolsets.iter().map(|t| t.name()).collect();
    let server = Server::new(o.toolsets.clone(), o.spec_root, o.room_bin, o.project.clone(), o.read_only);
    let names: Vec<String> = tools::offered(&server).map(|t| t.name()).collect();
    if crate::dx::envelope::ctx().json {
        let data = json!({"toolsets": sets, "read_only": o.read_only, "project": o.project.display().to_string(), "tools": names});
        crate::dx::envelope::print(&crate::dx::envelope::envelope_with("mcp.start", true, data, Some(Vec::new())));
    } else {
        println!("wai mcp --dry-run: nothing served. On standard input and output it would serve the toolsets {}{}, {} tools:",
                 sets.join(", "), if o.read_only { " read-only" } else { "" }, names.len());
        println!("  {}", names.join(", "));
        println!("  writing only inside {}", o.project.display());
    }
    Ok(true)
}

/// `wai mcp`: serve until standard input closes.
pub fn main(args: &[String]) -> Result<bool, String> {
    let o = options(args)?;
    #[cfg_attr(not(feature = "app"), allow(unused_mut))]
    let mut server = Server::new(o.toolsets, o.spec_root, o.room_bin, o.project, o.read_only);
    #[cfg(feature = "app")]
    if server.toolsets.contains(&tools::Toolset::App) && !server.read_only {
        let b = wai_app::bridge::Bridge::start(0).map_err(|e| format!("the bridge for Create pages: {e}"))?;
        eprintln!("wai mcp: the bridge for Create pages is http://127.0.0.1:{}, pairing code {}", b.port, b.code);
        server.bridge = Some(b);
    }
    let stdin = std::io::stdin();
    let mut out = std::io::stdout().lock();
    for line in stdin.lock().lines() {
        let Ok(line) = line else { break };
        if line.trim().is_empty() {
            continue;
        }
        let reply = match serde_json::from_str::<Value>(&line) {
            Ok(msg) => server.handle(&msg),
            Err(e) => Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": format!("not JSON: {e}")}})),
        };
        if let Some(r) = reply
            && writeln!(out, "{r}").and_then(|_| out.flush()).is_err()
        {
            break;
        }
    }
    Ok(true)
}
