//! Per-project loopback **endpoint** for the IdeA MCP transport (M5a).
//!
//! ## Where this sits in the bind (cadrage v5 §1, §2)
//!
//! The S-MCP transport is **stdio-spawn**: an MCP CLI (Claude Code, Codex)
//! reads a `{command,args}` declaration and *spawns* a thin `idea mcp-server`
//! bridge. That bridge talks JSON-RPC on its stdin/stdout to the CLI and relays
//! every line, over a **local loopback**, to the IdeA (Tauri) process where the
//! real [`OrchestratorService`](application::OrchestratorService) lives. The
//! loopback is a **Unix domain socket** (Linux/macOS) or a **Windows named
//! pipe** — never a network port, so it stays AppImage- and SSH-remote-safe and
//! needs no firewall/permission.
//!
//! This module owns the **single source of truth** for that loopback address:
//! [`mcp_endpoint`]. It is deterministic per [`ProjectId`] and is called by
//! **both** sides of the contract (cadrage §2):
//! - the side that *listens* — [`ensure_mcp_server`](crate::state::AppState::ensure_mcp_server),
//!   which binds the listener at project-open and tears it down at close (M5a);
//! - the side that *writes the CLI declaration* — `apply_mcp_config` (M5d),
//!   which must point the spawned bridge at the **exact** same address.
//!
//! Keeping the derivation here (not duplicated on each side) is what makes the
//! M1↔M3 coherence test possible.
//!
//! ## Transport crate choice — `interprocess`
//!
//! We use the [`interprocess`] crate's *local socket* abstraction rather than
//! `tokio::net::{UnixListener, windows::named_pipe}` directly, because:
//! - **One cross-OS API** for UDS (Unix) and named pipes (Windows): a single
//!   `accept` loop in M5c, no divergent `cfg` branches with two transport types.
//! - The workspace `tokio` is built **without** the `net` feature; pulling
//!   `interprocess` (with its `tokio` feature) into *only* this crate avoids
//!   widening tokio's surface workspace-wide.
//! - On Unix its listener carries a *reclaim guard* that **unlinks the socket
//!   file on drop** — so closing a project (dropping the handle) cleans the
//!   filesystem with no leak, satisfying M5a's teardown requirement for free.
//!
//! We deliberately bind a **filesystem-path** socket on Unix (under a per-user
//! runtime dir) rather than an abstract/namespaced one, so that the existence of
//! the endpoint is observable as a real path (testable) and cleaned up on close.

use std::path::PathBuf;

use application::{McpRuntime, McpRuntimeProvider};
use domain::{AgentId, Project, ProjectId};

/// The loopback address of a project's MCP endpoint — the value [`mcp_endpoint`]
/// returns. Deterministic per [`ProjectId`]; the single source of truth shared by
/// the listener (M5a/M3) and the CLI-declaration writer (M5d).
///
/// On Unix it is a **filesystem path** to a Unix-domain socket (the file is what
/// gets bound and, on close, unlinked). On Windows it is a **named pipe** path of
/// the form `\\.\pipe\idea-mcp-<id>`. The [`as_cli_arg`](Self::as_cli_arg) form is
/// the exact string handed to the spawned bridge via `--endpoint`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpEndpoint {
    /// The platform-native address string (UDS path / named-pipe path).
    addr: String,
}

impl McpEndpoint {
    /// The address as the bridge expects it on the command line (`--endpoint`).
    /// Identical string on both sides of the contract (cadrage §2).
    #[must_use]
    pub fn as_cli_arg(&self) -> &str {
        &self.addr
    }

    /// The Unix socket **file path**, when this endpoint is a filesystem-path UDS.
    /// Used by the listener to ensure the parent runtime dir exists and by tests to
    /// assert creation/cleanup. `None` on platforms whose address is not a path
    /// (Windows named pipes have no filesystem entry to manage).
    #[must_use]
    pub fn socket_path(&self) -> Option<PathBuf> {
        #[cfg(unix)]
        {
            Some(PathBuf::from(&self.addr))
        }
        #[cfg(not(unix))]
        {
            None
        }
    }
}

/// The directory under which per-project Unix sockets live, derived from the
/// per-user runtime dir (`$XDG_RUNTIME_DIR`, falling back to `$TMPDIR`, then
/// `/tmp`). Kept short so the full socket path stays well under the ~108-byte
/// `sockaddr_un` limit (`/run/user/<uid>/idea-mcp/<32 hex>.sock` ≈ 50 bytes).
///
/// The candidate bases are tried **in priority order**, returning the first whose
/// `idea-mcp` subdir exists or can be created. A set-but-unusable `$XDG_RUNTIME_DIR`
/// (e.g. a sandbox/CI where it points at a non-existent or read-only path) must not
/// dead-end the loopback bind: without this fallback the socket silently never
/// binds and inter-agent delegation dies. Deterministic for a given environment, so
/// the bind side and any `socket_path()` reader always agree on the same directory.
#[cfg(unix)]
fn unix_runtime_dir() -> PathBuf {
    let candidates = [
        std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from),
        std::env::var_os("TMPDIR").map(PathBuf::from),
        Some(PathBuf::from("/tmp")),
    ];
    for base in candidates.into_iter().flatten() {
        let dir = base.join("idea-mcp");
        if dir.is_dir() || std::fs::create_dir_all(&dir).is_ok() {
            return dir;
        }
    }
    // Last resort: keep the historical `/tmp` target even if creation just failed
    // (the bind will surface the real error rather than silently picking nowhere).
    PathBuf::from("/tmp").join("idea-mcp")
}

