//! Agent protocol — shared types used by both the daemon (tsafe-agent)
//! and the client (tsafe-cli `open_vault_via_agent`).
//!
//! # Security model
//!
//! * The agent holds the vault **password** in memory, zeroized on drop.
//! * Access requires the session token plus a `requesting_pid` claim that matches
//!   the real peer PID on the IPC transport.
//! * The daemon enforces the TTL chosen at `tsafe agent unlock`; expired
//!   sessions reject new requests and clear their socket state on exit.
//! * The named pipe is `\\.\pipe\tsafe-agent-{agent_pid}`.
//! * The `TSAFE_AGENT_SOCK` env var carries `{pipe_name}::{session_token_hex}`.
//! * A state file (`agent.sock` in the data dir) persists the sock address so
//!   processes that do not inherit `TSAFE_AGENT_SOCK` (e.g. VS Code
//!   background terminals) can still reach a running agent.
//!
//! # Wire protocol
//!
//! Request and response are newline-terminated JSON objects written over the
//! named pipe.  One request → one response per connection.

use std::path::PathBuf;
use std::time::{Duration, Instant};

use serde::{Deserialize, Serialize};

// ── Agent socket env vars ─────────────────────────────────────────────────────

/// Set by `tsafe agent unlock` after the user approves.
/// Format: `{pipe_name}::{session_token_hex}`
pub const ENV_AGENT_SOCK: &str = "TSAFE_AGENT_SOCK";

/// Path for the CellOS broker Unix socket.  Overridden by `TSAFE_SOCKET`.
pub const ENV_CELLOS_SOCK: &str = "TSAFE_SOCKET";

/// Resolve the CellOS broker socket path: `$TSAFE_SOCKET` or `~/.tsafe/agent.sock`.
pub fn cellos_socket_path() -> std::path::PathBuf {
    if let Ok(p) = std::env::var(ENV_CELLOS_SOCK) {
        return std::path::PathBuf::from(p);
    }
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
    std::path::PathBuf::from(home)
        .join(".tsafe")
        .join("agent.sock")
}

// ── CellOS per-cell state ─────────────────────────────────────────────────────

/// Immutable record established on the first `Resolve` for a cell.
#[derive(Debug, Clone)]
pub struct CellRecord {
    /// PID that registered this cell (SO_PEERCRED on first Resolve).
    pub pid: u32,
    /// 32-byte hex random token set at supervisor spawn time.
    pub token: String,
}

/// Lifecycle state of a tracked cell in the daemon's in-memory cache.
#[derive(Debug, Clone)]
pub enum CellState {
    Active(CellRecord),
    Revoked,
}

// ── CellOS wire messages ──────────────────────────────────────────────────────

/// Requests sent by the CellOS broker over `TSAFE_SOCKET`.
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "op")]
pub enum CellosRequest {
    /// Resolve a secret value for a cell.
    ///
    /// `cell_token` is a 32-byte hex random nonce generated at supervisor spawn.
    /// The daemon validates token + PID against the first-registration record to
    /// mitigate PID reuse between cell death and a new process reaching the socket.
    Resolve {
        key: String,
        cell_id: String,
        ttl_seconds: u64,
        cell_token: String,
    },
    /// Purge cached material for a cell on TTL expiry or explicit destroy.
    RevokeForCell { cell_id: String },
}

/// Responses from the daemon over `TSAFE_SOCKET`.
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "status")]
pub enum CellosResponse {
    /// Successful resolution — plaintext secret value.
    Value { value: String },
    /// Successful acknowledgment (used by RevokeForCell).
    Ok,
    /// Typed error — `error` describes the rejection reason.
    Err { error: String },
}

pub fn read_agent_sock_env() -> Option<String> {
    std::env::var(ENV_AGENT_SOCK).ok()
}

fn agent_sock_env_explicit() -> bool {
    std::env::var(ENV_AGENT_SOCK).is_ok()
}

// ── Wire messages ─────────────────────────────────────────────────────────────

#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "op")]
pub enum AgentRequest {
    /// Open the vault for `profile` and return its encrypted file bytes.
    /// The agent re-reads the vault file from disk on every call.
    /// `requesting_pid` must match the real PID of the connecting process.
    OpenVault {
        profile: String,
        session_token: String, // hex-encoded 32 bytes
        requesting_pid: u32,
    },
    /// Destroy the session token immediately.
    Lock { session_token: String },
    /// Heartbeat — returns `AgentResponse::Ok` with no data.
    Ping,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "status")]
pub enum AgentResponse {
    /// The vault password for the requested profile, as UTF-8 bytes.
    /// The CLI uses this to call `Vault::open` locally.
    Password {
        password: String, // NOT b64 — just the plaintext password string
    },
    Ok,
    Err {
        reason: String,
    },
}

// ── Session lifecycle ────────────────────────────────────────────────────────

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentSessionState {
    Active,
    Locked,
    Expired,
}

