//! Minimal Model Context Protocol server exposing Lumotia's local SQLite store.
//!
//! Scope: **read-only** tools. An external agent (Claude desktop, Cline, any
//! MCP-capable client) can list / search / fetch transcripts and list tasks.
//! No writes — Lumotia's Tauri app remains the only writer.
//!
//! Transport: newline-delimited JSON-RPC 2.0 over stdio, per the stdio
//! transport spec. Server spec version: 2024-11-05.

use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sqlx::SqlitePool;

pub const PROTOCOL_VERSION: &str = "2024-11-05";
pub const SERVER_NAME: &str = "lumotia-mcp";
pub const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");

#[derive(Debug, Deserialize)]
pub struct JsonRpcRequest {
    #[serde(default, rename = "jsonrpc")]
    pub jsonrpc: Option<String>,
    pub id: Option<Value>,
    pub method: String,
    #[serde(default)]
    pub params: Value,
}

#[derive(Debug, Serialize)]
pub struct JsonRpcResponse {
    pub jsonrpc: &'static str,
    pub id: Value,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub result: Option<Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<JsonRpcError>,
}

#[derive(Debug, Serialize)]
pub struct JsonRpcError {
    pub code: i32,
    pub message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub data: Option<Value>,
}

/// Dispatch a single JSON-RPC message. Returns `None` when the message is a
/// notification (no `id`) — MCP clients send `notifications/initialized`
/// after the initialize handshake, which we ignore.
pub async fn handle_message(pool: &SqlitePool, raw: Value) -> Option<JsonRpcResponse> {
    let request: JsonRpcRequest = match serde_json::from_value(raw) {
        Ok(req) => req,
        Err(err) => {
            return Some(error_response(
                Value::Null,
                -32700,
                format!("Parse error: {err}"),
            ));
        }
    };

    // Notifications: no id, no response.
    let id = request.id.clone()?;

    let outcome = match request.method.as_str() {
        "initialize" => Ok(initialize_result()),
        "tools/list" => Ok(tools_list_result()),
        "tools/call" => call_tool(pool, request.params).await,
        // Clients sometimes ping — respond trivially rather than erroring.
        "ping" => Ok(json!({})),
        other => Err(error(-32601, format!("Method not found: {other}"))),
    };

    Some(match outcome {
        Ok(result) => JsonRpcResponse {
            jsonrpc: "2.0",
            id,
            result: Some(result),
            error: None,
        },
        Err(err) => JsonRpcResponse {
            jsonrpc: "2.0",
            id,
            result: None,
            error: Some(err),
        },
    })
}

fn initialize_result() -> Value {
    json!({
        "protocolVersion": PROTOCOL_VERSION,
        "capabilities": { "tools": {} },
        "serverInfo": {
            "name": SERVER_NAME,
            "version": SERVER_VERSION,
        },
        "instructions":
            "Read-only access to Lumotia's local transcript history and task list. \
             All data stays on the user's machine.",
    })
}

fn tools_list_result() -> Value {
    json!({
        "tools": [
            {
                "name": "list_transcripts",
                "description": "List recent transcripts from Lumotia's local history, most recent first. \
                                Returns summaries (id, title, created_at, duration, preview).",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "limit": {
                            "type": "integer",
                            "description": "Max transcripts to return (1–200, default 20).",
                            "minimum": 1,
                            "maximum": 200,
                        },
                    },
                },
            },
            {
                "name": "get_transcript",
                "description": "Fetch the full text and metadata of a single transcript by id.",
                "inputSchema": {
                    "type": "object",
                    "required": ["id"],
                    "properties": {
                        "id": {
                            "type": "string",
                            "description": "Transcript id (UUID) from list_transcripts / search_transcripts.",
                        },
                    },
                },
            },
            {
                "name": "search_transcripts",
                "description": "Full-text search across Lumotia's transcripts. Returns matching summaries.",
                "inputSchema": {
                    "type": "object",
                    "required": ["query"],
                    "properties": {
                        "query": {
                            "type": "string",
                            "description": "Search query (FTS5 syntax supported).",
                        },
                        "limit": {
                            "type": "integer",
                            "description": "Max matches to return (1–100, default 20).",
                            "minimum": 1,
                            "maximum": 100,
                        },
                    },
                },
            },
            {
                "name": "list_tasks",
                "description": "List tasks from Lumotia's task store. Returns both open and completed.",
                "inputSchema": {
                    "type": "object",
                    "properties": {},
                },
            },
        ],
    })
}

async fn call_tool(pool: &SqlitePool, params: Value) -> Result<Value, JsonRpcError> {
    #[derive(Deserialize)]
    struct CallParams {
        name: String,
        #[serde(default)]
        arguments: Value,
    }

    let call: CallParams = serde_json::from_value(params)
        .map_err(|e| error(-32602, format!("Invalid params: {e}")))?;

    match call.name.as_str() {
        "list_transcripts" => list_transcripts_tool(pool, call.arguments).await,
        "get_transcript" => get_transcript_tool(pool, call.arguments).await,
        "search_transcripts" => search_transcripts_tool(pool, call.arguments).await,
        "list_tasks" => list_tasks_tool(pool).await,
        other => Err(error(-32602, format!("Unknown tool: {other}"))),
    }
}