/// **Single source of truth.** Computes the deterministic loopback endpoint of a
/// project's MCP transport from its [`ProjectId`].
///
/// Same input ⇒ same output (stable across calls and processes); distinct projects
/// ⇒ distinct endpoints (the id's 32-hex *simple* form is unique and collision-free
/// by construction). No network port is ever used.
///
/// - **Unix**: `<runtime-dir>/idea-mcp/<id>.sock` — a UDS file path.
/// - **Windows**: `\\.\pipe\idea-mcp-<id>` — a named pipe.
///
/// where `<id>` is the project UUID in hyphen-free hex (`simple`) form.
#[must_use]
pub fn mcp_endpoint(project_id: &ProjectId) -> McpEndpoint {
    // Hyphen-free, lowercase, fixed-width (32 chars): safe in both a filename and
    // a `\\.\pipe\` name, and unique per project.
    let id = project_id.as_uuid().simple().to_string();

    #[cfg(unix)]
    let addr = unix_runtime_dir()
        .join(format!("{id}.sock"))
        .to_string_lossy()
        .into_owned();

    #[cfg(windows)]
    let addr = format!(r"\\.\pipe\idea-mcp-{id}");

    #[cfg(not(any(unix, windows)))]
    let addr = format!("idea-mcp-{id}");

    McpEndpoint { addr }
}

/// Chemin du binaire IdeA à inscrire comme `command` dans la déclaration MCP.
///
/// Privilégie `$APPIMAGE` (chemin **stable** du `.AppImage`, qui survit aux
/// redémarrages) car sous AppImage `current_exe()` pointe sur un montage éphémère
/// `/tmp/.mount_*` qui disparaît au redémarrage ⇒ `ENOENT` au respawn du pont MCP.
/// Hors AppImage `$APPIMAGE` est absent ⇒ on retombe sur `current_exe()`. `None` si
/// aucun des deux n'est résolvable (ne devrait pas arriver) ⇒ déclaration minimale.
pub(crate) fn idea_exe_path() -> Option<String> {
    std::env::var("APPIMAGE").ok().or_else(|| {
        std::env::current_exe()
            .ok()
            .map(|p| p.to_string_lossy().into_owned())
    })
}

/// Implémentation app-tauri du port [`McpRuntimeProvider`] : fournit à
/// l'orchestrateur (couche `application`) les **faits OS/runtime** nécessaires pour
/// écrire la déclaration MCP réelle quand il (re)lance une cible sur le chemin
/// `ask` (`ensure_live_pty`).
///
/// Sans état : tout est dérivé du `Project` et de l'`AgentId` reçus + de
/// l'environnement (`$APPIMAGE`/`current_exe`). C'est le **seul** détenteur légitime
/// de ces faits (la couche `application` ne doit pas dépendre de `current_exe` /
/// `$APPIMAGE` / `mcp_endpoint`, cadrage v5 §0.3 / §7) ; seules des **chaînes**
/// traversent la frontière via [`McpRuntime`].
pub struct AppMcpRuntimeProvider;

