//! Pi agent session.
//!
//! The chat panel drives the real `pi` CLI through its RPC mode
//! (`pi --mode rpc`), a JSONL protocol over stdin/stdout: commands go in, agent
//! events stream out. Nothing here interprets the protocol — it forwards lines,
//! so the app tracks whatever the installed pi version emits.

use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::{Arc, Mutex};

use serde::Deserialize;
use serde_json::Value;

use crate::settings::Settings;
use crate::vault::VaultError;

/// Everything needed to launch an agent for one vault.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct AgentConfig {
    /// External agent CLI (`pi`, or an absolute path). Empty = embedded Pi host.
    pub command: String,
    /// JS runtime for the embedded host: `auto` | `bun` | `node`.
    pub runtime: String,
    /// Vault the agent works on; becomes its working directory.
    pub vault: String,
    pub provider: String,
    pub model: String,
    pub thinking: String,
    pub api_key: String,
    /// Optional OpenAI-compatible endpoint (for self-hosted / proxied models).
    pub base_url: String,
    /// Pass `--approve` so the vault's own `.pi` resources load.
    pub trust_project: bool,
    /// Load the vault's `AGENTS.md` into the prompt.
    pub context_files: bool,
    pub session_dir: String,
    pub extra_args: Vec<String>,
    /// Extension files passed with `-e`; the vault tool extension lives here.
    pub extensions: Vec<String>,
    pub append_system_prompt: String,
}

impl Default for AgentConfig {
    fn default() -> Self {
        Self {
            command: String::new(),
            runtime: "auto".to_string(),
            vault: String::new(),
            provider: String::new(),
            model: String::new(),
            thinking: String::new(),
            api_key: String::new(),
            base_url: String::new(),
            trust_project: true,
            context_files: true,
            session_dir: String::new(),
            extra_args: Vec::new(),
            extensions: Vec::new(),
            append_system_prompt: String::new(),
        }
    }
}

impl AgentConfig {
    fn trimmed(value: &str) -> Option<String> {
        let trimmed = value.trim();
        if trimmed.is_empty() {
            None
        } else {
            Some(trimmed.to_string())
        }
    }

    /// Build the launch configuration from persisted settings.
    pub fn from_settings(settings: &Settings, vault: &Path) -> Self {
        let ai = &settings.ai;
        AgentConfig {
            command: ai.agent_command.clone(),
            runtime: ai.runtime.clone(),
            vault: vault.to_string_lossy().to_string(),
            provider: ai.provider.clone(),
            model: ai.model.clone(),
            thinking: ai.thinking.clone(),
            api_key: ai.api_key.clone(),
            base_url: ai.base_url.clone(),
            trust_project: ai.trust_project,
            context_files: ai.context_files,
            session_dir: ai.session_dir.clone(),
            extra_args: ai.extra_args.clone(),
            extensions: ai.extensions.clone(),
            append_system_prompt: String::new(),
        }
    }

    /// Merge a caller's overrides over the persisted defaults. Absent fields fall
    /// back to settings, which is how the credential reaches the agent without
    /// ever leaving the core process.
    pub fn resolve(values: &Value, settings: &Settings, vault: Option<&Path>) -> Self {
        let defaults = match vault {
            Some(root) => AgentConfig::from_settings(settings, root),
            None => AgentConfig {
                command: settings.ai.agent_command.clone(),
                ..AgentConfig::default()
            },
        };
        let patch: AgentConfigPatch = serde_json::from_value(values.clone()).unwrap_or_default();

        AgentConfig {
            command: patch.command.unwrap_or(defaults.command),
            runtime: patch.runtime.unwrap_or(defaults.runtime),
            vault: patch.vault.unwrap_or(defaults.vault),
            provider: patch.provider.unwrap_or(defaults.provider),
            model: patch.model.unwrap_or(defaults.model),
            thinking: patch.thinking.unwrap_or(defaults.thinking),
            api_key: patch.api_key.unwrap_or(defaults.api_key),
            base_url: patch.base_url.unwrap_or(defaults.base_url),
            trust_project: patch.trust_project.unwrap_or(defaults.trust_project),
            context_files: patch.context_files.unwrap_or(defaults.context_files),
            session_dir: patch.session_dir.unwrap_or(defaults.session_dir),
            extra_args: patch.extra_args.unwrap_or(defaults.extra_args),
            extensions: patch.extensions.unwrap_or(defaults.extensions),
            append_system_prompt: patch
                .append_system_prompt
                .unwrap_or(defaults.append_system_prompt),
        }
    }

