//! Subprocess wrapper around `ail parse | check | build` plus the
//! model's compiled binary. The harness shells out to the system
//! `ail` binary (resolved via `AIL_BIN` env var or PATH lookup of
//! "ail") and to the binary that `ail build` emits.

use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;

/// Which authoring form the program was produced in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Cohort {
    Json,
    Ail,
}

impl Cohort {
    pub fn extension(self) -> &'static str {
        match self {
            Cohort::Json => "ail.json",
            Cohort::Ail => "ail",
        }
    }
    pub fn as_str(self) -> &'static str {
        match self {
            Cohort::Json => "json",
            Cohort::Ail => "ail",
        }
    }
}

/// Outcome of one pipeline run; `Ok(None)` is the success path.
#[derive(Debug)]
pub struct PipelineCapture {
    pub error: Option<String>,
    pub stdout: String,
    pub stderr: String,
}

/// Resolve the `ail` binary path: `AIL_BIN` env var, else bare "ail".
pub fn ail_bin() -> PathBuf {
    std::env::var("AIL_BIN")
        .ok()
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("ail"))
}

/// Pre-flight check: `ail` and `clang` both runnable.
/// Returns an error with a clear "missing dependency" message if either is absent.
pub fn preflight() -> Result<()> {
    for (label, bin) in [("ail", ail_bin()), ("clang", PathBuf::from("clang"))] {
        let out = Command::new(&bin)
            .arg("--version")
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status();
        match out {
            Ok(st) if st.success() => {}
            Ok(st) => return Err(anyhow!("{label} ({}) --version exited {st}", bin.display())),
            Err(e) => return Err(anyhow!("{label} ({}) not runnable: {e}", bin.display())),
        }
    }
    Ok(())
}

/// Run the per-cohort pipeline on `program`. Returns `Ok(None)` on
/// success, `Ok(Some(error_string))` on a pipeline failure that the
/// harness will feed back to the model (un-stripped — stripping is
/// the caller's responsibility), `Err(...)` only on harness-level
/// faults (missing `ail` binary, IO error).
pub fn run_pipeline(
    cohort: Cohort,
    program: &str,
    expected_stdout: &str,
    workdir: &Path,
) -> Result<PipelineCapture> {
    std::fs::create_dir_all(workdir).with_context(|| format!("creating {}", workdir.display()))?;
    let prog_path = workdir.join(format!("prog.{}", cohort.extension()));
    std::fs::write(&prog_path, program).with_context(|| format!("writing {}", prog_path.display()))?;

    // For AIL cohort, parse to JSON first.
    let json_path_initial = if matches!(cohort, Cohort::Ail) {
        let out = workdir.join("prog.ail.json");
        let parse_out = Command::new(ail_bin())
            .arg("parse").arg(&prog_path)
            .arg("-o").arg(&out)
            .output()
            .with_context(|| format!("running ail parse on {}", prog_path.display()))?;
        if !parse_out.status.success() {
            return Ok(PipelineCapture {
                error: Some(format!("parse: {}", String::from_utf8_lossy(&parse_out.stderr).trim())),
                stdout: String::new(),
                stderr: String::from_utf8_lossy(&parse_out.stderr).into_owned(),
            });
        }
        out
    } else {
        prog_path.clone()
    };

    // `ail check` enforces filename stem == module name. Extract the module
    // name from the JSON and rename the file accordingly before checking.
    // Faults reading/parsing the JSON are reported as a check-class error
    // (the same diagnostic the model would see if it submitted a JSON with
    // a malformed top-level shape).
    let json_path = match module_name_from_json(&json_path_initial) {
        Ok(name) => {
            let renamed = workdir.join(format!("{name}.ail.json"));
            if renamed != json_path_initial {
                std::fs::rename(&json_path_initial, &renamed)
                    .with_context(|| format!("renaming {} -> {}", json_path_initial.display(), renamed.display()))?;
            }
            renamed
        }
        Err(e) => {
            return Ok(PipelineCapture {
                error: Some(format!("check: {e:#}")),
                stdout: String::new(),
                stderr: e.to_string(),
            });
        }
    };

    // Type check.
    let check_out = Command::new(ail_bin()).arg("check").arg(&json_path).output()
        .with_context(|| format!("running ail check on {}", json_path.display()))?;
    if !check_out.status.success() {
        return Ok(PipelineCapture {
            error: Some(format!("check: {}", String::from_utf8_lossy(&check_out.stderr).trim())),
            stdout: String::new(),
            stderr: String::from_utf8_lossy(&check_out.stderr).into_owned(),
        });
    }

    // Build to a native binary.
    let bin_path = workdir.join("prog.bin");
    let build_out = Command::new(ail_bin())
        .arg("build").arg(&json_path)
        .arg("-o").arg(&bin_path)
        .output()
        .with_context(|| format!("running ail build on {}", json_path.display()))?;
    if !build_out.status.success() {
        return Ok(PipelineCapture {
            error: Some(format!("build: {}", String::from_utf8_lossy(&build_out.stderr).trim())),
            stdout: String::new(),
            stderr: String::from_utf8_lossy(&build_out.stderr).into_owned(),
        });
    }

    // Execute with a 5-second timeout.
    let run_out = run_with_timeout(&bin_path, Duration::from_secs(5))?;
    let actual_stdout = String::from_utf8_lossy(&run_out.stdout).into_owned();
    if !run_out.timed_out {
        if actual_stdout != expected_stdout {
            return Ok(PipelineCapture {
                error: Some(format!(
                    "output: expected {:?}, got {:?}",
                    expected_stdout, actual_stdout
                )),
                stdout: actual_stdout,
                stderr: String::from_utf8_lossy(&run_out.stderr).into_owned(),
            });
        }
        return Ok(PipelineCapture {
            error: None,
            stdout: actual_stdout,
            stderr: String::from_utf8_lossy(&run_out.stderr).into_owned(),
        });
    }
    Ok(PipelineCapture {
        error: Some("runtime: timeout after 5s".to_string()),
        stdout: actual_stdout,
        stderr: String::from_utf8_lossy(&run_out.stderr).into_owned(),
    })
}

