use std::{
    env::temp_dir,
    ffi::{OsStr, OsString},
    fs,
    path::PathBuf,
    process::{Command, Output},
    sync::{
        LazyLock,
        atomic::{AtomicU64, Ordering},
    },
    thread,
    time::{Duration, SystemTime, UNIX_EPOCH},
};

static SERVER: LazyLock<TestServer> = LazyLock::new(TestServer::start);
static BIN: &str = std::env!("CARGO_BIN_EXE_procfarm");

struct TestServer {
    args: TestServerArgs,
    handle: std::process::Child,
}
impl TestServer {
    fn start() -> Self {
        let args = TestServerArgs::new();
        Self::with_args(args)
    }

    fn with_args(args: TestServerArgs) -> Self {
        let handle = std::process::Command::new(BIN)
            .args(args.loc_args_as_slice())
            .args(["start"])
            .args(args.additional_args())
            .spawn()
            .unwrap();

        Self { args, handle }
    }

    fn run(
        &self,
        additional_args: impl IntoIterator<Item = impl AsRef<OsStr>>,
        cmd_args: impl IntoIterator<Item = impl AsRef<OsStr>>,
    ) -> Result<Output, std::io::Error> {
        self.args.run(additional_args, cmd_args)
    }

    fn stop(&self) -> Result<Output, std::io::Error> {
        let additional_args: [OsString; 0] = [];
        self.args.stop(additional_args)
    }

    fn flush(&self) -> Result<Output, std::io::Error> {
        let additional_args: [OsString; 0] = [];
        self.args.flush(additional_args)
    }
}
impl Drop for TestServer {
    fn drop(&mut self) {
        self.stop().ok();
        self.handle.kill().ok();
        self.handle.wait().ok();
    }
}

static ARGS_COUNTER: AtomicU64 = AtomicU64::new(0);
struct TestServerArgs {
    runtime_dir: PathBuf,
    additional: Vec<OsString>,
}
impl TestServerArgs {
    fn new() -> Self {
        let dir = temp_dir();

        let base_name = format!(
            "procfarm-{}-{}",
            current_ts(),
            ARGS_COUNTER.fetch_add(1, Ordering::Relaxed)
        );
        let runtime_dir = dir.join(base_name);
        Self {
            runtime_dir,
            additional: Default::default(),
        }
    }

    fn with_args(mut self, args: impl IntoIterator<Item = impl Into<OsString>>) -> Self {
        self.additional.extend(args.into_iter().map(|v| v.into()));
        self
    }

    fn loc_args_as_slice(&self) -> [&OsStr; 2] {
        [OsStr::new("--runtime-dir"), self.runtime_dir.as_os_str()]
    }

    fn additional_args(&self) -> &[OsString] {
        self.additional.as_slice()
    }

    /// If there's a server running with this args' deets, stop it.
    fn stop(
        &self,
        additional_args: impl IntoIterator<Item = impl AsRef<OsStr>>,
    ) -> Result<Output, std::io::Error> {
        Command::new(BIN)
            .args(self.loc_args_as_slice())
            .args(["stop", "--timeout", "5"])
            .args(additional_args)
            .output()
    }

    fn run(
        &self,
        additional_args: impl IntoIterator<Item = impl AsRef<OsStr>>,
        cmd_args: impl IntoIterator<Item = impl AsRef<OsStr>>,
    ) -> Result<Output, std::io::Error> {
        Command::new(BIN)
            .args(self.loc_args_as_slice())
            .arg("run")
            .args(additional_args)
            .arg("--")
            .args(cmd_args)
            // NB: this must be .spawn() rather than .output() if the test
            // will spawn a new server. Otherwise it waits for the detached
            // server to exit (forever).
            .spawn()
            .unwrap()
            .wait_with_output()
    }

    fn flush(
        &self,
        additional_args: impl IntoIterator<Item = impl AsRef<OsStr>>,
    ) -> Result<Output, std::io::Error> {
        Command::new(BIN)
            .args(self.loc_args_as_slice())
            .args(["flush"])
            .args(additional_args)
            .output()
    }
}

fn current_ts() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs()
}