async fn list_transcripts_tool(pool: &SqlitePool, args: Value) -> Result<Value, JsonRpcError> {
    #[derive(Deserialize, Default)]
    struct Args {
        #[serde(default)]
        limit: Option<i64>,
    }
    // The `arguments` field in CallParams defaults to `Value::Null`
    // when a client omits it entirely. `serde_json::from_value` does
    // not accept Null as an empty object, so we short-circuit that
    // case before deserialising — a missing `arguments` still falls
    // back to defaults (the common case for list_transcripts), while
    // a genuinely malformed payload returns -32602 per the Invalid
    // arguments contract the other handlers use.
    let args: Args = if args.is_null() {
        Args::default()
    } else {
        serde_json::from_value(args)
            .map_err(|e| error(-32602, format!("Invalid arguments: {e}")))?
    };
    let limit = args.limit.unwrap_or(20).clamp(1, 200);

    let rows = lumotia_storage::list_transcripts(pool, limit)
        .await
        .map_err(|e| error(-32603, format!("DB error: {e}")))?;

    let summaries: Vec<Value> = rows
        .into_iter()
        .map(|r| {
            json!({
                "id": r.id,
                "title": r.title,
                "createdAt": r.created_at,
                "source": r.source,
                "duration": r.duration,
                "starred": r.starred,
                "language": r.language,
                "preview": preview(&r.text, 240),
            })
        })
        .collect();

    Ok(text_content(
        serde_json::to_string_pretty(&summaries).unwrap(),
    ))
}

async fn get_transcript_tool(pool: &SqlitePool, args: Value) -> Result<Value, JsonRpcError> {
    #[derive(Deserialize)]
    struct Args {
        id: String,
    }
    let args: Args = serde_json::from_value(args)
        .map_err(|e| error(-32602, format!("Invalid arguments: {e}")))?;

    let row = lumotia_storage::get_transcript(pool, &args.id)
        .await
        .map_err(|e| error(-32603, format!("DB error: {e}")))?
        .ok_or_else(|| error(-32000, format!("Transcript {} not found", args.id)))?;

    let value = json!({
        "id": row.id,
        "title": row.title,
        "text": row.text,
        "createdAt": row.created_at,
        "source": row.source,
        "duration": row.duration,
        "engine": row.engine,
        "modelId": row.model_id,
        "language": row.language,
        "starred": row.starred,
        "manualTags": row.manual_tags,
        "template": row.template,
    });

    Ok(text_content(serde_json::to_string_pretty(&value).unwrap()))
}

async fn search_transcripts_tool(pool: &SqlitePool, args: Value) -> Result<Value, JsonRpcError> {
    #[derive(Deserialize)]
    struct Args {
        query: String,
        #[serde(default)]
        limit: Option<i64>,
    }
    let args: Args = serde_json::from_value(args)
        .map_err(|e| error(-32602, format!("Invalid arguments: {e}")))?;
    let limit = args.limit.unwrap_or(20).clamp(1, 100);

    let rows = lumotia_storage::search_transcripts(pool, &args.query, limit)
        .await
        .map_err(|e| error(-32603, format!("DB error: {e}")))?;

    let summaries: Vec<Value> = rows
        .into_iter()
        .map(|r| {
            json!({
                "id": r.id,
                "title": r.title,
                "createdAt": r.created_at,
                "preview": preview(&r.text, 240),
                "source": r.source,
            })
        })
        .collect();

    Ok(text_content(
        serde_json::to_string_pretty(&summaries).unwrap(),
    ))
}

async fn list_tasks_tool(pool: &SqlitePool) -> Result<Value, JsonRpcError> {
    let rows = lumotia_storage::list_tasks(pool)
        .await
        .map_err(|e| error(-32603, format!("DB error: {e}")))?;

    let summaries: Vec<Value> = rows
        .into_iter()
        .map(|r| {
            json!({
                "id": r.id,
                "text": r.text,
                "bucket": r.bucket,
                "done": r.done,
                "doneAt": r.done_at,
                "createdAt": r.created_at,
                "parentTaskId": r.parent_task_id,
            })
        })
        .collect();

    Ok(text_content(
        serde_json::to_string_pretty(&summaries).unwrap(),
    ))
}

fn text_content(text: String) -> Value {
    json!({
        "content": [{ "type": "text", "text": text }],
    })
}

fn preview(text: &str, limit: usize) -> String {
    let trimmed = text.trim();
    if trimmed.chars().count() <= limit {
        return trimmed.to_string();
    }
    let mut out: String = trimmed.chars().take(limit).collect();
    out.push('…');
    out
}

fn error(code: i32, message: String) -> JsonRpcError {
    JsonRpcError {
        code,
        message,
        data: None,
    }
}

fn error_response(id: Value, code: i32, message: String) -> JsonRpcResponse {
    JsonRpcResponse {
        jsonrpc: "2.0",
        id,
        result: None,
        error: Some(error(code, message)),
    }
}

