use crate::state::{GuiConfigState, PackagesState, SettingsState};
use alcomd3_mcp_protocol::{
    ClientIdentity, EndpointMetadata, IPC_PROTOCOL_VERSION, IpcRequest, IpcResponse, IpcTransport,
    endpoint_file_path,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::VecDeque;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Emitter, Manager};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Mutex;
use uuid::Uuid;
use vrc_get_vpm::environment::{UserProject, VccDatabaseConnection};
use vrc_get_vpm::io::{DefaultEnvironmentIo, DefaultProjectIo};
use vrc_get_vpm::{PackageInfo, PackageManifest, UnityProject};

pub const MCP_STATUS_CHANGED_EVENT: &str = "mcp-status-changed";
pub const MCP_TOOL_CALL_EVENT: &str = "mcp-tool-call";
const MAX_RECORDED_MCP_RECENT_CLIENTS: usize = 20;
const MCP_CLIENT_ACTIVITY_TTL_MS: u64 = 10 * 60 * 1_000;
const MCP_CLIENT_STATUS_EMIT_THROTTLE_MS: u64 = 1_000;

#[derive(Debug, Clone, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct McpStatus {
    enabled: bool,
    running: bool,
    protocol_version: u32,
    transport: String,
    host: Option<String>,
    port: Option<u16>,
    pid: u32,
    endpoint_file: String,
    bridge_command: String,
    recent_clients: Vec<McpRecentClientStatus>,
}

#[derive(Debug, Clone, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct McpRecentClientStatus {
    session_id: String,
    name: String,
    version: Option<String>,
    last_seen_unix_ms: u64,
}

#[derive(Debug, Clone, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct McpToolCallEvent {
    request_id: String,
    tool_name: String,
    phase: McpToolCallPhase,
}

#[derive(Debug, Clone, Serialize, specta::Type, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum McpToolCallPhase {
    Started,
    Finished,
    Failed,
}

pub struct McpState {
    inner: Mutex<McpStateInner>,
}

struct McpStateInner {
    active: Option<ActiveMcpServer>,
    recent_clients: VecDeque<McpRecentClientStatus>,
    last_client_status_emit_unix_ms: u64,
}

struct ActiveMcpServer {
    metadata: EndpointMetadata,
    task: tauri::async_runtime::JoinHandle<()>,
}

impl McpState {
    pub fn new() -> Self {
        Self {
            inner: Mutex::new(McpStateInner {
                active: None,
                recent_clients: VecDeque::new(),
                last_client_status_emit_unix_ms: 0,
            }),
        }
    }

    pub async fn status(&self, enabled: bool) -> McpStatus {
        let inner = self.inner.lock().await;
        let metadata = inner.active.as_ref().map(|active| &active.metadata);
        let now_unix_ms = now_unix_ms();

        McpStatus {
            enabled,
            running: metadata.is_some(),
            protocol_version: IPC_PROTOCOL_VERSION,
            transport: "tcp".to_string(),
            host: metadata.map(|metadata| metadata.host.clone()),
            port: metadata.map(|metadata| metadata.port),
            pid: std::process::id(),
            endpoint_file: endpoint_file_path().display().to_string(),
            bridge_command: bridge_command(),
            recent_clients: inner
                .recent_clients
                .iter()
                .filter(|client| is_recent_client_activity(client.last_seen_unix_ms, now_unix_ms))
                .cloned()
                .collect(),
        }
    }

    pub async fn ensure_running(&self, app: AppHandle) -> io::Result<()> {
        self.start(app).await
    }

    pub async fn set_enabled(&self, app: AppHandle, enabled: bool) -> io::Result<()> {
        if let Err(e) = self.start(app.clone()).await {
            log::error!("failed to ensure MCP IPC endpoint while setting MCP access: {e}");
        }
        self.emit_status(app, enabled).await;
        Ok(())
    }