#[test]
fn run_some_touches() {
    let tdir = temp_dir().join(format!("run-touches-{}", current_ts()));
    fs::create_dir(&tdir).unwrap();

    for i in 0..10 {
        let target = tdir.join(i.to_string()).to_string_lossy().into_owned();
        SERVER.run(["-v"], ["touch", target.as_str()]).unwrap();
    }

    // Give it up to 10 seconds to run all the commands
    for _ in 0..100 {
        if fs::exists(tdir.join("9")).unwrap() {
            break;
        }
        thread::sleep(Duration::from_millis(100));
    }
    for i in 0..10 {
        assert!(std::fs::exists(tdir.join(i.to_string())).unwrap());
    }
}

#[test]
fn run_starts_server_if_needed() {
    let tdir = temp_dir().join(format!("run-starts-server-if-needed-{}", current_ts()));
    fs::create_dir(&tdir).unwrap();

    let args = TestServerArgs::new();

    for i in 0..10 {
        let target = tdir.join(i.to_string()).to_string_lossy().into_owned();
        let res = args
            .run([] as [String; 0], ["touch", target.as_str()])
            .unwrap();
        assert!(
            res.status.success(),
            "{}",
            String::from_utf8_lossy(&res.stderr)
        );
    }

    // Give it up to 10 seconds to run all the commands
    for _ in 0..1000 {
        if fs::exists(tdir.join("9")).unwrap() {
            break;
        }
        thread::sleep(Duration::from_millis(100));
    }
    for i in 0..10 {
        assert!(fs::exists(tdir.join(i.to_string())).unwrap());
    }

    let res = args.stop([] as [OsString; 0]).unwrap();

    assert!(res.status.success());
}

#[test]
fn server_default_timeout() {
    let tdir = temp_dir().join(format!("server-default-timeout-{}", current_ts()));
    fs::create_dir(&tdir).unwrap();

    let fpath = tdir.join(format!("{}", current_ts()));

    // Start server with a default timeout of 0, so any command will time out.
    let server = TestServer::with_args(TestServerArgs::new().with_args(["--proc-timeout", "0"]));

    // Start a command to touch a file after a delay.
    let cmd = format!("sleep 10 && touch {}", fpath.to_string_lossy());
    server.run(["-v"], ["bash", "-c", &cmd]).unwrap();

    // Flush, which ensures all in-progress processes have completed (in this case, timed out).
    server.flush().unwrap();

    // The touched file should not exist.
    assert!(!fs::exists(fpath).unwrap());
}

#[test]
fn server_default_timeout_override() {
    let tdir = temp_dir().join(format!("server-default-timeout-override-{}", current_ts()));
    fs::create_dir(&tdir).unwrap();

    let fpath = tdir.join("1");

    // Start server with a long default timeout
    let server = TestServer::with_args(TestServerArgs::new().with_args(["--proc-timeout", "30"]));

    // Start a command to touch a file after a delay, with a short timeout that should
    // immediatley time out.
    let cmd = format!("sleep 1 && touch {}", fpath.to_string_lossy());
    server
        .run(["-v", "--timeout", "0"], ["bash", "-c", &cmd])
        .unwrap();

    // Flush, which ensures all in-progress processes have completed (in this case, timed out).
    server.flush().unwrap();

    // The touched file should not exist.
    assert!(!fs::exists(fpath).unwrap());
}

#[test]
fn flush() {
    let tdir = temp_dir().join(format!("flush-{}", current_ts()));
    fs::create_dir(&tdir).unwrap();

    let fpath = tdir.join("1");

    let server = TestServer::with_args(TestServerArgs::new());

    // Start a command that waits for a the file path to be present, and then exits.
    let cmd = format!(
        "while true; do if [ -f {} ]; then break; fi; done",
        fpath.to_string_lossy()
    );
    server.run([] as [String; 0], ["bash", "-c", &cmd]).unwrap();

    // Start a thread that waits for a flush to complete. It will not complete
    // until the file exists.
    let handle = thread::spawn(move || server.flush().unwrap());

    // Assert that flush does not complete for some amount of time. 100 ms is a lot
    // of time, relatively speaking.
    for _ in 0..10 {
        assert!(!handle.is_finished());
        thread::sleep(Duration::from_millis(10));
    }

    // But once we write the file...
    fs::write(fpath, "").unwrap();

    // Flush completes.
    let res = handle.join().unwrap();
    assert!(res.status.success());
}

/// We have reasonable fallbacks if there's no XDG_RUNTIME_DIR
#[test]
fn no_xdg_runtime_dir() {
    let out = Command::new(BIN)
        .env_remove("XDG_RUNTIME_DIR")
        .arg("stop")
        .output()
        .unwrap();

    assert!(
        out.status.success(),
        "{} {}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr),
    )
}
