//! Auto-detect launch behavior for the `herdr` command.
//!
//! When the user runs `herdr` with no subcommand:
//! 1. Check if a server is already listening on the client socket
//! 2. If no server → spawn one as a background daemon → wait for socket readiness (up to 15s)
//! 3. Attach as a thin client to the server
//!
//! The `--no-session` flag bypasses server/client entirely and runs monolithically
//! (escape hatch for users who want the traditional single-process behavior).

use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;

use tracing::info;

use super::socket_paths::client_socket_path;

/// Maximum time to wait for the server's client socket to become ready
/// after spawning the server process.
const SERVER_READY_TIMEOUT: Duration = Duration::from_secs(15);

/// Poll interval when waiting for the server socket to appear.
const SOCKET_POLL_INTERVAL: Duration = Duration::from_millis(50);

/// Timeout for checking the stable JSON API before attaching to the binary protocol socket.
const STATUS_REQUEST_TIMEOUT: Duration = Duration::from_secs(2);

/// Private daemon-start hint used to seed a fresh headless server from the
/// directory where the user ran `herdr`.
pub(crate) const STARTUP_CWD_ENV_VAR: &str = "HERDR_STARTUP_CWD";

// ---------------------------------------------------------------------------
// Server detection
// ---------------------------------------------------------------------------

/// Checks whether a herdr server is currently listening on the client socket.
///
/// This works by attempting to connect to the client socket. If the connection
/// succeeds, a server is running. If the socket file doesn't exist or the
/// connection is refused, no server is running. Stale sockets (from a crashed
/// server) are detected because connect returns `ConnectionRefused`
/// when nobody is listening.
#[allow(dead_code)] // Public API for external use and testing
pub fn is_server_listening() -> bool {
    is_server_listening_at(&client_socket_path())
}

/// Checks whether a herdr server is listening at a specific socket path.
fn is_server_listening_at(socket_path: &Path) -> bool {
    #[cfg(windows)]
    {
        let _ = socket_path;
        read_server_status().ok().flatten().is_some()
    }

    #[cfg(not(windows))]
    {
        if !socket_path.exists() {
            return false;
        }

        match crate::ipc::connect_local_stream(socket_path) {
            Ok(_) => {
                // Server is listening. Close the test connection immediately.
                // The server's handshake handler will time out on this connection
                // since we don't send Hello, which is fine.
                true
            }
            Err(err)
                if matches!(
                    err.kind(),
                    io::ErrorKind::ConnectionRefused | io::ErrorKind::TimedOut
                ) =>
            {
                // Socket file exists but nobody is listening — stale socket.
                false
            }
            Err(err) if err.kind() == io::ErrorKind::NotFound => {
                // Socket file disappeared between exists() and connect().
                false
            }
            Err(err) => {
                // Other errors (permission denied, etc.) — assume not listening.
                tracing::warn!(err = %err, "unexpected error checking server socket");
                false
            }
        }
    }
}

fn read_server_status() -> io::Result<Option<crate::api::RuntimeStatus>> {
    crate::api::read_runtime_status_at(&crate::api::socket_path(), STATUS_REQUEST_TIMEOUT)
}

#[cfg(windows)]
fn client_protocol_accepts_hello(socket_path: &Path) -> io::Result<bool> {
    if !socket_path.exists() {
        return Ok(false);
    }

    let mut stream = match crate::ipc::connect_local_stream(socket_path) {
        Ok(stream) => stream,
        Err(err)
            if matches!(
                err.kind(),
                io::ErrorKind::ConnectionRefused
                    | io::ErrorKind::NotFound
                    | io::ErrorKind::TimedOut
                    | io::ErrorKind::WouldBlock
            ) =>
        {
            return Ok(false);
        }
        Err(err) => return Err(err),
    };

    let hello = crate::protocol::ClientMessage::Hello {
        version: crate::protocol::PROTOCOL_VERSION,
        cols: 80,
        rows: 24,
        cell_width_px: 0,
        cell_height_px: 0,
        requested_encoding: crate::protocol::RenderEncoding::SemanticFrame,
        keybindings: crate::protocol::ClientKeybindings::Server,
        launch_mode: crate::protocol::ClientLaunchMode::App,
    };

    match crate::protocol::write_message(&mut stream, &hello) {
        Ok(()) => Ok(true),
        Err(crate::protocol::FramingError::Io(err))
            if matches!(
                err.kind(),
                io::ErrorKind::ConnectionRefused
                    | io::ErrorKind::NotFound
                    | io::ErrorKind::TimedOut
                    | io::ErrorKind::WouldBlock
                    | io::ErrorKind::BrokenPipe
                    | io::ErrorKind::ConnectionReset
            ) =>
        {
            Ok(false)
        }
        Err(err) => Err(io::Error::other(err.to_string())),
    }
}

