//! Port and PID file management for the HTTP sidecar.
//!
//! All files live under `.symforge/` in the current working directory.
//! The hook binary reads `sidecar.port` to locate the running sidecar.

use std::io::{self, Write};
use std::net::TcpStream;
use std::path::PathBuf;
use std::time::Duration;

pub const DIR_NAME: &str = crate::paths::SYMFORGE_DIR_NAME;
const PORT_FILE: &str = "sidecar.port";
const PID_FILE: &str = "sidecar.pid";
const SESSION_FILE: &str = "sidecar.session";

/// Ensure the current working directory has a usable `.symforge/` runtime directory.
pub fn ensure_symforge_dir() -> io::Result<PathBuf> {
    let cwd = std::env::current_dir()?;
    crate::paths::ensure_symforge_dir(&cwd)
}

/// Write the sidecar port to `.symforge/sidecar.port`.
///
/// The file contains ONLY the port number as ASCII digits, no trailing newline.
/// This is the convention the hook binary relies on.
pub fn write_port_file(port: u16) -> io::Result<()> {
    let dir = ensure_symforge_dir()?;
    let path = dir.join(PORT_FILE);
    let mut file = std::fs::File::create(&path)?;
    write!(file, "{port}")?;
    Ok(())
}

/// Write the sidecar PID to `.symforge/sidecar.pid`.
///
/// The file contains ONLY the PID as ASCII digits, no trailing newline.
pub fn write_pid_file(pid: u32) -> io::Result<()> {
    let dir = ensure_symforge_dir()?;
    let path = dir.join(PID_FILE);
    let mut file = std::fs::File::create(&path)?;
    write!(file, "{pid}")?;
    Ok(())
}

/// Write the daemon/session proxy identifier to `.symforge/sidecar.session`.
pub fn write_session_file(session_id: &str) -> io::Result<()> {
    let dir = ensure_symforge_dir()?;
    let path = dir.join(SESSION_FILE);
    let mut file = std::fs::File::create(&path)?;
    write!(file, "{session_id}")?;
    Ok(())
}

/// Remove only the daemon/session proxy file, preserving any live local sidecar port/pid files.
pub fn cleanup_session_file() {
    let dir = PathBuf::from(DIR_NAME);
    let _ = std::fs::remove_file(dir.join(SESSION_FILE));
}

/// Read and parse the port from `.symforge/sidecar.port`.
///
/// Returns an error if the file doesn't exist or contains invalid data.
pub fn read_port() -> io::Result<u16> {
    let path = PathBuf::from(DIR_NAME).join(PORT_FILE);
    let contents = std::fs::read_to_string(&path)?;
    contents
        .trim()
        .parse::<u16>()
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}

/// Remove both port and PID files. Ignores all errors.
///
/// Called during sidecar shutdown — it is safe to call even if files don't exist.
pub fn cleanup_files() {
    let dir = PathBuf::from(DIR_NAME);
    let _ = std::fs::remove_file(dir.join(PORT_FILE));
    let _ = std::fs::remove_file(dir.join(PID_FILE));
    let _ = std::fs::remove_file(dir.join(SESSION_FILE));
}

/// Remove port/PID/session files from a specific directory.
/// Used by the panic hook which cannot rely on CWD.
pub fn cleanup_files_at(dir: &std::path::Path) {
    let _ = std::fs::remove_file(dir.join(PORT_FILE));
    let _ = std::fs::remove_file(dir.join(PID_FILE));
    let _ = std::fs::remove_file(dir.join(SESSION_FILE));
}