#[derive(Debug)]
pub struct AgentSession {
    session_token: String,
    /// Sliding idle deadline — reset to `now + idle_secs` on each `OpenVault` request,
    /// but never extended past `absolute_deadline`.
    idle_deadline: Instant,
    /// Hard cap — never refreshed regardless of activity.
    absolute_deadline: Instant,
    /// Idle window in seconds, used to recompute `idle_deadline` on activity.
    idle_secs: u64,
    state: AgentSessionState,
}

#[derive(Debug)]
pub struct AgentSessionOutcome {
    pub response: AgentResponse,
    pub stop: bool,
    pub state: AgentSessionState,
}

impl AgentSession {
    /// Create a new session with separate idle and absolute TTLs.
    ///
    /// `idle_secs` — sliding window reset on every `OpenVault` request.
    /// `absolute_deadline` — hard wall-clock cap, never extended.
    pub fn new(
        session_token: impl Into<String>,
        idle_secs: u64,
        absolute_deadline: Instant,
    ) -> Self {
        let idle_deadline =
            (Instant::now() + Duration::from_secs(idle_secs)).min(absolute_deadline);
        Self {
            session_token: session_token.into(),
            idle_deadline,
            absolute_deadline,
            idle_secs,
            state: AgentSessionState::Active,
        }
    }

    pub fn state(&self, now: Instant) -> AgentSessionState {
        match self.state {
            AgentSessionState::Active
                if now >= self.idle_deadline || now >= self.absolute_deadline =>
            {
                AgentSessionState::Expired
            }
            state => state,
        }
    }

    pub fn handle_request(
        &mut self,
        req: &AgentRequest,
        peer_pid: Option<u32>,
        password: &str,
        now: Instant,
    ) -> AgentSessionOutcome {
        if let AgentRequest::Lock { session_token } = req {
            if crate::crypto::ct_eq_str(session_token, &self.session_token) {
                self.state = AgentSessionState::Locked;
                return AgentSessionOutcome {
                    response: AgentResponse::Ok,
                    stop: true,
                    state: self.state,
                };
            }
            return self.deny("invalid session token", false, now);
        }

        let expiry_reason = self.sync_expiry(now);
        match self.state {
            AgentSessionState::Expired => {
                let reason = expiry_reason.unwrap_or("agent session expired");
                self.deny(reason, true, now)
            }
            AgentSessionState::Locked => self.deny("agent session locked", true, now),
            AgentSessionState::Active => match req {
                AgentRequest::Ping => AgentSessionOutcome {
                    response: AgentResponse::Ok,
                    stop: false,
                    state: self.state,
                },
                AgentRequest::OpenVault {
                    profile: _,
                    session_token,
                    requesting_pid,
                } => {
                    if !crate::crypto::ct_eq_str(session_token, &self.session_token) {
                        self.deny("invalid session token", false, now)
                    } else if peer_pid != Some(*requesting_pid) {
                        self.deny(
                            "requesting PID does not match the connecting process",
                            false,
                            now,
                        )
                    } else {
                        // Refresh idle deadline — capped at absolute deadline.
                        self.idle_deadline =
                            (now + Duration::from_secs(self.idle_secs)).min(self.absolute_deadline);
                        AgentSessionOutcome {
                            response: AgentResponse::Password {
                                password: password.to_string(),
                            },
                            stop: false,
                            state: self.state,
                        }
                    }
                }
                AgentRequest::Lock { .. } => unreachable!("lock handled above"),
            },
        }
    }

    /// Transitions to `Expired` if either deadline has passed.
    /// Returns the expiry reason to use in the `Err` response, or `None` if still active.
    fn sync_expiry(&mut self, now: Instant) -> Option<&'static str> {
        if matches!(self.state, AgentSessionState::Active) {
            if now >= self.absolute_deadline {
                self.state = AgentSessionState::Expired;
                return Some("agent session expired (absolute timeout)");
            }
            if now >= self.idle_deadline {
                self.state = AgentSessionState::Expired;
                return Some("agent session expired (idle timeout)");
            }
        }
        None
    }

    fn deny(&mut self, reason: &str, stop: bool, now: Instant) -> AgentSessionOutcome {
        self.sync_expiry(now);
        AgentSessionOutcome {
            response: AgentResponse::Err {
                reason: reason.to_string(),
            },
            stop,
            state: self.state(now),
        }
    }
}

// ── Agent state file ─────────────────────────────────────────────────────────

/// Path to the agent socket state file.
/// Written by the daemon on startup; deleted on exit.
/// Allows processes that don't inherit `TSAFE_AGENT_SOCK` to find a running agent.
pub fn agent_sock_path() -> PathBuf {
    crate::profile::app_state_dir().join("agent.sock")
}

