//! The two ways `wai conform` reaches an implementation.
//!
//! **Command.** `--impl` is a command line, run by the system shell once a case (or once for
//! the whole corpus, where the suite allows it), with these placeholders replaced by quoted
//! paths: `{case}` a file holding the case (a verifier's vector, as the corpus writes it),
//! `{corpus}` the corpus file whole, `{in}` the input file, `{out}` a file to write the
//! output to. Without `{case}` or `{in}`, the case or input is given on standard input;
//! without `{out}`, the output is read from standard output. What the command prints first
//! on standard output is its verdict; its exit status says whether it refused (non-zero)
//! where the suite asks.
//!
//! **HTTP.** `--impl` is an `http://` URL: each case is a `POST` of the case or input to
//! it (an origin is instead fetched from, below it), and the response's status and body are
//! the answer.

use sha2::{Digest, Sha256};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

/// What a command did.
pub struct Ran {
    pub code: Option<i32>,
    pub stdout: Vec<u8>,
    pub stderr: Vec<u8>,
    pub timed_out: bool,
}

/// A path quoted for the shell that runs the command.
pub fn quote(p: &Path) -> String {
    let s = p.to_string_lossy();
    if cfg!(windows) { format!("\"{s}\"") } else { format!("'{}'", s.replace('\'', "'\\''")) }
}

/// `cmd` with each `{name}` replaced by its quoted path.
pub fn fill(cmd: &str, with: &[(&str, &Path)]) -> String {
    let mut s = cmd.to_string();
    for (k, p) in with {
        s = s.replace(&format!("{{{k}}}"), &quote(p));
    }
    s
}

/// Run `cmd` through the shell, `stdin` given to it, killed after `timeout`.
///
/// Its output is read until the command has exited and then for at most [`DRAIN`] more: a
/// program the command started in the background, or one still running after the shell was
/// killed, can hold the output open, and the run does not wait for it. The command stays in
/// the caller's process group, so an interrupt of `wai conform` reaches it too.
pub fn run(cmd: &str, stdin: Option<&[u8]>, timeout: Duration) -> Result<Ran, String> {
    #[cfg(windows)]
    let mut c = {
        // `Command::arg` would escape the quotes `quote` writes, and cmd would hand them on as part
        // of each path. So cmd's line goes as it is: /S takes the outer pair off and nothing else,
        // whatever the command line begins with.
        use std::os::windows::process::CommandExt;
        let mut c = Command::new("cmd");
        c.raw_arg("/S /C \"").raw_arg(cmd).raw_arg("\"");
        c
    };
    #[cfg(not(windows))]
    let mut c = {
        let mut c = Command::new("sh");
        c.arg("-c").arg(cmd);
        c
    };
    let mut child = c
        .stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|e| format!("{cmd}: {e}"))?;
    if let (Some(b), Some(mut w)) = (stdin, child.stdin.take()) {
        let b = b.to_vec();
        // Detached: a reader that never drains its input must not hold the run.
        std::thread::spawn(move || {
            let _ = w.write_all(&b);
        });
    }
    let read = |r: Option<Box<dyn Read + Send>>| {
        let (tx, rx) = std::sync::mpsc::channel();
        std::thread::spawn(move || {
            let mut v = Vec::new();
            if let Some(mut r) = r {
                let _ = r.read_to_end(&mut v);
            }
            let _ = tx.send(v);
        });
        rx
    };
    let out = read(child.stdout.take().map(|x| Box::new(x) as Box<dyn Read + Send>));
    let err = read(child.stderr.take().map(|x| Box::new(x) as Box<dyn Read + Send>));
    let t0 = Instant::now();
    let mut timed_out = false;
    let status = loop {
        match child.try_wait().map_err(|e| e.to_string())? {
            Some(s) => break Some(s),
            None if t0.elapsed() >= timeout => {
                timed_out = true;
                let _ = child.kill();
                break child.wait().ok();
            }
            None => std::thread::sleep(Duration::from_millis(5)),
        }
    };
    let until = Instant::now() + DRAIN;
    let stdout = out.recv_timeout(until.saturating_duration_since(Instant::now())).unwrap_or_default();
    let stderr = err.recv_timeout(until.saturating_duration_since(Instant::now())).unwrap_or_default();
    Ok(Ran { code: status.and_then(|s| s.code()), stdout, stderr, timed_out })
}

/// How long the output of a command that has exited is still read.
pub const DRAIN: Duration = Duration::from_secs(2);

/// The first line of `b`, trimmed.
pub fn first_line(b: &[u8]) -> String {
    String::from_utf8_lossy(b).lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("").to_string()
}