/// Extract the top-level `"name"` field from a JSON module file.
/// Returns a check-class error string if the JSON cannot be read or
/// the `name` field is absent / not a string.
fn module_name_from_json(path: &Path) -> Result<String> {
    let text = std::fs::read_to_string(path)
        .with_context(|| format!("reading {}", path.display()))?;
    let v: serde_json::Value = serde_json::from_str(&text)
        .with_context(|| format!("parsing JSON in {}", path.display()))?;
    let name = v.get("name")
        .and_then(|n| n.as_str())
        .ok_or_else(|| anyhow!("top-level `name` field missing or not a string in {}", path.display()))?;
    Ok(name.to_string())
}

struct RunOutput {
    stdout: Vec<u8>,
    stderr: Vec<u8>,
    timed_out: bool,
}

fn run_with_timeout(bin: &Path, timeout: Duration) -> Result<RunOutput> {
    use std::io::Read;
    let mut child = Command::new(bin)
        .stdout(Stdio::piped()).stderr(Stdio::piped())
        .spawn().with_context(|| format!("spawning {}", bin.display()))?;
    let start = std::time::Instant::now();
    loop {
        if let Some(_status) = child.try_wait()? {
            let mut so = Vec::new();
            let mut se = Vec::new();
            if let Some(mut s) = child.stdout.take() { s.read_to_end(&mut so).ok(); }
            if let Some(mut s) = child.stderr.take() { s.read_to_end(&mut se).ok(); }
            return Ok(RunOutput { stdout: so, stderr: se, timed_out: false });
        }
        if start.elapsed() >= timeout {
            let _ = child.kill();
            let _ = child.wait();
            return Ok(RunOutput { stdout: Vec::new(), stderr: Vec::new(), timed_out: true });
        }
        std::thread::sleep(Duration::from_millis(25));
    }
}

#[cfg(test)]
mod tests {
    use anyhow::{anyhow, Context, Error};

    /// Constructs the same chain shape `module_name_from_json`
    /// produces on a serde-parse failure: a leaf error wrapped
    /// with a "parsing JSON in <path>" context. Asserts that the
    /// format string used at pipeline.rs error-emission preserves
    /// both layers.
    #[test]
    fn check_format_preserves_anyhow_chain() {
        let leaf: Error = anyhow!("expected value at line 1 column 1");
        let chained = Err::<(), _>(leaf)
            .context("parsing JSON in /tmp/x.ail.json")
            .unwrap_err();
        let formatted = format!("check: {chained:#}");
        assert!(
            formatted.contains("parsing JSON in /tmp/x.ail.json"),
            "top-level context missing; got: {formatted}",
        );
        assert!(
            formatted.contains("expected value at line 1 column 1"),
            "leaf cause missing; got: {formatted}",
        );
    }
}