/// Write the agent socket address to the state file so other processes can find it.
/// File is restricted to owner-only on Unix (it contains the session token).
pub fn write_agent_sock(sock_val: &str) {
    let path = agent_sock_path();
    if let Some(parent) = path.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    // Write atomically so a hard kill mid-write cannot leave a corrupt state file.
    let tmp = path.with_extension("sock.tmp");
    if std::fs::write(&tmp, sock_val).is_ok() {
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
        }
        let _ = std::fs::rename(&tmp, &path);
    }
}

/// Read the agent socket address from the state file. Returns `None` if missing.
pub fn read_agent_sock() -> Option<String> {
    std::fs::read_to_string(agent_sock_path())
        .ok()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
}

/// Delete the agent state file (called on daemon exit).
pub fn clear_agent_sock() {
    let _ = std::fs::remove_file(agent_sock_path());
}
// ── Pipe / socket naming ─────────────────────────────────────────────────────

/// Build the IPC address for this agent process.
///
/// - **Windows:** named pipe `\\.\pipe\tsafe-agent-{pid}`
/// - **macOS/Linux:** Unix domain socket in a user-private temp directory
#[cfg(target_os = "windows")]
pub fn pipe_name(agent_pid: u32) -> String {
    format!(r"\\.\pipe\tsafe-agent-{agent_pid}")
}

#[cfg(not(target_os = "windows"))]
pub fn pipe_name(agent_pid: u32) -> String {
    let candidate_dirs = [
        std::env::var("XDG_RUNTIME_DIR").ok(),
        std::env::var("TMPDIR").ok(),
    ];
    pipe_name_for_dirs(agent_pid, candidate_dirs)
}

#[cfg(not(target_os = "windows"))]
fn pipe_name_for_dirs(agent_pid: u32, candidate_dirs: [Option<String>; 2]) -> String {
    use std::os::unix::ffi::OsStrExt;

    const MAX_UNIX_SOCKET_PATH_BYTES: usize = 100;

    let filename = format!("tsafe-agent-{agent_pid}.sock");

    for dir in candidate_dirs
        .into_iter()
        .flatten()
        .filter(|d| !d.is_empty())
    {
        let candidate = std::path::Path::new(&dir).join(&filename);
        if candidate.as_os_str().as_bytes().len() <= MAX_UNIX_SOCKET_PATH_BYTES {
            return candidate.to_string_lossy().into_owned();
        }
    }

    let fallback_dir = short_agent_runtime_dir();
    std::path::Path::new(&fallback_dir)
        .join(filename)
        .to_string_lossy()
        .into_owned()
}

#[cfg(not(target_os = "windows"))]
fn short_agent_runtime_dir() -> String {
    // SAFETY: getuid() is a leaf POSIX libc call that takes no arguments and
    // cannot fail; it reads process credentials without dereferencing pointers.
    let uid = unsafe { libc::getuid() };
    let dir = std::path::PathBuf::from(format!("/tmp/tsafe-agent-{uid}"));
    if std::fs::create_dir_all(&dir).is_ok() {
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
        }
        return dir.to_string_lossy().into_owned();
    }
    "/tmp".to_string()
}

/// Parse `TSAFE_AGENT_SOCK` into `(pipe_name, session_token_hex)`.
pub fn parse_agent_sock(sock: &str) -> Option<(String, String)> {
    // Format: "{pipe_name}::{token_hex}"  (double colon to avoid clash with UNC paths)
    let idx = sock.rfind("::")?;
    let pipe = sock[..idx].to_string();
    let token = sock[idx + 2..].to_string();
    if pipe.is_empty() || token.is_empty() {
        return None;
    }
    Some((pipe, token))
}

/// Build the value to put in `TSAFE_AGENT_SOCK`.
pub fn format_agent_sock(pipe: &str, token_hex: &str) -> String {
    format!("{pipe}::{token_hex}")
}

// ── Client-side helper ────────────────────────────────────────────────────────