    async fn start(&self, app: AppHandle) -> io::Result<()> {
        let mut inner = self.inner.lock().await;
        if inner.active.is_some() {
            return Ok(());
        }

        let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
        let address = listener.local_addr()?;
        let token = Uuid::new_v4().simple().to_string();
        let metadata = EndpointMetadata {
            protocol_version: IPC_PROTOCOL_VERSION,
            transport: IpcTransport::Tcp,
            host: "127.0.0.1".to_string(),
            port: address.port(),
            token: token.clone(),
            pid: std::process::id(),
        };

        write_endpoint_file(&endpoint_file_path(), &metadata).await?;

        let task = tauri::async_runtime::spawn(async move {
            accept_loop(app, listener, token).await;
        });

        inner.recent_clients.clear();
        inner.last_client_status_emit_unix_ms = 0;
        inner.active = Some(ActiveMcpServer { metadata, task });
        Ok(())
    }

    async fn stop(&self) -> io::Result<()> {
        let mut inner = self.inner.lock().await;
        if let Some(active) = inner.active.take() {
            active.task.abort();
        }
        inner.recent_clients.clear();
        inner.last_client_status_emit_unix_ms = 0;
        remove_endpoint_file(&endpoint_file_path()).await
    }

    pub async fn shutdown(&self) -> io::Result<()> {
        self.stop().await
    }

    async fn emit_status(&self, app: AppHandle, enabled: bool) {
        let status = self.status(enabled).await;
        if let Err(e) = app.emit(MCP_STATUS_CHANGED_EVENT, status) {
            log::error!("failed to emit MCP status change: {e}");
        }
    }

    async fn record_client(&self, client: &ClientIdentity) -> bool {
        let mut inner = self.inner.lock().await;
        let now_unix_ms = now_unix_ms();
        let McpStateInner {
            recent_clients,
            last_client_status_emit_unix_ms,
            ..
        } = &mut *inner;
        record_client_activity(
            recent_clients,
            client,
            now_unix_ms,
            last_client_status_emit_unix_ms,
        )
    }
}

async fn accept_loop(app: AppHandle, listener: TcpListener, token: String) {
    loop {
        match listener.accept().await {
            Ok((stream, _)) => {
                let app = app.clone();
                let token = token.clone();
                tauri::async_runtime::spawn(async move {
                    if let Err(e) = handle_connection(app, stream, token).await {
                        log::error!("MCP IPC connection failed: {e}");
                    }
                });
            }
            Err(e) => {
                log::error!("MCP IPC listener failed: {e}");
                break;
            }
        }
    }
}

async fn handle_connection(app: AppHandle, stream: TcpStream, token: String) -> io::Result<()> {
    let mut reader = BufReader::new(stream);
    let mut line = String::new();
    reader.read_line(&mut line).await?;

    let response = match serde_json::from_str::<IpcRequest>(&line) {
        Ok(request) => handle_request(app, request, &token).await,
        Err(e) => IpcResponse::error(
            Uuid::nil(),
            "invalid_request",
            format!("Failed to parse MCP IPC request: {e}"),
            None,
        ),
    };

    let stream = reader.get_mut();
    let bytes = serde_json::to_vec(&response).map_err(io::Error::other)?;
    stream.write_all(&bytes).await?;
    stream.write_all(b"\n").await?;
    stream.flush().await
}

async fn handle_request(app: AppHandle, request: IpcRequest, token: &str) -> IpcResponse {
    if request.protocol_version != IPC_PROTOCOL_VERSION {
        return IpcResponse::error(
            request.request_id,
            "protocol_version_mismatch",
            format!(
                "Unsupported MCP IPC protocol version {}; expected {}",
                request.protocol_version, IPC_PROTOCOL_VERSION
            ),
            None,
        );
    }

    if request.token != token {
        return IpcResponse::error(
            request.request_id,
            "unauthorized",
            "Invalid MCP IPC token",
            None,
        );
    }

    let mcp = app.state::<McpState>();
    let enabled = app.state::<GuiConfigState>().get().mcp_enabled;
    if mcp.record_client(&request.client).await {
        mcp.emit_status(app.clone(), enabled).await;
    }

    if !enabled {
        return mcp_disabled_response(request.request_id);
    }

    let tool_name = mcp_tool_name(&request.method);
    if let Some(tool_name) = tool_name {
        emit_mcp_tool_call_event(
            &app,
            request.request_id,
            tool_name,
            McpToolCallPhase::Started,
        );
    }

    let result = dispatch_gui_request(app.clone(), &request.method, request.params).await;
    if let Some(tool_name) = tool_name {
        emit_mcp_tool_call_event(
            &app,
            request.request_id,
            tool_name,
            mcp_tool_call_finished_phase(&result),
        );
    }

    match result {
        Ok(result) => IpcResponse::success(request.request_id, result),
        Err(error) => IpcResponse::error(request.request_id, error.code, error.message, error.data),
    }
}