    /// Extra environment the extension needs to find the vault core.
    pub fn child_env(&self, cli: &Path) -> Vec<(String, String)> {
        let mut env = vec![
            ("TASK_LENS_CLI".to_string(), cli.to_string_lossy().to_string()),
            ("NO_COLOR".to_string(), "1".to_string()),
        ];
        if !self.vault.trim().is_empty() {
            env.push(("TASK_LENS_VAULT".to_string(), self.vault.clone()));
        }
        // A custom endpoint is expressed through the provider env var pi reads
        // for OpenAI-compatible hosts.
        if let Some(base) = Self::trimmed(&self.base_url) {
            env.push(("OPENAI_BASE_URL".to_string(), base));
        }
        env
    }

    /// Extension files that actually exist, as absolute paths.
    ///
    /// pi aborts the whole process when an extension fails to load, so a stale
    /// or relative path must never reach it. Relative entries resolve against
    /// the vault (where the agent runs).
    pub fn resolved_extensions(&self) -> Vec<String> {
        let vault = Path::new(self.vault.trim());
        self.extensions
            .iter()
            .filter_map(|raw| {
                let trimmed = raw.trim();
                if trimmed.is_empty() {
                    return None;
                }
                let candidate = Path::new(trimmed);
                let absolute = if candidate.is_absolute() {
                    candidate.to_path_buf()
                } else {
                    vault.join(candidate)
                };
                if absolute.is_file() {
                    Some(absolute.to_string_lossy().to_string())
                } else {
                    None
                }
            })
            .collect()
    }

    /// Extensions the caller asked for but that are not on disk.
    pub fn missing_extensions(&self) -> Vec<String> {
        let vault = Path::new(self.vault.trim());
        self.extensions
            .iter()
            .filter(|raw| !raw.trim().is_empty())
            .filter(|raw| {
                let candidate = Path::new(raw.trim());
                let absolute = if candidate.is_absolute() {
                    candidate.to_path_buf()
                } else {
                    vault.join(candidate)
                };
                !absolute.is_file()
            })
            .map(|raw| raw.trim().to_string())
            .collect()
    }

    /// Everything the embedded host needs, in its own config shape.
    pub fn host_payload(&self) -> Value {
        serde_json::json!({
            "vault": self.vault,
            "provider": self.provider,
            "model": self.model,
            "thinking": self.thinking,
            "apiKey": self.api_key,
            "baseUrl": self.base_url,
            "contextFiles": self.context_files,
            "sessionDir": self.session_dir,
            "extensions": self.resolved_extensions(),
        })
    }

    /// Command line for `pi`, in RPC mode. Pure so it can be asserted directly.
    pub fn build_args(&self) -> Vec<String> {
        let mut args = vec!["--mode".to_string(), "rpc".to_string()];
        let mut push_flag = |flag: &str, value: Option<String>| {
            if let Some(value) = value {
                args.push(flag.to_string());
                args.push(value);
            }
        };

        push_flag("--provider", Self::trimmed(&self.provider));
        push_flag("--model", Self::trimmed(&self.model));
        push_flag("--thinking", Self::trimmed(&self.thinking));
        push_flag("--api-key", Self::trimmed(&self.api_key));
        push_flag("--session-dir", Self::trimmed(&self.session_dir));
        push_flag(
            "--append-system-prompt",
            Self::trimmed(&self.append_system_prompt),
        );

        args.push(if self.trust_project { "--approve" } else { "--no-approve" }.to_string());
        if !self.context_files {
            args.push("--no-context-files".to_string());
        }
        for extension in self.resolved_extensions() {
            args.push("-e".to_string());
            args.push(extension);
        }
        for extra in &self.extra_args {
            let trimmed = extra.trim();
            if !trimmed.is_empty() {
                args.push(trimmed.to_string());
            }
        }
        args
    }
}