/// Build a JSON-RPC 2.0 Parse Error response (code -32700, id null),
/// for use by the stdio transport when a raw line fails to parse as
/// JSON at all. `handle_message` covers the shape-mismatch case; this
/// helper covers the `serde_json::from_str` failure in `main.rs` so
/// clients receive a well-formed JSON-RPC reply instead of silence
/// (2026-04-22 review MAJOR).
pub fn parse_error_response(detail: &str) -> JsonRpcResponse {
    error_response(Value::Null, -32700, format!("Parse error: {detail}"))
}

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

    #[tokio::test]
    async fn initialize_returns_server_info() {
        let request = json!({
            "jsonrpc": "2.0",
            "id": 1,
            "method": "initialize",
            "params": {},
        });

        // No pool needed — initialize doesn't hit the DB.
        let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
        let response = handle_message(&pool, request).await.expect("has response");

        let result = response.result.expect("ok");
        assert_eq!(result["protocolVersion"], PROTOCOL_VERSION);
        assert_eq!(result["serverInfo"]["name"], SERVER_NAME);
    }

    #[tokio::test]
    async fn notification_without_id_produces_no_response() {
        let request = json!({
            "jsonrpc": "2.0",
            "method": "notifications/initialized",
        });

        let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
        let response = handle_message(&pool, request).await;

        assert!(response.is_none());
    }

    #[tokio::test]
    async fn tools_list_advertises_four_tools() {
        let request = json!({
            "jsonrpc": "2.0",
            "id": 2,
            "method": "tools/list",
            "params": {},
        });

        let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
        let response = handle_message(&pool, request).await.expect("has response");

        let tools = response.result.expect("ok")["tools"]
            .as_array()
            .unwrap()
            .clone();
        let names: Vec<String> = tools
            .iter()
            .map(|tool| tool["name"].as_str().unwrap().to_string())
            .collect();
        assert_eq!(
            names,
            vec![
                "list_transcripts",
                "get_transcript",
                "search_transcripts",
                "list_tasks"
            ],
        );
    }

    #[test]
    fn parse_error_response_has_jsonrpc_2_0_shape() {
        let resp = parse_error_response("expected value at line 1 column 1");
        assert_eq!(resp.jsonrpc, "2.0");
        assert_eq!(resp.id, Value::Null);
        assert!(resp.result.is_none());
        let err = resp
            .error
            .expect("parse_error_response must carry an error");
        assert_eq!(err.code, -32700);
        assert!(err.message.contains("Parse error"));
        assert!(err.message.contains("expected value"));
    }

    #[tokio::test]
    async fn list_transcripts_accepts_omitted_arguments() {
        // Regression for the review-of-review: tools/call requests
        // that omit `arguments` arrive with `Value::Null`. The
        // malformed-params fix must not reject those — it is the
        // common shape for an empty call, equivalent to defaults.
        let request = json!({
            "jsonrpc": "2.0",
            "id": 98,
            "method": "tools/call",
            "params": {
                "name": "list_transcripts",
                // `arguments` omitted
            },
        });

        let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
        lumotia_storage::migrations::run_migrations(&pool)
            .await
            .unwrap();
        let response = handle_message(&pool, request).await.expect("has response");

        assert!(
            response.error.is_none(),
            "omitted arguments must not error, got: {:?}",
            response.error
        );
        assert!(response.result.is_some());
    }

    #[tokio::test]
    async fn list_transcripts_rejects_malformed_params_with_invalid_arguments() {
        // Regression for the 2026-04-22 review MAJOR: previously the
        // handler did `from_value(args).unwrap_or_default()`, so
        // `{"limit": "not-a-number"}` silently became `limit = 20`.
        // Every other handler returns -32602 on shape mismatch; this
        // one must now do the same.
        let request = json!({
            "jsonrpc": "2.0",
            "id": 99,
            "method": "tools/call",
            "params": {
                "name": "list_transcripts",
                "arguments": { "limit": "twenty" },
            },
        });

        let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
        let response = handle_message(&pool, request).await.expect("has response");

        assert!(response.result.is_none());
        let err = response.error.expect("expected error");
        assert_eq!(err.code, -32602, "invalid arguments must surface as -32602");
        assert!(err.message.contains("Invalid arguments"));
    }

    #[tokio::test]
    async fn unknown_method_returns_method_not_found_error() {
        let request = json!({
            "jsonrpc": "2.0",
            "id": 3,
            "method": "not_a_real_method",
        });

        let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
        let response = handle_message(&pool, request).await.expect("has response");

        assert!(response.result.is_none());
        assert_eq!(response.error.unwrap().code, -32601);
    }

    #[test]
    fn preview_truncates_at_boundary() {
        let long: String = "abcdefghij".repeat(30);
        let result = preview(&long, 20);
        let char_count = result.chars().count();
        assert_eq!(char_count, 21); // 20 + ellipsis
        assert!(result.ends_with('…'));
    }

    #[test]
    fn preview_keeps_short_text_intact() {
        assert_eq!(preview("hello", 20), "hello");
        assert_eq!(preview("  padded  ", 20), "padded");
    }
}