/// Try to retrieve the vault password from a running tsafe-agent.
///
/// Returns `Some(password)` if a valid agent is reachable and grants access.
/// Returns `None` if neither `TSAFE_AGENT_SOCK` nor the state file point to a running agent
/// (caller falls back to interactive prompt).
/// Returns `Err` if the env var is set but the call fails (hard error — do not
/// silently fall back to a password prompt when the user explicitly configured
/// an agent session).
#[cfg(target_os = "windows")]
pub fn request_password_from_agent(profile: &str) -> crate::errors::SafeResult<Option<String>> {
    use crate::errors::SafeError;
    use std::io::{BufRead, BufReader, Write};

    // Prefer the env var; fall back to the persisted state file.
    let sock = match read_agent_sock_env().or_else(read_agent_sock) {
        Some(s) => s,
        None => return Ok(None),
    };
    let env_var_was_set = agent_sock_env_explicit();
    let (pipe, token) = match parse_agent_sock(&sock) {
        Some(v) => v,
        None => {
            // Malformed state file — wipe it and fall through to prompt.
            if !env_var_was_set {
                clear_agent_sock();
            }
            return Err(SafeError::InvalidVault {
                reason: "malformed TSAFE_AGENT_SOCK: expected '{pipe}::{token_hex}'".into(),
            });
        }
    };
    let requesting_pid = std::process::id();

    let req = AgentRequest::OpenVault {
        profile: profile.to_string(),
        session_token: token,
        requesting_pid,
    };
    let req_json = serde_json::to_string(&req).map_err(|e| SafeError::Crypto {
        context: e.to_string(),
    })?;

    let mut stream = match connect_pipe_client(&pipe) {
        Ok(s) => s,
        Err(_) if !env_var_was_set => {
            // Agent exited and left a stale state file — clean up and fall back to prompt.
            clear_agent_sock();
            return Ok(None);
        }
        Err(e) => return Err(e),
    };
    // NOTE: Windows named-pipe File handles do not support read timeouts via the
    // standard Rust API. A full fix would require SetCommTimeouts FFI. For now the
    // write is bounded by the OS pipe-full back-pressure (small JSON payload).
    writeln!(stream, "{req_json}").map_err(|e| SafeError::InvalidVault {
        reason: format!("agent write: {e}"),
    })?;

    let mut resp_line = String::new();
    BufReader::new(&stream)
        .read_line(&mut resp_line)
        .map_err(|e| SafeError::InvalidVault {
            reason: format!("agent read: {e}"),
        })?;

    let resp: AgentResponse =
        serde_json::from_str(resp_line.trim()).map_err(|e| SafeError::InvalidVault {
            reason: format!("agent bad response: {e}"),
        })?;

    match resp {
        AgentResponse::Password { password } => Ok(Some(password)),
        AgentResponse::Err { reason } => Err(SafeError::InvalidVault {
            reason: format!("agent denied: {reason}"),
        }),
        _ => Err(SafeError::InvalidVault {
            reason: "unexpected agent response".into(),
        }),
    }
}

// ── Unix domain socket client ────────────────────────────────────────────────

#[cfg(not(target_os = "windows"))]
pub fn request_password_from_agent(profile: &str) -> crate::errors::SafeResult<Option<String>> {
    use crate::errors::SafeError;

    // Prefer the env var; fall back to the persisted state file.
    let sock = match read_agent_sock_env().or_else(read_agent_sock) {
        Some(s) => s,
        None => return Ok(None),
    };
    let env_var_was_set = agent_sock_env_explicit();
    let (pipe, token) = match parse_agent_sock(&sock) {
        Some(v) => v,
        None => {
            if !env_var_was_set {
                clear_agent_sock();
            }
            return Err(SafeError::InvalidVault {
                reason: "malformed TSAFE_AGENT_SOCK: expected '{pipe}::{token_hex}'".into(),
            });
        }
    };
    let requesting_pid = std::process::id();

    let req = AgentRequest::OpenVault {
        profile: profile.to_string(),
        session_token: token,
        requesting_pid,
    };

    let resp = match agent_rpc_unix(&pipe, &req) {
        Ok(r) => r,
        Err(_) if !env_var_was_set => {
            // Stale state file — agent exited. Clean up and fall back to prompt.
            clear_agent_sock();
            return Ok(None);
        }
        Err(e) => return Err(e),
    };

    match resp {
        AgentResponse::Password { password } => Ok(Some(password)),
        AgentResponse::Err { reason } => Err(SafeError::InvalidVault {
            reason: format!("agent denied: {reason}"),
        }),
        _ => Err(SafeError::InvalidVault {
            reason: "unexpected agent response".into(),
        }),
    }
}

#[cfg(not(target_os = "windows"))]
fn agent_rpc_unix(pipe: &str, req: &AgentRequest) -> crate::errors::SafeResult<AgentResponse> {
    use crate::errors::SafeError;
    use std::io::{BufRead, BufReader, Write};
    use std::os::unix::net::UnixStream;

    let mut stream = UnixStream::connect(pipe).map_err(|e| SafeError::InvalidVault {
        reason: format!("could not connect to agent socket '{pipe}': {e}"),
    })?;
    stream
        .set_read_timeout(Some(Duration::from_secs(5)))
        .map_err(|e| SafeError::InvalidVault {
            reason: format!("agent set_read_timeout: {e}"),
        })?;
    let req_json = serde_json::to_string(req).map_err(|e| SafeError::Crypto {
        context: e.to_string(),
    })?;
    writeln!(stream, "{req_json}").map_err(|e| SafeError::InvalidVault {
        reason: format!("agent write: {e}"),
    })?;

    let mut resp_line = String::new();
    BufReader::new(&stream)
        .read_line(&mut resp_line)
        .map_err(|e| SafeError::InvalidVault {
            reason: format!("agent read: {e}"),
        })?;

    serde_json::from_str(resp_line.trim()).map_err(|e| SafeError::InvalidVault {
        reason: format!("agent bad response: {e}"),
    })
}