pub struct McpIpcError {
    pub code: String,
    pub message: String,
    pub data: Option<Value>,
}

impl McpIpcError {
    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            code: code.into(),
            message: message.into(),
            data: None,
        }
    }

    fn from_error(code: impl Into<String>, error: impl std::error::Error) -> Self {
        Self::new(code, error.to_string())
    }
}

async fn dispatch_gui_request(
    app: AppHandle,
    method: &str,
    params: Value,
) -> Result<Value, McpIpcError> {
    match method {
        "list_projects" => list_projects(app).await,
        "get_project_details" => get_project_details(app, params).await,
        "list_repositories" => list_repositories(app).await,
        "search_packages" => search_packages(app, params).await,
        _ => Err(McpIpcError::new(
            "unknown_method",
            format!("MCP IPC method is not implemented: {method}"),
        )),
    }
}

fn mcp_tool_name(method: &str) -> Option<&'static str> {
    match method {
        "list_projects" => Some("alcomd3_list_projects"),
        "get_project_details" => Some("alcomd3_get_project_details"),
        "list_repositories" => Some("alcomd3_list_repositories"),
        "search_packages" => Some("alcomd3_search_packages"),
        _ => None,
    }
}

fn mcp_tool_call_finished_phase<T, E>(result: &Result<T, E>) -> McpToolCallPhase {
    match result {
        Ok(_) => McpToolCallPhase::Finished,
        Err(_) => McpToolCallPhase::Failed,
    }
}

fn emit_mcp_tool_call_event(
    app: &AppHandle,
    request_id: Uuid,
    tool_name: &str,
    phase: McpToolCallPhase,
) {
    let event = McpToolCallEvent {
        request_id: request_id.to_string(),
        tool_name: tool_name.to_string(),
        phase,
    };
    if let Err(e) = app.emit(MCP_TOOL_CALL_EVENT, event) {
        log::error!("failed to emit MCP tool call event: {e}");
    }
}

async fn list_projects(app: AppHandle) -> Result<Value, McpIpcError> {
    let io = app.state::<DefaultEnvironmentIo>();
    let connection = VccDatabaseConnection::connect(io.inner())
        .await
        .map_err(|e| McpIpcError::from_error("project_database_error", e))?;

    let projects = connection
        .get_projects()
        .iter()
        .filter_map(project_summary)
        .collect::<Vec<_>>();

    Ok(json!({
        "ok": true,
        "projects": projects,
    }))
}

#[derive(Deserialize)]
struct ProjectDetailsParams {
    project_path: String,
}

async fn get_project_details(app: AppHandle, params: Value) -> Result<Value, McpIpcError> {
    let params = serde_json::from_value::<ProjectDetailsParams>(params)
        .map_err(|e| McpIpcError::from_error("invalid_params", e))?;
    let io = app.state::<DefaultEnvironmentIo>();
    let connection = VccDatabaseConnection::connect(io.inner())
        .await
        .map_err(|e| McpIpcError::from_error("project_database_error", e))?;

    if connection
        .find_project(&params.project_path)
        .map_err(|e| McpIpcError::from_error("project_database_error", e))?
        .is_none()
    {
        return Err(McpIpcError::new(
            "project_not_registered",
            "project_path must match an ALCOMD3 registered project",
        ));
    }

    let unity_project = UnityProject::load(DefaultProjectIo::new(
        PathBuf::from(&params.project_path).into_boxed_path(),
    ))
    .await
    .map_err(|e| McpIpcError::from_error("project_load_error", e))?;

    let installed_packages = unity_project
        .installed_packages()
        .map(|(id, package)| {
            json!({
                "id": id,
                "package": package_manifest_summary(package),
            })
        })
        .collect::<Vec<_>>();

    Ok(json!({
        "ok": true,
        "project": {
            "path": params.project_path,
            "unity": {
                "major": unity_project.unity_version().major(),
                "minor": unity_project.unity_version().minor(),
                "version": unity_project.unity_version().to_string(),
                "revision": unity_project.unity_revision(),
            },
            "shouldResolve": unity_project.should_resolve(),
            "installedPackages": installed_packages,
        },
    }))
}