/// Partial overrides from a caller: an absent field means "use settings", which
/// is what lets a boolean be overridden to false.
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
struct AgentConfigPatch {
    command: Option<String>,
    runtime: Option<String>,
    vault: Option<String>,
    provider: Option<String>,
    model: Option<String>,
    thinking: Option<String>,
    api_key: Option<String>,
    base_url: Option<String>,
    trust_project: Option<bool>,
    context_files: Option<bool>,
    session_dir: Option<String>,
    extra_args: Option<Vec<String>>,
    extensions: Option<Vec<String>>,
    append_system_prompt: Option<String>,
}

/// How the agent is launched: an external CLI, or the embedded host.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchPlan {
    pub program: PathBuf,
    pub args: Vec<String>,
    pub needs_shell: bool,
    /// True when the embedded Pi host (SDK) is used instead of an external CLI.
    pub embedded: bool,
}

/// Directory of the running executable; the fallback keeps behaviour sane when
/// the path is unavailable.
pub fn exe_dir() -> PathBuf {
    std::env::current_exe()
        .ok()
        .and_then(|path| path.parent().map(Path::to_path_buf))
        .unwrap_or_else(|| PathBuf::from("."))
}

/// The vault CLI that the agent tools call back into.
///
/// The sibling binary wins: in a development build and in a bundled app it sits
/// next to the running executable (the desktop app itself is not the CLI).
pub fn cli_binary(exe_dir: &Path) -> PathBuf {
    let name = if cfg!(windows) { "tasklens-cli.exe" } else { "tasklens-cli" };
    let sibling = exe_dir.join(name);
    if sibling.is_file() {
        return sibling;
    }
    let extensions: &[&str] = if cfg!(windows) { &[".exe", ".cmd", ".bat"] } else { &[""] };
    let path_env = std::env::var("PATH").unwrap_or_default();
    if let Some(installed) = find_in_path("tasklens-cli", &path_env, extensions, &|candidate: &Path| {
        candidate.is_file()
    }) {
        return installed;
    }
    std::env::current_exe().unwrap_or_else(|_| PathBuf::from(name))
}

/// Host entry point inside the app bundle / repository, best first.
///
/// The bundled `host.mjs` is self-contained (no `node_modules` needed); the
/// `.ts` source is the development fallback.
const HOST_ENTRIES: [&str; 2] = ["agent-host/host.mjs", "agent-host/index.ts"];
/// Explicit host override, mainly for development.
const HOST_ENV: &str = "TASK_LENS_AGENT_HOST";

/// Candidate locations for the embedded host, nearest first.
pub fn host_candidates(exe_dir: &Path) -> Vec<PathBuf> {
    let mut candidates = Vec::new();
    if let Ok(explicit) = std::env::var(HOST_ENV) {
        if !explicit.trim().is_empty() {
            candidates.push(PathBuf::from(explicit));
        }
    }
    // Packaged app: bundled resources sit next to the executable.
    for entry in HOST_ENTRIES {
        candidates.push(exe_dir.join("resources").join(entry));
    }
    for entry in HOST_ENTRIES {
        candidates.push(exe_dir.join(entry));
    }
    // Development: walk up from `target/debug` to the repository root.
    let mut directory = exe_dir.to_path_buf();
    for _ in 0..4 {
        if !directory.pop() {
            break;
        }
        for entry in HOST_ENTRIES {
            candidates.push(directory.join(entry));
        }
    }
    candidates
}