fn validate_running_server_compatibility() -> io::Result<()> {
    let Some(status) = read_server_status()? else {
        return Err(io::Error::other(format!(
            "a herdr server is listening, but its status API is unavailable.\n\n{}\nIf that fails, stop the old server process manually.",
            crate::session::active_restart_after_update_guidance()
        )));
    };

    if status.protocol == Some(crate::protocol::PROTOCOL_VERSION) {
        return Ok(());
    }

    Err(io::Error::other(format!(
        "Herdr was updated, but this session is still running the old server.\n\nserver: v{} protocol {}\nclient: v{} protocol {}\n\n{}",
        status.version.as_deref().unwrap_or("unknown"),
        status
            .protocol
            .map(|value| value.to_string())
            .unwrap_or_else(|| "unknown".to_string()),
        crate::build_info::version(),
        crate::protocol::PROTOCOL_VERSION,
        crate::session::active_restart_after_update_guidance()
    )))
}

// ---------------------------------------------------------------------------
// Server spawning
// ---------------------------------------------------------------------------

/// Spawns the herdr server as a background daemon process.
///
/// The server process is fully detached:
/// - Runs in its own session (setsid) so it survives the client exiting
/// - Stdin/stdout/stderr are redirected to /dev/null
/// - Inherits relevant environment variables (`XDG_CONFIG_HOME`, `HERDR_SESSION`,
///   socket overrides, etc.), except inherited socket overrides are cleared when
///   this CLI invocation explicitly selected a session.
///
/// Returns the PID of the spawned server process.
pub fn spawn_server_daemon() -> io::Result<u32> {
    let exe = std::env::current_exe().map_err(|err| {
        io::Error::new(
            err.kind(),
            format!("failed to determine herdr executable path: {err}"),
        )
    })?;

    info!(exe = %exe.display(), "spawning server daemon");

    let mut command = build_server_daemon_command(exe);

    let pid =
        crate::platform::launch_server_daemon_command(&mut command).map_err(|err: io::Error| {
            io::Error::new(err.kind(), format!("failed to spawn herdr server: {err}"))
        })?;
    info!(pid, "server daemon spawned");

    Ok(pid)
}

fn build_server_daemon_command(exe: PathBuf) -> Command {
    let mut command = Command::new(&exe);
    command
        .arg("server")
        // Redirect stdio to /dev/null
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null());
    crate::platform::detach_server_daemon_command(&mut command);

    match std::env::current_dir() {
        Ok(cwd) => {
            command.env(STARTUP_CWD_ENV_VAR, cwd);
        }
        Err(_) => {
            command.env_remove(STARTUP_CWD_ENV_VAR);
        }
    }

    if crate::session::explicit_session_requested() {
        command
            .env_remove(crate::api::SOCKET_PATH_ENV_VAR)
            .env_remove("HERDR_CLIENT_SOCKET_PATH");
    }

    command
}

// ---------------------------------------------------------------------------
// Socket readiness
// ---------------------------------------------------------------------------

/// Waits for the server's client socket to become ready for connections.
///
/// Polls the socket path at regular intervals until a connection succeeds
/// or the timeout elapses. Returns an error if the server doesn't become
/// ready within the timeout.
pub fn wait_for_server_socket(socket_path: &Path, timeout: Duration) -> io::Result<()> {
    let deadline = std::time::Instant::now() + timeout;

    while std::time::Instant::now() < deadline {
        #[cfg(windows)]
        if client_protocol_accepts_hello(socket_path)? {
            info!(path = %socket_path.display(), "server client protocol ready");
            return Ok(());
        }

        #[cfg(not(windows))]
        if is_server_listening_at(socket_path) {
            info!(path = %socket_path.display(), "server socket ready");
            return Ok(());
        }
        std::thread::sleep(SOCKET_POLL_INTERVAL);
    }

    Err(io::Error::new(
        io::ErrorKind::TimedOut,
        format!(
            "server did not become ready within {}s (socket: {}). The background server may still be starting; try `herdr` again, or check {}",
            timeout.as_secs(),
            socket_path.display(),
            crate::session::data_dir().join("herdr-server.log").display()
        ),
    ))
}

// ---------------------------------------------------------------------------
// Auto-detect launch
// ---------------------------------------------------------------------------