pub fn sha256_hex(b: &[u8]) -> String {
    Sha256::digest(b).iter().map(|x| format!("{x:02x}")).collect()
}

/// The words of a command line, quotes removed (the shell's rules, roughly).
fn words(cmd: &str) -> Vec<String> {
    let mut out = Vec::new();
    let mut cur = String::new();
    let mut quote: Option<char> = None;
    let mut any = false;
    for c in cmd.chars() {
        match (quote, c) {
            (Some(q), c) if c == q => quote = None,
            (Some(_), c) => cur.push(c),
            (None, '\'' | '"') => {
                quote = Some(c);
                any = true;
            }
            (None, c) if c.is_whitespace() => {
                if any || !cur.is_empty() {
                    out.push(std::mem::take(&mut cur));
                    any = false;
                }
            }
            (None, c) => cur.push(c),
        }
    }
    if any || !cur.is_empty() {
        out.push(cur);
    }
    out
}

fn on_path(name: &str) -> Option<PathBuf> {
    if name.contains('/') || name.contains('\\') {
        return Path::new(name).is_file().then(|| PathBuf::from(name));
    }
    let path = std::env::var_os("PATH")?;
    std::env::split_paths(&path).map(|d| d.join(name)).find(|p| p.is_file())
}

/// The files a command line names (its program found on `PATH`, and every argument that
/// is a file), each with its SHA-256: what identifies the implementation that ran.
pub fn identity(cmd: &str) -> Vec<(String, String)> {
    let mut out = Vec::new();
    for (i, w) in words(cmd).iter().enumerate() {
        if w.starts_with('{') && w.ends_with('}') {
            continue;
        }
        let p = if i == 0 { on_path(w) } else { Path::new(w).is_file().then(|| PathBuf::from(w)) };
        if let Some(p) = p
            && let Ok(b) = std::fs::read(&p)
        {
            out.push((p.to_string_lossy().into_owned(), sha256_hex(&b)));
        }
    }
    out
}

/// Whether the program a command line runs can be found.
pub fn program_found(cmd: &str) -> bool {
    match words(cmd).first() {
        None => false,
        Some(p) => on_path(p).is_some() || matches!(p.as_str(), "cd" | "exec" | "env" | "echo" | "printf" | "test" | "[" | "true" | "false"),
    }
}

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

    /// A quoted path reaches the command as the path, and the program itself may be quoted.
    #[cfg(windows)]
    #[test]
    fn cmd_gets_the_command_line_as_it_is_written() {
        let d = std::env::temp_dir().join(format!("wai-conform-cmd-{}", std::process::id()));
        std::fs::create_dir_all(&d).unwrap();
        let f = d.join("a b.txt");
        std::fs::write(&f, "held\r\n").unwrap();
        let r = run(&fill("type {f}", &[("f", &f)]), None, Duration::from_secs(20)).unwrap();
        assert_eq!((r.code, first_line(&r.stdout).trim()), (Some(0), "held"), "{}", String::from_utf8_lossy(&r.stderr));
        let findstr = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()).join("System32").join("findstr.exe");
        let r = run(&fill("{p} held {f}", &[("p", &findstr), ("f", &f)]), None, Duration::from_secs(20)).unwrap();
        assert_eq!((r.code, first_line(&r.stdout).trim()), (Some(0), "held"), "{}", String::from_utf8_lossy(&r.stderr));
        let _ = std::fs::remove_dir_all(&d);
    }

    #[cfg(unix)]
    #[test]
    fn placeholders_are_quoted_paths() {
        let s = fill("dec {in} {out}", &[("in", Path::new("/t/a b.wscr")), ("out", Path::new("/t/it's.pcm"))]);
        assert_eq!(s, "dec '/t/a b.wscr' '/t/it'\\''s.pcm'");
        assert_eq!(words("python3 -I 'a b.py' verdicts {corpus}"), vec!["python3", "-I", "a b.py", "verdicts", "{corpus}"]);
    }

    #[cfg(unix)]
    #[test]
    fn a_command_runs_and_one_that_hangs_is_stopped() {
        let r = run("cat", Some(b"x\ny"), Duration::from_secs(20)).unwrap();
        assert_eq!(r.stdout, b"x\ny");
        assert_eq!(first_line(&r.stdout), "x");
        // The shell is killed at the timeout; the `sleep` it started keeps the output open,
        // and is not waited for past the drain.
        let t0 = Instant::now();
        let r = run("sleep 30; echo done", None, Duration::from_millis(200)).unwrap();
        assert!(r.timed_out);
        assert!(t0.elapsed() < Duration::from_secs(10), "{:?}", t0.elapsed());
    }
}