fn pick_runtime(requested: &str) -> Result<(&'static str, PathBuf, bool), VaultError> {
    let wanted = requested.trim();
    let candidates: &[&str] = match wanted {
        "" | "auto" => &["bun", "node"],
        "bun" => &["bun"],
        "node" => &["node"],
        other => {
            return Err(VaultError(format!(
                "未知的 JS 运行时「{other}」:只能是 auto / bun / node"
            )))
        }
    };
    for candidate in candidates {
        let (program, needs_shell) = resolve_command(candidate);
        if program.is_file() {
            return Ok((candidate, program, needs_shell));
        }
    }
    Err(VaultError(format!(
        "未找到可用的 JS 运行时(尝试了 {}):内置 Pi 组件需要 bun 或 node",
        candidates.join(" / ")
    )))
}

/// Decide what to spawn: the embedded host by default, or the configured
/// external agent command when one is set.
pub fn launch_plan(config: &AgentConfig) -> Result<LaunchPlan, VaultError> {
    let external = config.command.trim();
    if !external.is_empty() {
        let (program, needs_shell) = resolve_command(external);
        return Ok(LaunchPlan {
            program,
            args: config.build_args(),
            needs_shell,
            embedded: false,
        });
    }

    let exe_dir = exe_dir();
    let host = host_candidates(&exe_dir)
        .into_iter()
        .find(|candidate| candidate.is_file())
        .ok_or_else(|| {
            VaultError(format!(
                "找不到内置 agent 宿主({}):请确认 agent-host/ 随应用一起分发",
                HOST_ENTRIES[0]
            ))
        })?;

    let (runtime, program, needs_shell) = pick_runtime(&config.runtime)?;
    let mut args = Vec::new();
    let host_is_typescript = host
        .extension()
        .is_some_and(|extension| extension.eq_ignore_ascii_case("ts"));
    if runtime == "node" && host_is_typescript {
        // Type stripping for the host and the tool module it imports.
        args.push("--experimental-strip-types".to_string());
        args.push("--no-warnings".to_string());
    }
    args.push(host.to_string_lossy().to_string());

    Ok(LaunchPlan {
        program,
        args,
        needs_shell,
        embedded: true,
    })
}

/// Locate an executable the way a shell would, including Windows extensions.
fn find_in_path(
    name: &str,
    path_env: &str,
    extensions: &[&str],
    is_file: &dyn Fn(&Path) -> bool,
) -> Option<PathBuf> {
    for directory in path_env.split(if cfg!(windows) { ';' } else { ':' }) {
        let directory = directory.trim();
        if directory.is_empty() {
            continue;
        }
        for extension in extensions {
            let candidate = Path::new(directory).join(format!("{name}{extension}"));
            if is_file(&candidate) {
                return Some(candidate);
            }
        }
    }
    None
}

/// Resolve the agent command to something `CreateProcess` accepts.
///
/// Real executables win over Windows shims: a `.cmd` needs `cmd.exe`, which
/// loses the child's stdio for some launchers. Only when no `.exe` exists does
/// the shim get used (and flagged for `cmd.exe`).
pub fn resolve_command(name: &str) -> (PathBuf, bool) {
    let trimmed = name.trim();
    if trimmed.contains('/') || trimmed.contains('\\') {
        return (PathBuf::from(trimmed), false);
    }
    let path_env = std::env::var("PATH").unwrap_or_default();
    let is_file = |candidate: &Path| candidate.is_file();

    if !cfg!(windows) {
        return match find_in_path(trimmed, &path_env, &[""], &is_file) {
            Some(found) => (found, false),
            None => (PathBuf::from(trimmed), false),
        };
    }

    if let Some(executable) = find_in_path(trimmed, &path_env, &[".exe"], &is_file) {
        return (executable, false);
    }
    if let Some(shim) = find_in_path(trimmed, &path_env, &[".cmd", ".bat"], &is_file) {
        return (shim, true);
    }
    (PathBuf::from(trimmed), false)
}

/// A live agent process.
pub struct AgentSession {
    stdin: Mutex<ChildStdin>,
    child: Mutex<Child>,
    pub command: String,
    /// True when the embedded Pi host is running rather than an external CLI.
    pub embedded: bool,
}