impl McpRuntimeProvider for AppMcpRuntimeProvider {
    /// `agent_id` = la cible relancée = le `--requester` (c'est elle qui appellera
    /// `idea_reply`). Réutilise la **même** logique que le chemin GUI
    /// (`commands.rs::launch_agent`) : même endpoint (source de vérité unique),
    /// même forme `simple` 32-hex du `project_id`. `None` (exe introuvable) ⇒
    /// l'orchestrateur dégrade vers la déclaration minimale, jamais d'échec de
    /// lancement.
    fn runtime_for(&self, project: &Project, agent_id: AgentId) -> Option<McpRuntime> {
        Some(McpRuntime {
            exe: idea_exe_path()?,
            endpoint: mcp_endpoint(&project.id).as_cli_arg().to_owned(),
            project_id: project.id.as_uuid().simple().to_string(),
            requester: agent_id.to_string(),
        })
    }
}

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

    fn pid(s: &str) -> ProjectId {
        ProjectId::from_uuid(Uuid::parse_str(s).unwrap())
    }

    #[test]
    fn endpoint_is_deterministic_for_the_same_project() {
        let p = pid("11111111-1111-1111-1111-111111111111");
        assert_eq!(mcp_endpoint(&p), mcp_endpoint(&p));
        assert_eq!(mcp_endpoint(&p).as_cli_arg(), mcp_endpoint(&p).as_cli_arg());
    }

    #[test]
    fn distinct_projects_get_distinct_endpoints() {
        let a = pid("11111111-1111-1111-1111-111111111111");
        let b = pid("22222222-2222-2222-2222-222222222222");
        assert_ne!(mcp_endpoint(&a), mcp_endpoint(&b));
        assert_ne!(mcp_endpoint(&a).as_cli_arg(), mcp_endpoint(&b).as_cli_arg());
    }

    #[test]
    fn address_encodes_the_project_id_without_hyphens() {
        let p = pid("abcdef01-2345-6789-abcd-ef0123456789");
        let arg = mcp_endpoint(&p).as_cli_arg().to_owned();
        assert!(
            arg.contains("abcdef0123456789abcdef0123456789"),
            "endpoint must embed the hyphen-free id, got {arg}"
        );
    }

    #[cfg(unix)]
    #[test]
    fn unix_endpoint_is_a_sock_path_under_the_runtime_dir() {
        let p = pid("11111111-1111-1111-1111-111111111111");
        let ep = mcp_endpoint(&p);
        let path = ep
            .socket_path()
            .expect("unix endpoint exposes a socket path");
        assert!(path.extension().is_some_and(|e| e == "sock"));
        assert_eq!(path.parent().unwrap(), unix_runtime_dir());
    }

    /// `idea_exe_path()` : `$APPIMAGE` posé ⇒ on renvoie sa valeur (chemin stable du
    /// `.AppImage`, qui survit aux redémarrages) ; absent ⇒ on retombe sur
    /// `current_exe()`. Les deux assertions sont regroupées dans **un seul** test pour
    /// éviter une course inter-tests sur la variable d'env globale du process ; la var
    /// est sauvegardée/restaurée pour ne pas polluer les autres tests.
    #[test]
    fn idea_exe_path_prefers_appimage_then_falls_back_to_current_exe() {
        let saved = std::env::var_os("APPIMAGE");

        // APPIMAGE posé ⇒ valeur exacte renvoyée.
        std::env::set_var("APPIMAGE", "/opt/idea/IdeA.AppImage");
        assert_eq!(
            idea_exe_path().as_deref(),
            Some("/opt/idea/IdeA.AppImage"),
            "$APPIMAGE doit primer"
        );

        // APPIMAGE absent ⇒ repli sur current_exe() (présent dans un binaire de test).
        std::env::remove_var("APPIMAGE");
        let fallback = idea_exe_path();
        let expected = std::env::current_exe()
            .ok()
            .map(|p| p.to_string_lossy().into_owned());
        assert_eq!(fallback, expected, "repli attendu sur current_exe()");
        assert!(fallback.is_some(), "current_exe() résolvable dans le test");

        // Restauration de l'état initial de la variable.
        match saved {
            Some(v) => std::env::set_var("APPIMAGE", v),
            None => std::env::remove_var("APPIMAGE"),
        }
    }

    /// `AppMcpRuntimeProvider::runtime_for` : cohérence des champs avec les sources de
    /// vérité — endpoint identique à `mcp_endpoint(project.id).as_cli_arg()`,
    /// `project_id` en forme `simple` 32-hex, `requester == agent_id.to_string()`.
    #[test]
    fn app_provider_runtime_for_matches_sources_of_truth() {
        use domain::project::ProjectPath;
        use domain::remote::RemoteRef;
        use domain::{AgentId, Project, ProjectId};

        let project = Project::new(
            ProjectId::from_uuid(Uuid::parse_str("abcdef01-2345-6789-abcd-ef0123456789").unwrap()),
            "demo",
            ProjectPath::new("/home/me/proj").unwrap(),
            RemoteRef::local(),
            1_700_000_000_000,
        )
        .unwrap();
        let agent_id = AgentId::from_uuid(Uuid::from_u128(42));

        // On s'assure que current_exe/$APPIMAGE est résolvable (sinon runtime_for None).
        let rt = AppMcpRuntimeProvider
            .runtime_for(&project, agent_id)
            .expect("runtime_for doit produire un McpRuntime (exe résolvable)");

        // Endpoint = même source de vérité que le listener.
        assert_eq!(
            rt.endpoint,
            mcp_endpoint(&project.id).as_cli_arg(),
            "endpoint cohérent avec mcp_endpoint()"
        );
        // project_id en forme simple 32-hex (hyphen-free), consommée par la garde M5c.
        assert_eq!(rt.project_id, project.id.as_uuid().simple().to_string());
        assert_eq!(rt.project_id.len(), 32, "forme simple 32-hex");
        assert!(!rt.project_id.contains('-'), "pas de tirets");
        // requester = l'id de la cible relancée.
        assert_eq!(rt.requester, agent_id.to_string());
        // exe non vide.
        assert!(!rt.exe.is_empty(), "exe renseigné");
    }
}