async fn list_repositories(app: AppHandle) -> Result<Value, McpIpcError> {
    let io = app.state::<DefaultEnvironmentIo>();
    let settings = app.state::<SettingsState>();
    let config = app.state::<GuiConfigState>();
    let settings = settings
        .load(io.inner())
        .await
        .map_err(|e| McpIpcError::from_error("settings_load_error", e))?;
    let config = config.get();

    let user_repositories = settings
        .get_user_repos()
        .iter()
        .enumerate()
        .map(|(index, repo)| {
            let id = repo
                .id()
                .or(repo.url().map(url::Url::as_str))
                .map(str::to_string);
            json!({
                "index": index,
                "id": id,
                "url": repo.url().map(ToString::to_string),
                "displayName": repo.name().map(str::to_string).or_else(|| id.clone()),
            })
        })
        .collect::<Vec<_>>();

    Ok(json!({
        "ok": true,
        "userRepositories": user_repositories,
        "hiddenUserRepositories": config.gui_hidden_repositories.iter().collect::<Vec<_>>(),
        "hideLocalUserPackages": config.hide_local_user_packages,
        "showPrereleasePackages": settings.show_prerelease_packages(),
    }))
}

#[derive(Deserialize)]
struct SearchPackagesParams {
    query: Option<String>,
    limit: Option<usize>,
}

async fn search_packages(app: AppHandle, params: Value) -> Result<Value, McpIpcError> {
    let params = serde_json::from_value::<SearchPackagesParams>(params)
        .map_err(|e| McpIpcError::from_error("invalid_params", e))?;
    let limit = params.limit.unwrap_or(30).min(100);
    let query = params.query.unwrap_or_default();
    let query = query.trim().to_ascii_lowercase();

    let io = app.state::<DefaultEnvironmentIo>();
    let settings = app.state::<SettingsState>();
    let packages = app.state::<PackagesState>();
    let http = app.state::<reqwest::Client>();

    let settings = settings
        .load(io.inner())
        .await
        .map_err(|e| McpIpcError::from_error("settings_load_error", e))?;
    let packages = packages
        .load(&settings, io.inner(), http.inner(), app.clone())
        .await
        .map_err(|e| McpIpcError::from_error("packages_load_error", e))?;

    let mut results = packages
        .packages()
        .filter(|package| query.is_empty() || package_matches_query(package, &query))
        .map(package_info_summary)
        .collect::<Vec<_>>();

    results.sort_by(|a, b| {
        let a_name = a["name"].as_str().unwrap_or_default();
        let b_name = b["name"].as_str().unwrap_or_default();
        let a_version = a["version"].as_str().unwrap_or_default();
        let b_version = b["version"].as_str().unwrap_or_default();
        a_name.cmp(b_name).then_with(|| a_version.cmp(b_version))
    });
    results.truncate(limit);

    Ok(json!({
        "ok": true,
        "query": if query.is_empty() { Value::Null } else { Value::String(query) },
        "limit": limit,
        "packages": results,
    }))
}

fn project_summary(project: &UserProject) -> Option<Value> {
    let path = project.path()?.to_string();
    Some(json!({
        "name": project.name(),
        "path": path,
        "projectType": format!("{:?}", project.project_type()),
        "unity": project.unity_version().map(|version| version.to_string()),
        "unityRevision": project.unity_revision(),
        "lastModified": project.last_modified().map(|value| value.as_unix_milliseconds()),
        "createdAt": project.crated_at().map(|value| value.as_unix_milliseconds()),
        "favorite": project.favorite(),
        "exists": Path::new(&path).is_dir(),
    }))
}