/// Check whether the port/PID files are stale (i.e., the old sidecar is no longer running).
///
/// If no port file exists, there is nothing stale — returns `false`.
/// If a port file exists, attempts a blocking TCP connect to `{bind_host}:{port}` with a
/// 200 ms timeout. If the connection succeeds the sidecar is alive and returns `false`.
/// If the connection is refused or times out, the files are stale: calls `cleanup_files()`
/// and returns `true`.
pub fn check_stale(bind_host: &str) -> bool {
    let port = match read_port() {
        Ok(p) => p,
        Err(_) => return false, // No port file — nothing to clean up.
    };

    // Bracket IPv6 addresses for correct SocketAddr parsing (e.g. "[::1]:8080").
    let addr = if bind_host.contains(':') {
        format!("[{bind_host}]:{port}")
    } else {
        format!("{bind_host}:{port}")
    };
    let sock_addr = match addr.parse() {
        Ok(a) => a,
        Err(_) => {
            // Cannot determine staleness when the address is unparseable —
            // default to "not stale" to avoid deleting a live sidecar's files.
            return false;
        }
    };
    match TcpStream::connect_timeout(&sock_addr, Duration::from_millis(200)) {
        Ok(_) => false, // Connection succeeded — sidecar is still alive.
        Err(_) => {
            // Connection refused or timed out — files are stale.
            cleanup_files();
            true
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;
    use tempfile::TempDir;

    /// Serialize all cwd-manipulating tests so they don't interfere with each other.
    /// cwd is process-global — parallel manipulation causes flaky failures.
    static CWD_LOCK: Mutex<()> = Mutex::new(());

    fn stable_cwd() -> PathBuf {
        std::env::current_dir().unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR")))
    }

    fn restore_cwd(path: &std::path::Path) {
        if std::env::set_current_dir(path).is_err() {
            std::env::set_current_dir(env!("CARGO_MANIFEST_DIR"))
                .expect("manifest dir must be a valid cwd fallback");
        }
    }

    /// Run a test with cwd set to a temp directory so file operations are isolated.
    /// Holds `CWD_LOCK` for the duration, restores cwd on exit (even on panic).
    fn with_temp_dir<F: FnOnce() + std::panic::UnwindSafe>(f: F) {
        let _guard = CWD_LOCK
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner());
        let tmp = TempDir::new().unwrap();
        let original = stable_cwd();
        std::env::set_current_dir(tmp.path()).unwrap();
        let result = std::panic::catch_unwind(f);
        restore_cwd(&original);
        drop(tmp);
        if let Err(e) = result {
            std::panic::resume_unwind(e);
        }
    }

    #[test]
    fn test_write_read_port_roundtrip() {
        with_temp_dir(|| {
            write_port_file(12345).expect("write_port_file should succeed");
            let port = read_port().expect("read_port should succeed after write");
            assert_eq!(port, 12345, "port roundtrip must preserve value");
        });
    }

    #[test]
    fn test_write_port_file_no_trailing_newline() {
        with_temp_dir(|| {
            write_port_file(8080).expect("write_port_file should succeed");
            // Read while still inside the temp cwd so the relative path resolves correctly.
            let port_path = PathBuf::from(DIR_NAME).join(PORT_FILE);
            let bytes = std::fs::read(&port_path).unwrap();
            assert_eq!(
                bytes, b"8080",
                "port file must contain ONLY the digits, no newline"
            );
        });
    }

    #[test]
    fn test_cleanup_removes_files() {
        with_temp_dir(|| {
            write_port_file(9000).expect("write should succeed");
            write_pid_file(12345).expect("write should succeed");

            let dir = PathBuf::from(DIR_NAME);
            assert!(
                dir.join(PORT_FILE).exists(),
                "port file should exist before cleanup"
            );
            assert!(
                dir.join(PID_FILE).exists(),
                "pid file should exist before cleanup"
            );

            cleanup_files();

            assert!(
                !dir.join(PORT_FILE).exists(),
                "port file should be gone after cleanup"
            );
            assert!(
                !dir.join(PID_FILE).exists(),
                "pid file should be gone after cleanup"
            );
        });
    }

    #[test]
    fn test_cleanup_is_noop_when_no_files() {
        with_temp_dir(|| {
            // Should not panic even if files don't exist.
            cleanup_files();
        });
    }

    #[test]
    fn test_read_port_missing_returns_error() {
        with_temp_dir(|| {
            let result = read_port();
            assert!(
                result.is_err(),
                "read_port should return error when file is missing"
            );
        });
    }

    #[test]
    fn test_ensure_symforge_dir_creates_directory() {
        with_temp_dir(|| {
            let dir = ensure_symforge_dir().expect("ensure_symforge_dir should succeed");
            assert!(
                dir.exists(),
                ".symforge directory should exist after ensure_symforge_dir"
            );
            assert!(dir.is_dir(), "path should be a directory");
        });
    }

    #[test]
    fn test_ensure_symforge_dir_idempotent() {
        with_temp_dir(|| {
            ensure_symforge_dir().expect("first call should succeed");
            ensure_symforge_dir().expect("second call should also succeed (idempotent)");
        });
    }
    #[test]
    fn test_check_stale_returns_false_when_no_port_file() {
        with_temp_dir(|| {
            let is_stale = check_stale("127.0.0.1");
            assert!(!is_stale, "no port file means nothing is stale");
        });
    }

    #[test]
    fn test_check_stale_cleans_up_when_port_is_closed() {
        with_temp_dir(|| {
            // Write a port that is very unlikely to have anything listening.
            write_port_file(19999).expect("write should succeed");
            write_pid_file(99999).expect("write should succeed");

            let is_stale = check_stale("127.0.0.1");
            assert!(
                is_stale,
                "port 19999 should be detected as stale (nothing listening)"
            );

            // Cleanup should have been called.
            let dir = PathBuf::from(DIR_NAME);
            assert!(
                !dir.join(PORT_FILE).exists(),
                "port file cleaned up after stale detection"
            );
        });
    }
}