// ── Windows named-pipe client ─────────────────────────────────────────────────

#[cfg(target_os = "windows")]
fn connect_pipe_client(pipe: &str) -> crate::errors::SafeResult<std::fs::File> {
    use crate::errors::SafeError;
    use std::os::windows::ffi::OsStrExt;

    let wide: Vec<u16> = std::ffi::OsStr::new(pipe)
        .encode_wide()
        .chain(std::iter::once(0))
        .collect();

    extern "system" {
        fn CreateFileW(
            name: *const u16,
            access: u32,
            share: u32,
            security: *mut std::ffi::c_void,
            creation: u32,
            flags: u32,
            template: *mut std::ffi::c_void,
        ) -> *mut std::ffi::c_void;
    }

    // SAFETY: `wide` is a null-terminated UTF-16 buffer owned by this stack frame
    // and lives for the duration of the CreateFileW call (the Vec is not moved or
    // dropped before `wide.as_ptr()` is read). The remaining args are well-defined
    // Windows constants (GENERIC_READ|GENERIC_WRITE access, no sharing, no security
    // descriptor, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, no template handle).
    // CreateFileW does not retain any of these pointers past the call.
    let handle = unsafe {
        // GENERIC_READ | GENERIC_WRITE = 0xC0000000, OPEN_EXISTING = 3, FILE_ATTRIBUTE_NORMAL = 128
        CreateFileW(
            wide.as_ptr(),
            0xC000_0000,
            0,
            std::ptr::null_mut(),
            3,
            128,
            std::ptr::null_mut(),
        )
    };

    if handle.is_null() || handle as isize == -1 {
        return Err(SafeError::InvalidVault {
            reason: format!("could not connect to agent pipe '{pipe}' — is the agent running?"),
        });
    }

    // SAFETY: `handle` is a freshly-opened Windows HANDLE returned by CreateFileW
    // above. The null/INVALID_HANDLE_VALUE check immediately preceding this block
    // ensures it is a valid, owning handle that is not currently owned by any
    // other File or RAII wrapper. Transferring ownership into std::fs::File is the
    // sound usage pattern for FromRawHandle — File's Drop will call CloseHandle.
    Ok(unsafe {
        <std::fs::File as std::os::windows::io::FromRawHandle>::from_raw_handle(handle as _)
    })
}

// ── Lock helper ───────────────────────────────────────────────────────────────

/// Send a Lock request to the agent, causing it to exit immediately.
/// No-op if no agent socket env is set.
#[cfg(target_os = "windows")]
pub fn send_lock() -> crate::errors::SafeResult<()> {
    use crate::errors::SafeError;
    use std::io::Write;

    let sock = match read_agent_sock_env().or_else(read_agent_sock) {
        Some(s) => s,
        None => return Ok(()),
    };
    let env_var_was_set = agent_sock_env_explicit();
    let (pipe, token) = parse_agent_sock(&sock).ok_or_else(|| SafeError::InvalidVault {
        reason: "malformed TSAFE_AGENT_SOCK".into(),
    })?;
    let req = AgentRequest::Lock {
        session_token: token,
    };
    let req_json = serde_json::to_string(&req).map_err(|e| SafeError::Crypto {
        context: e.to_string(),
    })?;
    let mut stream = match connect_pipe_client(&pipe) {
        Ok(s) => s,
        Err(_) if !env_var_was_set => {
            clear_agent_sock();
            return Ok(());
        }
        Err(e) => return Err(e),
    };
    let _ = writeln!(stream, "{req_json}");
    Ok(())
}

#[cfg(not(target_os = "windows"))]
pub fn send_lock() -> crate::errors::SafeResult<()> {
    use crate::errors::SafeError;

    let sock = match read_agent_sock_env().or_else(read_agent_sock) {
        Some(s) => s,
        None => return Ok(()),
    };
    let env_var_was_set = agent_sock_env_explicit();
    let (pipe, token) = parse_agent_sock(&sock).ok_or_else(|| SafeError::InvalidVault {
        reason: "malformed TSAFE_AGENT_SOCK".into(),
    })?;
    let req = AgentRequest::Lock {
        session_token: token,
    };
    match agent_rpc_unix(&pipe, &req) {
        Ok(_) => {}
        Err(_) if !env_var_was_set => {
            clear_agent_sock();
        }
        Err(e) => return Err(e),
    }
    Ok(())
}

// ── Ping helper ───────────────────────────────────────────────────────────────