fn package_info_summary(package: &PackageInfo<'_>) -> Value {
    let manifest = package.package_json();
    let source = if let Some(repo) = package.repo() {
        let id = repo.id().or(repo.url().map(url::Url::as_str));
        json!({
            "type": "remote",
            "id": id,
            "displayName": repo.name().or(id),
            "url": repo.url().map(ToString::to_string),
        })
    } else {
        json!({
            "type": "localUser",
        })
    };

    let mut value = package_manifest_summary(manifest);
    if let Value::Object(ref mut object) = value {
        object.insert("source".to_string(), source);
    }
    value
}

fn package_manifest_summary(package: &PackageManifest) -> Value {
    json!({
        "name": package.name(),
        "displayName": package.display_name(),
        "description": package.description(),
        "version": package.version().to_string(),
        "unity": package.unity().map(|unity| {
            json!({
                "major": unity.major(),
                "minor": unity.minor(),
            })
        }),
        "keywords": package.keywords(),
        "aliases": package.aliases(),
        "vpmDependencies": package
            .vpm_dependencies()
            .keys()
            .map(|name| name.to_string())
            .collect::<Vec<_>>(),
        "legacyPackages": package.legacy_packages(),
        "changelogUrl": package.changelog_url().map(ToString::to_string),
        "documentationUrl": package.documentation_url().map(ToString::to_string),
        "isYanked": package.is_yanked(),
    })
}

fn package_matches_query(package: &PackageInfo<'_>, query: &str) -> bool {
    let manifest = package.package_json();
    text_matches(manifest.name(), query)
        || manifest
            .display_name()
            .is_some_and(|value| text_matches(value, query))
        || manifest
            .description()
            .is_some_and(|value| text_matches(value, query))
        || manifest
            .keywords()
            .iter()
            .any(|value| text_matches(value.as_ref(), query))
        || manifest
            .aliases()
            .iter()
            .any(|value| text_matches(value.as_ref(), query))
}

fn text_matches(value: &str, query: &str) -> bool {
    value.to_ascii_lowercase().contains(query)
}

async fn write_endpoint_file(path: &Path, metadata: &EndpointMetadata) -> io::Result<()> {
    let Some(parent) = path.parent() else {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "endpoint file path has no parent",
        ));
    };
    tokio::fs::create_dir_all(parent).await?;
    let bytes = serde_json::to_vec_pretty(metadata).map_err(io::Error::other)?;
    let temp_path = temporary_endpoint_path(path);
    remove_endpoint_file(&temp_path).await?;
    tokio::fs::write(&temp_path, bytes).await?;
    remove_endpoint_file(path).await?;
    tokio::fs::rename(&temp_path, path).await
}

async fn remove_endpoint_file(path: &Path) -> io::Result<()> {
    match tokio::fs::remove_file(path).await {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
        Err(e) => Err(e),
    }
}

fn temporary_endpoint_path(path: &Path) -> PathBuf {
    let mut file_name = path
        .file_name()
        .map(|name| name.to_os_string())
        .unwrap_or_else(|| "endpoint.json".into());
    file_name.push(format!(".tmp.{}", std::process::id()));
    path.with_file_name(file_name)
}

fn bridge_command() -> String {
    quote_command_path(&bridge_executable_path())
}

fn bridge_executable_path() -> PathBuf {
    let current = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("ALCOM"));
    let directory = current.parent().unwrap_or_else(|| Path::new("."));
    #[cfg(windows)]
    {
        directory.join("alcomd3-mcp.exe")
    }
    #[cfg(not(windows))]
    {
        directory.join("alcomd3-mcp")
    }
}

fn quote_command_path(path: &Path) -> String {
    let value = path.display().to_string();
    if value.contains([' ', '\t', '"']) {
        format!("\"{}\"", value.replace('"', "\\\""))
    } else {
        value
    }
}

fn now_unix_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
        .unwrap_or_default()
}

