//! Picloud subprocess lifecycle for the CLI integration test binary.
//!
//! Mirrors what the original seed test did inline, lifted out so it can
//! be shared across modules via `common::fixture()`. The Fixture lives
//! in a `LazyLock` — and `LazyLock<T>` never drops, so we register an
//! `atexit` handler that SIGTERMs the child when the test binary exits
//! normally (which is the common case under `cargo test`).
//!
//! `PR_SET_PDEATHSIG` is intentionally *not* used: it fires when the
//! creating thread dies, and cargo runs each `#[test]` on its own
//! worker thread that exits as soon as the test returns — which would
//! kill picloud after the first test that triggered the LazyLock,
//! breaking every test after it.
//!
//! Abnormal exit (SIGKILL of the test binary) leaks the child; the
//! dashboard Playwright suite accepts the same tradeoff.

use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::process::{Child, Command as StdCommand, Stdio};
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};

use serde_json::Value;

pub fn pick_free_port() -> u16 {
    let listener =
        std::net::TcpListener::bind("127.0.0.1:0").expect("bind 127.0.0.1:0 to pick port");
    listener.local_addr().expect("local addr").port()
}

pub fn picloud_binary_path() -> PathBuf {
    // The integration test binary lives at
    // `<target>/debug/deps/cli-<hash>`. Walk up two levels to reach
    // `<target>/debug` and look for `picloud` next to ourselves.
    let exe = std::env::current_exe().expect("current_exe");
    let debug_dir = exe
        .parent()
        .and_then(|p| p.parent())
        .expect("test binary should live under target/debug/deps");
    debug_dir.join(if cfg!(windows) {
        "picloud.exe"
    } else {
        "picloud"
    })
}

pub fn spawn_picloud(database_url: &str, port: u16, admin_user: &str, admin_pass: &str) -> Child {
    let binary = picloud_binary_path();
    assert!(
        binary.exists(),
        "expected picloud binary at {}. Run `cargo build -p picloud` first \
         (or use `cargo test --workspace -- --include-ignored` which builds it)",
        binary.display()
    );
    let mut child = StdCommand::new(&binary)
        .env("PICLOUD_BIND", format!("127.0.0.1:{port}"))
        .env("DATABASE_URL", database_url)
        .env("PICLOUD_ADMIN_USERNAME", admin_user)
        .env("PICLOUD_ADMIN_PASSWORD", admin_pass)
        .env("RUST_LOG", "warn")
        .stdout(Stdio::null())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn picloud");

    // Drain stderr in a side thread so the pipe buffer doesn't fill and
    // block the server.
    if let Some(err) = child.stderr.take().map(BufReader::new) {
        let (tx, _rx) = mpsc::channel::<String>();
        thread::spawn(move || {
            for line in err.lines().map_while(Result::ok) {
                let _ = tx.send(line);
            }
        });
    }

    register_atexit_killer(child.id());
    child
}

// --------------------------------------------------------------------
// atexit-based child cleanup
// --------------------------------------------------------------------

static PICLOUD_PID: AtomicI32 = AtomicI32::new(0);

fn register_atexit_killer(pid: u32) {
    // First spawn wins; subsequent spawns (none expected today) would
    // overwrite, but the previous child would already be tracked via
    // its Drop path on the Fixture.
    PICLOUD_PID.store(pid as i32, Ordering::SeqCst);

    #[cfg(unix)]
    {
        use std::sync::Once;
        static REGISTERED: Once = Once::new();
        REGISTERED.call_once(|| {
            // SAFETY: atexit's contract is a `extern "C" fn()` callback;
            // ours signals a child PID we own.
            unsafe {
                libc::atexit(kill_picloud_at_exit);
            }
        });
    }
}

#[cfg(unix)]
extern "C" fn kill_picloud_at_exit() {
    let pid = PICLOUD_PID.swap(0, Ordering::SeqCst);
    if pid > 0 {
        // SAFETY: SIGTERM to a PID we recorded; if PID has been reused
        // we're killing the wrong process — accepted risk for a test
        // helper.
        unsafe {
            libc::kill(pid, libc::SIGTERM);
        }
    }
}

pub fn wait_for_health(url: &str, timeout: Duration) -> Result<(), String> {
    let deadline = Instant::now() + timeout;
    let client = reqwest::blocking::Client::builder()
        .timeout(Duration::from_secs(2))
        .build()
        .map_err(|e| e.to_string())?;
    while Instant::now() < deadline {
        if let Ok(resp) = client.get(format!("{url}/healthz")).send() {
            if resp.status().is_success() {
                return Ok(());
            }
        }
        thread::sleep(Duration::from_millis(250));
    }
    Err(format!("/healthz never returned 200 within {timeout:?}"))
}

pub fn login_for_bearer_token(url: &str, username: &str, password: &str) -> String {
    let client = reqwest::blocking::Client::new();
    let resp = client
        .post(format!("{url}/api/v1/admin/auth/login"))
        .json(&serde_json::json!({
            "username": username,
            "password": password,
        }))
        .send()
        .expect("login request");
    assert!(
        resp.status().is_success(),
        "login should succeed, got {}: {}",
        resp.status(),
        resp.text().unwrap_or_default()
    );
    let v: Value = resp.json().expect("login json");
    v["token"]
        .as_str()
        .expect("login returns token")
        .to_string()
}

pub fn kill_subprocess(child: &mut Child) {
    let _ = child.kill();
    let _ = child.wait();
}