/// Wrap a line from the agent so the UI always receives JSON.
fn wrap_line(line: &str, stream: &str) -> String {
    let trimmed = line.trim_end();
    if stream == "stdout" && serde_json::from_str::<Value>(trimmed).is_ok() {
        return trimmed.to_string();
    }
    serde_json::json!({ "type": "tasklens_log", "stream": stream, "text": trimmed }).to_string()
}

impl AgentSession {
    /// Spawn the agent for `config`. `on_line` receives one JSON line per event.
    pub fn start(
        config: &AgentConfig,
        on_line: Arc<dyn Fn(String) + Send + Sync>,
    ) -> Result<AgentSession, VaultError> {
        let plan = launch_plan(config)?;
        let cwd = if config.vault.trim().is_empty() {
            None
        } else {
            Some(PathBuf::from(config.vault.trim()))
        };
        if let Some(directory) = &cwd {
            if !directory.is_dir() {
                return Err(VaultError(format!(
                    "知识库目录不存在: {}",
                    directory.to_string_lossy()
                )));
            }
        }

        let mut command = if plan.needs_shell {
            let mut shell = Command::new("cmd.exe");
            shell.arg("/c").arg(&plan.program);
            shell.args(&plan.args);
            shell
        } else {
            let mut direct = Command::new(&plan.program);
            direct.args(&plan.args);
            direct
        };

        let cli = cli_binary(&exe_dir());
        for (key, value) in config.child_env(&cli) {
            command.env(key, value);
        }
        if let Some(directory) = cwd {
            command.current_dir(directory);
        }
        command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
        #[cfg(windows)]
        {
            use std::os::windows::process::CommandExt;
            const CREATE_NO_WINDOW: u32 = 0x0800_0000;
            if !plan.embedded {
                command.creation_flags(CREATE_NO_WINDOW);
            }
        }

        let mut child = command.spawn().map_err(|problem| {
            VaultError(format!(
                "无法启动 agent({}): {problem}",
                plan.program.to_string_lossy()
            ))
        })?;

        let stdin = child
            .stdin
            .take()
            .ok_or_else(|| VaultError("无法连接 agent 输入".to_string()))?;
        let stdout = child
            .stdout
            .take()
            .ok_or_else(|| VaultError("无法连接 agent 输出".to_string()))?;
        let stderr = child.stderr.take();

        // The embedded host takes its configuration on stdin (an external CLI
        // gets it from the command line instead).
        let mut stdin = stdin;
        if plan.embedded {
            let request = serde_json::json!({ "type": "start", "config": config.host_payload() });
            stdin
                .write_all(request.to_string().as_bytes())
                .and_then(|_| stdin.write_all(b"\n"))
                .and_then(|_| stdin.flush())
                .map_err(|problem| VaultError(format!("无法向 agent 发送配置: {problem}")))?;
        }

        let out_events = Arc::clone(&on_line);
        std::thread::spawn(move || {
            for line in BufReader::new(stdout).lines().map_while(Result::ok) {
                if line.trim().is_empty() {
                    continue;
                }
                out_events(wrap_line(&line, "stdout"));
            }
            // stdout closing means the agent process ended.
            out_events(serde_json::json!({ "type": "tasklens_exit" }).to_string());
        });

        if let Some(stderr) = stderr {
            let err_events = Arc::clone(&on_line);
            std::thread::spawn(move || {
                for line in BufReader::new(stderr).lines().map_while(Result::ok) {
                    if line.trim().is_empty() {
                        continue;
                    }
                    err_events(wrap_line(&line, "stderr"));
                }
            });
        }

        Ok(AgentSession {
            stdin: Mutex::new(stdin),
            child: Mutex::new(child),
            command: plan.program.to_string_lossy().to_string(),
            embedded: plan.embedded,
        })
    }

    /// Forward one protocol line (a command such as `prompt` or `abort`).
    pub fn send_line(&self, line: &str) -> Result<(), VaultError> {
        let mut stdin = self
            .stdin
            .lock()
            .map_err(|_| VaultError("agent 输入不可用".to_string()))?;
        stdin
            .write_all(line.trim_end().as_bytes())
            .and_then(|_| stdin.write_all(b"\n"))
            .and_then(|_| stdin.flush())
            .map_err(|problem| VaultError(format!("发送给 agent 失败: {problem}")))
    }