/// Performs auto-detect launch: check for server, spawn if needed, then
/// attach as a thin client.
///
/// This is the entry point called from `main.rs` when the user runs `herdr`
/// without `--no-session` and without a subcommand.
///
/// Flow:
/// 1. Check if a server is listening on the client socket
/// 2. If no server → spawn server daemon → wait for socket readiness
/// 3. Run the thin client (which connects to the server)
pub fn auto_detect_launch() -> io::Result<()> {
    let socket_path = client_socket_path();
    info!(path = %socket_path.display(), "auto-detect launch starting");

    if is_server_listening_at(&socket_path) {
        validate_running_server_compatibility()?;
        info!("server already running, attaching as client");
    } else {
        info!("no server running, spawning server daemon");
        spawn_server_daemon()?;
        wait_for_server_socket(&socket_path, SERVER_READY_TIMEOUT)?;
        info!("server ready, attaching as client");
    }

    // Now attach as a thin client.
    crate::client::run_client()
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(all(test, unix))]
mod tests {
    use super::*;
    use std::ffi::OsStr;
    use std::io::{BufRead, BufReader, Write};
    use std::os::unix::net::UnixListener;
    use std::sync::{Mutex, OnceLock};

    fn env_lock() -> &'static Mutex<()> {
        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
        LOCK.get_or_init(|| Mutex::new(()))
    }

    fn unique_test_dir(name: &str) -> std::path::PathBuf {
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        std::path::PathBuf::from(format!("/tmp/ha-{name}-{}-{nanos}", std::process::id()))
    }

    #[test]
    fn is_server_listening_returns_false_for_nonexistent_path() {
        let dir = unique_test_dir("nonexistent");
        let path = dir.join("s.sock");
        assert!(!is_server_listening_at(&path));
    }

    #[test]
    fn server_daemon_command_clears_socket_overrides_for_explicit_session() {
        let _guard = env_lock().lock().unwrap();
        std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, "/tmp/inherited.sock");
        std::env::set_var("HERDR_CLIENT_SOCKET_PATH", "/tmp/inherited-client.sock");
        std::env::remove_var(crate::session::SESSION_ENV_VAR);
        crate::session::clear_explicit_session_for_test();
        let args = vec![
            "herdr".to_string(),
            "--session".to_string(),
            "work".to_string(),
        ];
        crate::session::configure_from_args(&args).unwrap();

        let command = build_server_daemon_command(PathBuf::from("/tmp/herdr-test"));
        let envs: Vec<_> = command.get_envs().collect();

        assert!(envs.iter().any(|(key, value)| {
            *key == OsStr::new(crate::api::SOCKET_PATH_ENV_VAR) && value.is_none()
        }));
        assert!(envs.iter().any(|(key, value)| {
            *key == OsStr::new("HERDR_CLIENT_SOCKET_PATH") && value.is_none()
        }));
        std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
        std::env::remove_var("HERDR_CLIENT_SOCKET_PATH");
        std::env::remove_var(crate::session::SESSION_ENV_VAR);
        crate::session::clear_explicit_session_for_test();
    }

    #[test]
    fn server_daemon_command_passes_current_dir_as_startup_cwd() {
        let expected = std::env::current_dir().unwrap();
        let command = build_server_daemon_command(PathBuf::from("/tmp/herdr-test"));
        let envs: Vec<_> = command.get_envs().collect();

        assert!(envs.iter().any(|(key, value)| {
            *key == OsStr::new(STARTUP_CWD_ENV_VAR) && value == &Some(expected.as_os_str())
        }));
    }

    #[cfg(target_os = "linux")]
    #[test]
    fn server_daemon_detach_creates_new_session() {
        let mut command = Command::new("sh");
        command.arg("-c").arg(
            r#"sid=$(ps -o sid= -p $$ | tr -d ' ')
test "$sid" = "$$"
"#,
        );
        crate::platform::detach_server_daemon_command(&mut command);

        let status = command.status().unwrap();
        assert!(
            status.success(),
            "detached server child should be its own session leader"
        );
    }

    #[test]
    fn is_server_listening_returns_true_for_live_socket() {
        let dir = unique_test_dir("live");
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("s.sock");

        let _listener = UnixListener::bind(&path).unwrap();
        assert!(is_server_listening_at(&path));
        let _ = std::fs::remove_dir_all(dir);
    }

    #[test]
    fn is_server_listening_returns_false_for_stale_socket() {
        let dir = unique_test_dir("stale");
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("s.sock");

        // Create a socket and immediately drop the listener.
        // This leaves a stale socket file with nobody listening.
        {
            let _listener = UnixListener::bind(&path).unwrap();
        }

        // The socket file exists but nobody is listening.
        assert!(!is_server_listening_at(&path));
        let _ = std::fs::remove_dir_all(dir);
    }

    #[test]
    fn is_server_listening_returns_false_when_listener_dropped() {
        let dir = unique_test_dir("dropped");
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("s.sock");

        // Bind and immediately drop the listener.
        drop(UnixListener::bind(&path).unwrap());

        // Socket is stale — should return false.
        assert!(!is_server_listening_at(&path));

        let _ = std::fs::remove_dir_all(dir);
    }

    #[test]
    fn wait_for_server_socket_succeeds_immediately() {
        let dir = unique_test_dir("wait-ok");
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("s.sock");

        let _listener = UnixListener::bind(&path).unwrap();

        // Should succeed immediately (socket is already ready).
        let result = wait_for_server_socket(&path, Duration::from_millis(100));
        assert!(result.is_ok());
        let _ = std::fs::remove_dir_all(dir);
    }

    #[test]
    fn wait_for_server_socket_times_out() {
        let dir = unique_test_dir("wait-timeout");
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("s.sock");

        // No listener — should time out.
        let result = wait_for_server_socket(&path, Duration::from_millis(50));
        assert!(result.is_err());
        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::TimedOut);
        let _ = std::fs::remove_dir_all(dir);
    }

    #[test]
    fn wait_for_server_socket_succeeds_after_delay() {
        let dir = unique_test_dir("wait-delay");
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("s.sock");

        // Spawn a thread that will create the listener after a short delay.
        let path_clone = path.clone();
        std::thread::spawn(move || {
            std::thread::sleep(Duration::from_millis(50));
            let _listener = UnixListener::bind(&path_clone).unwrap();
            // Keep the listener alive for a bit.
            std::thread::sleep(Duration::from_secs(1));
        });

        // Wait with a generous timeout — should succeed.
        let result = wait_for_server_socket(&path, Duration::from_secs(2));
        assert!(result.is_ok());
        let _ = std::fs::remove_dir_all(dir);
    }

    #[test]
    fn read_server_status_at_reads_ping_response() {
        let dir = unique_test_dir("status");
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("api.sock");
        let listener = UnixListener::bind(&path).unwrap();
        let handle = std::thread::spawn(move || {
            let (mut stream, _) = listener.accept().unwrap();
            let mut request = String::new();
            BufReader::new(stream.try_clone().unwrap())
                .read_line(&mut request)
                .unwrap();
            assert!(request.contains("ping"));
            stream
                .write_all(
                    b"{\"id\":\"autodetect:server:status\",\"result\":{\"type\":\"pong\",\"version\":\"0.5.5\",\"protocol\":2}}\n",
                )
                .unwrap();
            stream.flush().unwrap();
        });

        let status = crate::api::read_runtime_status_at(&path, Duration::from_millis(200))
            .unwrap()
            .unwrap();
        let _ = handle.join();
        assert_eq!(status.version.as_deref(), Some("0.5.5"));
        assert_eq!(status.protocol, Some(2));
        let _ = std::fs::remove_dir_all(dir);
    }

    #[test]
    fn validate_running_server_compatibility_fails_when_status_api_missing() {
        let _guard = env_lock().lock().unwrap();
        let dir = unique_test_dir("missing-api");
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("api.sock");
        std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, &path);

        let err = validate_running_server_compatibility().unwrap_err();

        assert!(
            err.to_string().contains("status API is unavailable"),
            "unexpected error: {err}"
        );
        std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
        let _ = std::fs::remove_dir_all(dir);
    }

    #[test]
    fn validate_running_server_compatibility_names_session_commands_for_protocol_mismatch() {
        let _guard = env_lock().lock().unwrap();
        let dir = unique_test_dir("named-protocol");
        std::env::set_var("XDG_CONFIG_HOME", &dir);
        std::env::set_var(crate::session::SESSION_ENV_VAR, "work");
        std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
        crate::session::clear_explicit_session_for_test();
        let path = crate::session::api_socket_path_for(Some("work"));
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        let listener = UnixListener::bind(&path).unwrap();
        let handle = std::thread::spawn(move || {
            let (mut stream, _) = listener.accept().unwrap();
            let mut request = String::new();
            BufReader::new(stream.try_clone().unwrap())
                .read_line(&mut request)
                .unwrap();
            assert!(request.contains("ping"));
            let body = format!(
                "{{\"id\":\"autodetect:server:status\",\"result\":{{\"type\":\"pong\",\"version\":\"0.5.5\",\"protocol\":{}}}}}\n",
                crate::protocol::PROTOCOL_VERSION + 1
            );
            stream.write_all(body.as_bytes()).unwrap();
            stream.flush().unwrap();
        });

        let err = validate_running_server_compatibility().unwrap_err();
        let message = err.to_string();

        let _ = handle.join();
        assert!(
            message.contains("Stop the old server to use the new version"),
            "unexpected error: {message}"
        );
        assert!(
            message.contains("Run `herdr session stop work`"),
            "unexpected error: {message}"
        );
        assert!(
            message.contains("then run `herdr session attach work` again"),
            "unexpected error: {message}"
        );
        std::env::remove_var("XDG_CONFIG_HOME");
        std::env::remove_var(crate::session::SESSION_ENV_VAR);
        std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
        crate::session::clear_explicit_session_for_test();
        let _ = std::fs::remove_dir_all(dir);
    }
}