/// Send a Ping to the agent at `sock_val` and return `Ok(true)` if it responds `Ok`.
/// Returns `Ok(false)` on an unexpected response, `Err` on I/O failure.
#[cfg(target_os = "windows")]
pub fn ping_agent(sock_val: &str) -> crate::errors::SafeResult<bool> {
    use crate::errors::SafeError;
    use std::io::{BufRead, BufReader, Write};

    let (pipe, _token) = parse_agent_sock(sock_val).ok_or_else(|| SafeError::InvalidVault {
        reason: "malformed agent socket value".into(),
    })?;
    let req_json = serde_json::to_string(&AgentRequest::Ping).map_err(|e| SafeError::Crypto {
        context: e.to_string(),
    })?;
    let mut stream = connect_pipe_client(&pipe)?;
    writeln!(stream, "{req_json}").map_err(|e| SafeError::InvalidVault {
        reason: format!("ping write: {e}"),
    })?;
    let mut line = String::new();
    BufReader::new(&stream)
        .read_line(&mut line)
        .map_err(|e| SafeError::InvalidVault {
            reason: format!("ping read: {e}"),
        })?;
    let resp: AgentResponse =
        serde_json::from_str(line.trim()).map_err(|e| SafeError::InvalidVault {
            reason: format!("ping bad response: {e}"),
        })?;
    Ok(matches!(resp, AgentResponse::Ok))
}

#[cfg(not(target_os = "windows"))]
pub fn ping_agent(sock_val: &str) -> crate::errors::SafeResult<bool> {
    use crate::errors::SafeError;

    let (pipe, _token) = parse_agent_sock(sock_val).ok_or_else(|| SafeError::InvalidVault {
        reason: "malformed agent socket value".into(),
    })?;
    let resp = agent_rpc_unix(&pipe, &AgentRequest::Ping)?;
    Ok(matches!(resp, AgentResponse::Ok))
}