fn record_client_activity(
    clients: &mut VecDeque<McpRecentClientStatus>,
    client: &ClientIdentity,
    now_unix_ms: u64,
    last_emit_unix_ms: &mut u64,
) -> bool {
    let removed_stale_clients = retain_recent_client_activity(clients, now_unix_ms);
    let session_id = client.session_id.to_string();
    let existing = clients
        .iter()
        .find(|existing| existing.session_id == session_id);
    let should_emit = existing.is_none_or(|existing| {
        existing.name != client.name
            || existing.version != client.version
            || now_unix_ms.saturating_sub(*last_emit_unix_ms) >= MCP_CLIENT_STATUS_EMIT_THROTTLE_MS
    }) || removed_stale_clients;

    clients.retain(|existing| existing.session_id != session_id);
    clients.push_front(McpRecentClientStatus {
        session_id,
        name: client.name.clone(),
        version: client.version.clone(),
        last_seen_unix_ms: now_unix_ms,
    });
    while clients.len() > MAX_RECORDED_MCP_RECENT_CLIENTS {
        clients.pop_back();
    }

    if should_emit {
        *last_emit_unix_ms = now_unix_ms;
    }
    should_emit
}

fn retain_recent_client_activity(
    clients: &mut VecDeque<McpRecentClientStatus>,
    now_unix_ms: u64,
) -> bool {
    let before_len = clients.len();
    clients.retain(|client| is_recent_client_activity(client.last_seen_unix_ms, now_unix_ms));
    clients.len() != before_len
}

fn is_recent_client_activity(last_seen_unix_ms: u64, now_unix_ms: u64) -> bool {
    now_unix_ms.saturating_sub(last_seen_unix_ms) <= MCP_CLIENT_ACTIVITY_TTL_MS
}

fn mcp_disabled_response(request_id: Uuid) -> IpcResponse {
    IpcResponse::error(
        request_id,
        "mcp_disabled",
        "MCP is disabled in ALCOMD3 GUI",
        None,
    )
}