    pub fn is_running(&self) -> bool {
        self.child
            .lock()
            .map(|mut child| matches!(child.try_wait(), Ok(None)))
            .unwrap_or(false)
    }

    /// Terminate the agent process — and its children.
    ///
    /// On Windows the direct child is `cmd.exe` wrapping the real agent, so
    /// killing only that would leave an agent running (and spending tokens)
    /// after the user pressed stop.
    pub fn stop(&self) {
        if let Ok(mut child) = self.child.lock() {
            let pid = child.id();
            let _ = child.kill();
            let _ = child.wait();
            #[cfg(windows)]
            kill_process_tree(pid);
        }
    }
}

/// `taskkill /T` walks the whole tree; the plain handle kill misses grandchildren.
#[cfg(windows)]
fn kill_process_tree(pid: u32) {
    let mut command = Command::new("taskkill");
    command
        .args(["/PID", &pid.to_string(), "/T", "/F"])
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null());
    {
        use std::os::windows::process::CommandExt;
        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
        command.creation_flags(CREATE_NO_WINDOW);
    }
    let _ = command.status();
}

impl Drop for AgentSession {
    fn drop(&mut self) {
        self.stop();
    }
}

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

    fn config() -> AgentConfig {
        AgentConfig {
            command: "pi".to_string(),
            vault: "/tmp/vault".to_string(),
            provider: "deepseek".to_string(),
            model: "deepseek-chat".to_string(),
            thinking: "medium".to_string(),
            api_key: "sk-test".to_string(),
            trust_project: true,
            context_files: true,
            extensions: Vec::new(),
            extra_args: vec!["--verbose".to_string(), "".to_string()],
            ..AgentConfig::default()
        }
    }

    #[test]
    fn builds_the_rpc_command_line() {
        let directory = tempfile::tempdir().expect("temp dir");
        let extension = directory.path().join("tasklens.ts");
        std::fs::write(&extension, "export default () => {}").expect("write");
        let mut config = config();
        config.extensions = vec![
            extension.to_string_lossy().to_string(),
            "  ".to_string(),
        ];

        let args = config.build_args();
        assert_eq!(args[0], "--mode");
        assert_eq!(args[1], "rpc");
        for expected in [
            ["--provider", "deepseek"],
            ["--model", "deepseek-chat"],
            ["--thinking", "medium"],
            ["--api-key", "sk-test"],
        ] {
            let position = args
                .iter()
                .position(|value| value == expected[0])
                .unwrap_or_else(|| panic!("missing {}", expected[0]));
            assert_eq!(args[position + 1], expected[1]);
        }
        assert!(args.contains(&"--approve".to_string()));
        assert!(!args.contains(&"--no-context-files".to_string()));
        // Blank entries are dropped; real ones are passed as absolute paths.
        assert_eq!(args.iter().filter(|value| *value == "-e").count(), 1);
        assert!(args.contains(&extension.to_string_lossy().to_string()));
        assert!(args.contains(&"--verbose".to_string()));
    }

    #[test]
    fn omits_empty_options_and_forwards_trust_choice() {
        let plain = AgentConfig {
            command: "pi".to_string(),
            trust_project: false,
            context_files: false,
            ..AgentConfig::default()
        };
        let args = plain.build_args();
        assert_eq!(args, vec!["--mode", "rpc", "--no-approve", "--no-context-files"]);
    }

    #[test]
    fn exposes_the_vault_core_to_the_extension() {
        let env = config().child_env(Path::new("C:/app/tasklens-cli.exe"));
        assert!(env
            .iter()
            .any(|(key, value)| key == "TASK_LENS_CLI" && value.ends_with("tasklens-cli.exe")));
        assert!(env
            .iter()
            .any(|(key, value)| key == "TASK_LENS_VAULT" && value == "/tmp/vault"));

        let with_base = AgentConfig {
            base_url: "https://api.example.com/v1".to_string(),
            ..config()
        };
        assert!(with_base
            .child_env(Path::new("cli"))
            .iter()
            .any(|(key, value)| key == "OPENAI_BASE_URL" && value.starts_with("https://")));
    }

    #[test]
    fn prefers_real_executables_over_windows_shims() {
        let both = |candidate: &Path| {
            let name = candidate.to_string_lossy();
            name.ends_with("pi.cmd") || name.ends_with("pi.exe")
        };
        let found = find_in_path("pi", "C:\\a;C:\\bin", &[".exe", ".cmd", ".bat"], &both).expect("found");
        assert!(found.to_string_lossy().ends_with("pi.exe"), "{found:?}");

        // A shim is still usable — it just needs `cmd.exe` to launch it.
        let shim_only = |candidate: &Path| candidate.to_string_lossy().ends_with("pi.cmd");
        let shim = find_in_path("pi", "C:\\bin", &[".exe", ".cmd", ".bat"], &shim_only).expect("shim");
        assert!(shim.to_string_lossy().ends_with("pi.cmd"));

        assert!(find_in_path("nope", "C:\\bin", &[".exe"], &|_| false).is_none());
    }

    #[test]
    fn resolves_overrides_over_persisted_settings() {
        let mut settings = Settings::default();
        settings.ai.provider = "deepseek".to_string();
        settings.ai.model = "deepseek-chat".to_string();
        settings.ai.api_key = "sk-from-settings".to_string();
        settings.ai.extensions = vec!["/opt/vault-tools.ts".to_string()];
        settings.ai.trust_project = false;

        // Callers may send only what they know; everything else comes from settings.
        let resolved = AgentConfig::resolve(
            &serde_json::json!({ "model": "deepseek-reasoner" }),
            &settings,
            Some(Path::new("/tmp/vault")),
        );
        assert_eq!(resolved.model, "deepseek-reasoner");
        assert_eq!(resolved.provider, "deepseek");
        assert_eq!(resolved.api_key, "sk-from-settings");
        assert_eq!(resolved.extensions, vec!["/opt/vault-tools.ts".to_string()]);
        assert!(!resolved.trust_project, "an explicit false must survive");
        assert_eq!(resolved.vault, "/tmp/vault");

        // Booleans can be flipped on as well.
        let flipped = AgentConfig::resolve(
            &serde_json::json!({ "trustProject": true, "contextFiles": false }),
            &settings,
            None,
        );
        assert!(flipped.trust_project);
        assert!(!flipped.context_files);
        // No external command configured: the embedded host runs instead.
        assert_eq!(flipped.command, "");
        assert_eq!(flipped.runtime, "auto");
    }

    #[test]
    fn host_payload_carries_the_configuration_the_host_expects() {
        let config = AgentConfig {
            vault: "/tmp/vault".to_string(),
            provider: "deepseek".to_string(),
            model: "deepseek-chat".to_string(),
            thinking: "high".to_string(),
            api_key: "sk-x".to_string(),
            context_files: false,
            ..AgentConfig::default()
        };
        let payload = config.host_payload();
        assert_eq!(payload["provider"], "deepseek");
        assert_eq!(payload["model"], "deepseek-chat");
        assert_eq!(payload["thinking"], "high");
        assert_eq!(payload["apiKey"], "sk-x");
        assert_eq!(payload["contextFiles"], false);
        assert_eq!(payload["vault"], "/tmp/vault");
        assert!(payload["extensions"].as_array().is_some_and(Vec::is_empty));
    }

    #[test]
    fn an_explicit_command_switches_to_the_external_agent() {
        let config = AgentConfig {
            command: "definitely-not-installed-tool".to_string(),
            vault: "/tmp/vault".to_string(),
            ..AgentConfig::default()
        };
        let plan = launch_plan(&config).expect("plan");
        assert!(!plan.embedded);
        assert!(plan
            .program
            .to_string_lossy()
            .contains("definitely-not-installed-tool"));
        // External CLIs are still driven through the RPC protocol.
        assert_eq!(plan.args.first().map(String::as_str), Some("--mode"));
    }

    #[test]
    fn prefers_the_sibling_cli_binary() {
        let directory = tempfile::tempdir().expect("temp dir");
        let exe_dir = directory.path().join("app");
        std::fs::create_dir_all(&exe_dir).expect("mkdir");
        let name = if cfg!(windows) { "tasklens-cli.exe" } else { "tasklens-cli" };
        std::fs::write(exe_dir.join(name), "// cli").expect("write");
        assert_eq!(cli_binary(&exe_dir), exe_dir.join(name));
    }

    #[test]
    fn finds_the_embedded_host_next_to_or_above_the_executable() {
        let directory = tempfile::tempdir().expect("temp dir");
        let executable = directory.path().join("app").join("tasklens.exe");
        std::fs::create_dir_all(executable.parent().expect("parent")).expect("mkdir");

        // Packaged layout: resources/ beside the executable.
        let packaged = directory.path().join("app").join("resources").join("agent-host");
        std::fs::create_dir_all(&packaged).expect("mkdir");
        std::fs::write(packaged.join("index.ts"), "// host").expect("write");
        assert_eq!(
            host_candidates(&directory.path().join("app"))
                .into_iter()
                .find(|candidate| candidate.is_file()),
            Some(packaged.join("index.ts"))
        );

        // The bundled, self-contained entry point wins over the source.
        std::fs::write(packaged.join("host.mjs"), "// bundled").expect("write");
        assert_eq!(
            host_candidates(&directory.path().join("app"))
                .into_iter()
                .find(|candidate| candidate.is_file()),
            Some(packaged.join("host.mjs"))
        );

        // Development layout: the repository root a few levels up.
        let development = tempfile::tempdir().expect("temp dir");
        let deep = development.path().join("target").join("debug");
        std::fs::create_dir_all(&deep).expect("mkdir");
        let repository_host = development.path().join("agent-host");
        std::fs::create_dir_all(&repository_host).expect("mkdir");
        std::fs::write(repository_host.join("index.ts"), "// host").expect("write");
        assert_eq!(
            host_candidates(&deep).into_iter().find(|candidate| candidate.is_file()),
            Some(repository_host.join("index.ts"))
        );

        assert!(pick_runtime("python").is_err());
    }

    #[test]
    fn resolves_extension_paths_and_reports_missing_ones() {
        let directory = tempfile::tempdir().expect("temp dir");
        let vault = directory.path();
        let present = vault.join("tools.ts");
        std::fs::write(&present, "export default () => {}").expect("write");

        let config = AgentConfig {
            vault: vault.to_string_lossy().to_string(),
            // Relative (resolved against the vault), missing, and blank entries.
            extensions: vec!["tools.ts".to_string(), "gone.ts".to_string(), "  ".to_string()],
            ..AgentConfig::default()
        };

        let resolved = config.resolved_extensions();
        assert_eq!(resolved.len(), 1, "{resolved:?}");
        assert!(resolved[0].ends_with("tools.ts"));
        assert!(Path::new(&resolved[0]).is_absolute());
        assert_eq!(config.missing_extensions(), vec!["gone.ts".to_string()]);

        // Only real files reach pi; the missing one must not kill the agent.
        let args = config.build_args();
        assert_eq!(args.iter().filter(|value| *value == "-e").count(), 1);
        assert!(!args.iter().any(|value| value.contains("gone.ts")));
    }

    #[test]
    fn wraps_non_json_output_so_the_ui_can_always_parse_it() {
        let wrapped = wrap_line("Warning: something", "stderr");
        let parsed: Value = serde_json::from_str(&wrapped).expect("json");
        assert_eq!(parsed["type"], "tasklens_log");
        assert_eq!(parsed["stream"], "stderr");
        assert_eq!(parsed["text"], "Warning: something");

        // Real protocol lines pass through untouched.
        let passthrough = wrap_line("{\"type\":\"message_end\"}", "stdout");
        assert_eq!(passthrough, "{\"type\":\"message_end\"}");
    }
}