// ── Tests ────────────────────────────────────────────────────────────────────

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

    #[test]
    fn parse_agent_sock_valid() {
        let (pipe, token) = parse_agent_sock(r"\\.\pipe\tsafe-agent-1234::abcdef").unwrap();
        assert_eq!(pipe, r"\\.\pipe\tsafe-agent-1234");
        assert_eq!(token, "abcdef");
    }

    #[test]
    fn parse_agent_sock_unix_path() {
        let (pipe, token) = parse_agent_sock("/tmp/tsafe-agent-5678.sock::deadbeef").unwrap();
        assert_eq!(pipe, "/tmp/tsafe-agent-5678.sock");
        assert_eq!(token, "deadbeef");
    }

    #[test]
    fn parse_agent_sock_malformed() {
        assert!(parse_agent_sock("no-separator").is_none());
        assert!(parse_agent_sock("::token_only").is_none());
        assert!(parse_agent_sock("pipe_only::").is_none());
    }

    #[test]
    fn format_then_parse_roundtrips() {
        let sock = format_agent_sock("/tmp/test.sock", "abc123");
        let (pipe, token) = parse_agent_sock(&sock).unwrap();
        assert_eq!(pipe, "/tmp/test.sock");
        assert_eq!(token, "abc123");
    }

    #[test]
    fn pipe_name_contains_pid() {
        let name = pipe_name(9999);
        assert!(name.contains("9999"), "pipe_name should contain the PID");
    }

    #[cfg(not(target_os = "windows"))]
    #[test]
    fn pipe_name_avoids_overlong_unix_socket_path() {
        let overlong = format!("/tmp/{}", "x".repeat(120));
        let name = pipe_name_for_dirs(12345, [Some(overlong.clone()), Some(overlong.clone())]);

        assert!(name.ends_with("tsafe-agent-12345.sock"));
        assert!(
            name.len() <= 100,
            "socket path should fit conservative Unix limit: {name}"
        );
        assert!(
            !name.starts_with(&overlong),
            "overlong runtime dirs must not be used"
        );
    }

    #[test]
    fn session_allows_matching_open_vault_request() {
        let now = Instant::now();
        let mut session = AgentSession::new("token-123", 60, now + Duration::from_secs(60));
        let outcome = session.handle_request(
            &AgentRequest::OpenVault {
                profile: "default".into(),
                session_token: "token-123".into(),
                requesting_pid: 4242,
            },
            Some(4242),
            "secret",
            now,
        );

        assert!(!outcome.stop);
        assert_eq!(outcome.state, AgentSessionState::Active);
        match outcome.response {
            AgentResponse::Password { password } => assert_eq!(password, "secret"),
            other => panic!("expected password response, got {other:?}"),
        }
    }

    #[test]
    fn session_rejects_pid_mismatch_without_stopping() {
        let now = Instant::now();
        let mut session = AgentSession::new("token-123", 60, now + Duration::from_secs(60));
        let outcome = session.handle_request(
            &AgentRequest::OpenVault {
                profile: "default".into(),
                session_token: "token-123".into(),
                requesting_pid: 4242,
            },
            Some(9001),
            "secret",
            now,
        );

        assert!(!outcome.stop);
        assert_eq!(outcome.state, AgentSessionState::Active);
        match outcome.response {
            AgentResponse::Err { reason } => {
                assert!(reason.contains("does not match the connecting process"));
            }
            other => panic!("expected authorization error, got {other:?}"),
        }
    }

    #[test]
    fn session_expires_and_stops_on_non_lock_requests() {
        let now = Instant::now();
        let mut session = AgentSession::new("token-123", 60, now - Duration::from_secs(1));
        let outcome = session.handle_request(&AgentRequest::Ping, Some(4242), "secret", now);

        assert!(outcome.stop);
        assert_eq!(outcome.state, AgentSessionState::Expired);
        match outcome.response {
            AgentResponse::Err { reason } => {
                assert_eq!(reason, "agent session expired (absolute timeout)")
            }
            other => panic!("expected expiry error, got {other:?}"),
        }
    }

    #[test]
    fn session_lock_transitions_to_locked_and_rejects_follow_up_requests() {
        let now = Instant::now();
        let mut session = AgentSession::new("token-123", 60, now + Duration::from_secs(60));
        let lock_outcome = session.handle_request(
            &AgentRequest::Lock {
                session_token: "token-123".into(),
            },
            Some(4242),
            "secret",
            now,
        );

        assert!(lock_outcome.stop);
        assert_eq!(lock_outcome.state, AgentSessionState::Locked);
        assert!(matches!(lock_outcome.response, AgentResponse::Ok));

        let ping_outcome = session.handle_request(&AgentRequest::Ping, Some(4242), "secret", now);
        assert!(ping_outcome.stop);
        assert_eq!(ping_outcome.state, AgentSessionState::Locked);
        match ping_outcome.response {
            AgentResponse::Err { reason } => assert_eq!(reason, "agent session locked"),
            other => panic!("expected locked-session error, got {other:?}"),
        }
    }

    #[test]
    fn session_rejects_invalid_lock_token_without_locking() {
        let now = Instant::now();
        let mut session = AgentSession::new("token-123", 60, now + Duration::from_secs(60));

        let bad_lock = session.handle_request(
            &AgentRequest::Lock {
                session_token: "wrong-token".into(),
            },
            Some(4242),
            "secret",
            now,
        );

        assert!(!bad_lock.stop);
        assert_eq!(bad_lock.state, AgentSessionState::Active);
        match bad_lock.response {
            AgentResponse::Err { reason } => assert_eq!(reason, "invalid session token"),
            other => panic!("expected invalid-token error, got {other:?}"),
        }

        let follow_up = session.handle_request(
            &AgentRequest::OpenVault {
                profile: "default".into(),
                session_token: "token-123".into(),
                requesting_pid: 4242,
            },
            Some(4242),
            "secret",
            now,
        );

        assert!(!follow_up.stop);
        assert_eq!(follow_up.state, AgentSessionState::Active);
        match follow_up.response {
            AgentResponse::Password { password } => assert_eq!(password, "secret"),
            other => panic!("expected password response after invalid lock, got {other:?}"),
        }
    }

    #[test]
    fn invalid_open_token_does_not_refresh_idle_deadline() {
        let now = Instant::now();
        let absolute = now + Duration::from_secs(3600);
        let mut session = AgentSession::new("token-123", 10, absolute);
        let before_idle = session.idle_deadline;
        let later = now + Duration::from_secs(5);

        let outcome = session.handle_request(
            &AgentRequest::OpenVault {
                profile: "default".into(),
                session_token: "wrong-token".into(),
                requesting_pid: 1,
            },
            Some(1),
            "secret",
            later,
        );

        assert!(!outcome.stop);
        assert_eq!(outcome.state, AgentSessionState::Active);
        match outcome.response {
            AgentResponse::Err { reason } => assert_eq!(reason, "invalid session token"),
            other => panic!("expected invalid-token error, got {other:?}"),
        }
        assert_eq!(
            session.idle_deadline, before_idle,
            "denied requests must not extend the idle window"
        );
    }

    #[test]
    fn locked_session_rejects_open_vault_even_with_valid_credentials() {
        let now = Instant::now();
        let mut session = AgentSession::new("token-123", 60, now + Duration::from_secs(60));
        let _ = session.handle_request(
            &AgentRequest::Lock {
                session_token: "token-123".into(),
            },
            Some(4242),
            "secret",
            now,
        );

        let outcome = session.handle_request(
            &AgentRequest::OpenVault {
                profile: "default".into(),
                session_token: "token-123".into(),
                requesting_pid: 4242,
            },
            Some(4242),
            "secret",
            now,
        );

        assert!(outcome.stop);
        assert_eq!(outcome.state, AgentSessionState::Locked);
        match outcome.response {
            AgentResponse::Err { reason } => assert_eq!(reason, "agent session locked"),
            other => panic!("expected locked-session error, got {other:?}"),
        }
    }

    #[test]
    fn expired_session_rejects_open_vault_without_returning_password() {
        let now = Instant::now();
        let mut session = AgentSession::new("token-123", 60, now - Duration::from_secs(1));

        let outcome = session.handle_request(
            &AgentRequest::OpenVault {
                profile: "default".into(),
                session_token: "token-123".into(),
                requesting_pid: 4242,
            },
            Some(4242),
            "secret",
            now,
        );

        assert!(outcome.stop);
        assert_eq!(outcome.state, AgentSessionState::Expired);
        match outcome.response {
            AgentResponse::Err { reason } => {
                assert_eq!(reason, "agent session expired (absolute timeout)")
            }
            other => panic!("expected expiry error, got {other:?}"),
        }
    }

    // ── H2: idle / absolute TTL tests ─────────────────────────────────────────

    #[test]
    fn open_vault_refreshes_idle_deadline() {
        let now = Instant::now();
        let absolute = now + Duration::from_secs(3600);
        let mut session = AgentSession::new("token-123", 10, absolute);
        let before_idle = session.idle_deadline;

        // Simulate time advancing; an OpenVault should push idle_deadline forward.
        let later = now + Duration::from_secs(8);
        session.handle_request(
            &AgentRequest::OpenVault {
                profile: "default".into(),
                session_token: "token-123".into(),
                requesting_pid: 1,
            },
            Some(1),
            "pw",
            later,
        );

        assert!(
            session.idle_deadline > before_idle,
            "idle_deadline should have advanced after OpenVault"
        );
    }

    #[test]
    fn idle_refresh_is_capped_at_absolute_deadline() {
        let now = Instant::now();
        // absolute = now + 5s; idle_secs = 100 (would push past absolute if uncapped)
        let absolute = now + Duration::from_secs(5);
        let mut session = AgentSession::new("token-123", 100, absolute);

        session.handle_request(
            &AgentRequest::OpenVault {
                profile: "default".into(),
                session_token: "token-123".into(),
                requesting_pid: 1,
            },
            Some(1),
            "pw",
            now,
        );

        assert_eq!(
            session.idle_deadline, session.absolute_deadline,
            "idle_deadline must not exceed absolute_deadline"
        );
    }

    #[test]
    fn idle_timeout_produces_idle_timeout_reason() {
        let now = Instant::now();
        // absolute is in the future; force idle_deadline into the past
        let absolute = now + Duration::from_secs(3600);
        let mut session = AgentSession::new("token-123", 3600, absolute);
        session.idle_deadline = now - Duration::from_secs(1);

        let outcome = session.handle_request(&AgentRequest::Ping, Some(1), "pw", now);
        assert!(outcome.stop);
        match outcome.response {
            AgentResponse::Err { reason } => assert!(
                reason.contains("idle timeout"),
                "expected idle timeout in reason, got: {reason}"
            ),
            other => panic!("expected Err, got {other:?}"),
        }
    }

    #[test]
    fn absolute_timeout_produces_absolute_timeout_reason() {
        let now = Instant::now();
        // absolute already expired; idle would be in the future (but capped)
        let absolute = now - Duration::from_secs(1);
        let mut session = AgentSession::new("token-123", 3600, absolute);

        let outcome = session.handle_request(&AgentRequest::Ping, Some(1), "pw", now);
        assert!(outcome.stop);
        match outcome.response {
            AgentResponse::Err { reason } => assert!(
                reason.contains("absolute timeout"),
                "expected absolute timeout in reason, got: {reason}"
            ),
            other => panic!("expected Err, got {other:?}"),
        }
    }

    /// Unix-only: start a mock agent on a socket, send Ping, verify Ok response.
    #[cfg(unix)]
    #[test]
    fn unix_socket_ping_roundtrip() {
        use std::io::{BufRead, BufReader, Write};
        use std::os::unix::net::UnixListener;

        let dir = tempfile::tempdir().unwrap();
        let sock_path = dir.path().join("test-agent.sock");
        let sock_str = sock_path.to_str().unwrap().to_string();

        let listener = UnixListener::bind(&sock_path).unwrap();

        // Spawn a mock agent that responds Ok to any request.
        let handle = std::thread::spawn(move || {
            let (stream, _) = listener.accept().unwrap();
            let mut reader = BufReader::new(&stream);
            let mut line = String::new();
            reader.read_line(&mut line).unwrap();
            // Parse request and respond Ok.
            let _req: AgentRequest = serde_json::from_str(line.trim()).unwrap();
            let resp = AgentResponse::Ok;
            let mut writer = &stream;
            writeln!(writer, "{}", serde_json::to_string(&resp).unwrap()).unwrap();
        });

        // Send Ping via the client helper.
        let resp = agent_rpc_unix(&sock_str, &AgentRequest::Ping).unwrap();
        assert!(matches!(resp, AgentResponse::Ok));

        handle.join().unwrap();
    }
}