#[allow(dead_code)]
fn ok_result(value: Value) -> Value {
    json!({
        "ok": true,
        "result": value,
    })
}

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

    #[test]
    fn record_client_activity_emits_for_new_client() {
        let mut clients = VecDeque::new();
        let mut last_emit_unix_ms = 0;
        let client = test_client("Codex", Some("1.0.0"));

        assert!(record_client_activity(
            &mut clients,
            &client,
            1_000,
            &mut last_emit_unix_ms
        ));
        assert_eq!(last_emit_unix_ms, 1_000);
        assert_eq!(clients.len(), 1);
        assert_eq!(clients[0].name, "Codex");
        assert_eq!(clients[0].version.as_deref(), Some("1.0.0"));
        assert_eq!(clients[0].last_seen_unix_ms, 1_000);
    }

    #[test]
    fn record_client_activity_throttles_last_seen_only_updates() {
        let mut clients = VecDeque::new();
        let mut last_emit_unix_ms = 0;
        let client = test_client("Codex", Some("1.0.0"));

        assert!(record_client_activity(
            &mut clients,
            &client,
            1_000,
            &mut last_emit_unix_ms
        ));

        let before_throttle = 1_000 + MCP_CLIENT_STATUS_EMIT_THROTTLE_MS - 1;
        assert!(!record_client_activity(
            &mut clients,
            &client,
            before_throttle,
            &mut last_emit_unix_ms
        ));
        assert_eq!(last_emit_unix_ms, 1_000);
        assert_eq!(clients[0].last_seen_unix_ms, before_throttle);

        let after_throttle = 1_000 + MCP_CLIENT_STATUS_EMIT_THROTTLE_MS;
        assert!(record_client_activity(
            &mut clients,
            &client,
            after_throttle,
            &mut last_emit_unix_ms
        ));
        assert_eq!(last_emit_unix_ms, after_throttle);
        assert_eq!(clients[0].last_seen_unix_ms, after_throttle);
    }

    #[test]
    fn record_client_activity_emits_when_client_metadata_changes() {
        let mut clients = VecDeque::new();
        let mut last_emit_unix_ms = 0;
        let mut client = test_client("Codex", Some("1.0.0"));

        assert!(record_client_activity(
            &mut clients,
            &client,
            1_000,
            &mut last_emit_unix_ms
        ));

        client.version = Some("1.0.1".to_string());
        assert!(record_client_activity(
            &mut clients,
            &client,
            1_001,
            &mut last_emit_unix_ms
        ));
        assert_eq!(last_emit_unix_ms, 1_001);
        assert_eq!(clients[0].version.as_deref(), Some("1.0.1"));
    }

    #[test]
    fn record_client_activity_prunes_stale_clients() {
        let mut clients = VecDeque::new();
        let mut last_emit_unix_ms = 1_000;
        let stale_client = test_client("Stale", Some("1.0.0"));
        let fresh_client = test_client("Fresh", Some("1.0.0"));
        let next_client = test_client("Codex", Some("1.0.0"));

        clients.push_back(McpRecentClientStatus {
            session_id: stale_client.session_id.to_string(),
            name: stale_client.name,
            version: stale_client.version,
            last_seen_unix_ms: 1_000,
        });
        clients.push_back(McpRecentClientStatus {
            session_id: fresh_client.session_id.to_string(),
            name: fresh_client.name,
            version: fresh_client.version,
            last_seen_unix_ms: 1_000 + MCP_CLIENT_ACTIVITY_TTL_MS,
        });

        assert!(record_client_activity(
            &mut clients,
            &next_client,
            1_000 + MCP_CLIENT_ACTIVITY_TTL_MS + 1,
            &mut last_emit_unix_ms
        ));

        assert_eq!(clients.len(), 2);
        assert_eq!(clients[0].name, "Codex");
        assert_eq!(clients[1].name, "Fresh");
    }

    #[test]
    fn recent_client_activity_expires_after_ttl() {
        assert!(is_recent_client_activity(
            1_000,
            1_000 + MCP_CLIENT_ACTIVITY_TTL_MS
        ));
        assert!(!is_recent_client_activity(
            1_000,
            1_000 + MCP_CLIENT_ACTIVITY_TTL_MS + 1
        ));
    }

    #[test]
    fn mcp_tool_name_maps_gui_methods_to_public_tool_names() {
        assert_eq!(
            mcp_tool_name("list_projects"),
            Some("alcomd3_list_projects")
        );
        assert_eq!(
            mcp_tool_name("get_project_details"),
            Some("alcomd3_get_project_details")
        );
        assert_eq!(
            mcp_tool_name("list_repositories"),
            Some("alcomd3_list_repositories")
        );
        assert_eq!(
            mcp_tool_name("search_packages"),
            Some("alcomd3_search_packages")
        );
        assert_eq!(mcp_tool_name("unknown"), None);
    }

    #[test]
    fn mcp_tool_call_finished_phase_tracks_success_and_failure() {
        let ok: Result<(), McpIpcError> = Ok(());
        let error: Result<(), McpIpcError> = Err(McpIpcError::new("failed", "failed"));

        assert_eq!(
            mcp_tool_call_finished_phase(&ok),
            McpToolCallPhase::Finished
        );
        assert_eq!(
            mcp_tool_call_finished_phase(&error),
            McpToolCallPhase::Failed
        );
    }

    #[test]
    fn mcp_tool_call_event_serializes_for_frontend_listener() {
        let request_id = Uuid::nil().to_string();
        let event = McpToolCallEvent {
            request_id: request_id.clone(),
            tool_name: "alcomd3_list_projects".to_string(),
            phase: McpToolCallPhase::Started,
        };

        let serialized = serde_json::to_value(event).unwrap();

        assert_eq!(
            serialized,
            json!({
                "requestId": request_id,
                "toolName": "alcomd3_list_projects",
                "phase": "started",
            })
        );
    }

    #[test]
    fn disabled_mcp_response_returns_business_error() {
        let request_id = Uuid::new_v4();

        let response = mcp_disabled_response(request_id);

        assert_eq!(response.request_id, request_id);
        assert!(!response.ok);
        let error = response.error.unwrap();
        assert_eq!(error.code, "mcp_disabled");
        assert!(error.message.contains("disabled"));
    }

    fn test_client(name: &str, version: Option<&str>) -> ClientIdentity {
        ClientIdentity {
            session_id: Uuid::new_v4(),
            name: name.to_string(),
            version: version.map(str::to_string),
        }
    }
}
