//! A wumpus hunter project specification.

use std::{
    ffi::OsString,
    fs::File,
    os::unix::ffi::OsStringExt,
    path::{Path, PathBuf},
    process::Command,
};

use log::{error, trace};
use serde::Deserialize;

use crate::runlog::RunLog;

/// A project specification.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Spec {
    /// The project description.
    ///
    /// This gets put on the HTML report front page.
    pub description: String,

    /// The project git repository URL.
    ///
    /// This is where the project source is cloned from.
    pub repository_url: String,

    /// The git ref to use.
    ///
    /// This can be anything `git checkout` can check out. Usually it
    /// is a branch name. If it's a tag or commit id or something else
    /// that doesn't ever change, the wumpus hunter doesn't get
    /// changes to the repository and always runs tests for the same
    /// commit.
    pub git_ref: String,

    /// The shell command to run to run the test suite.
    ///
    /// This will be passed onto the `bash` shell.
    pub command: String,
}

impl Spec {
    /// Load a [`Spec`] from a file.
    pub fn from_file(filename: &Path) -> anyhow::Result<Self> {
        let file = File::open(filename)?;
        let spec: Self = serde_yaml::from_reader(&file)?;
        Ok(spec)
    }

    /// Query versions of important tools.
    pub fn versions(&self, run_log: &mut RunLog) -> anyhow::Result<()> {
        RunCmd::new(".", run_log)
            .arg("rustc")
            .arg("--version")
            .run()?;
        RunCmd::new(".", run_log)
            .arg("cargo")
            .arg("--version")
            .run()?;
        Ok(())
    }

    /// Clone the specified repository to the desired directory.
    pub fn git_clone(&self, working_dir: &Path, run_log: &mut RunLog) -> anyhow::Result<()> {
        RunCmd::new(".", run_log)
            .arg("git")
            .arg("clone")
            .arg(&self.repository_url)
            .path(working_dir)
            .run()?;
        Ok(())
    }

    /// Update the information about git remotes checked out
    /// repository.
    pub fn git_remote_update(
        &self,
        working_dir: &Path,
        run_log: &mut RunLog,
    ) -> anyhow::Result<()> {
        RunCmd::new(working_dir, run_log)
            .arg("git")
            .arg("remote")
            .arg("update")
            .run()?;
        Ok(())
    }

    /// Check out the desired ref.
    pub fn git_checkout(
        &self,
        working_dir: &Path,
        committish: &str,
        run_log: &mut RunLog,
    ) -> anyhow::Result<()> {
        RunCmd::new(working_dir, run_log)
            .arg("git")
            .arg("checkout")
            .arg(committish)
            .run()?;
        Ok(())
    }

    /// Return the commit id currently checked out.
    pub fn git_head(&self, working_dir: &Path, run_log: &mut RunLog) -> anyhow::Result<String> {
        let (stdout, _) = RunCmd::new(working_dir, run_log)
            .arg("git")
            .arg("rev-parse")
            .arg("HEAD")
            .run()?;
        Ok(stdout.trim().into())
    }

    /// Return the date for a specified commit.
    pub fn git_commit_date(
        &self,
        working_dir: &Path,
        commit: &str,
        run_log: &mut RunLog,
    ) -> String {
        if let Ok((stdout, _)) = RunCmd::new(working_dir, run_log)
            .arg("git")
            .arg("show")
            .arg("--pretty=fuller")
            .arg("--date=iso")
            .arg(commit)
            .run()
        {
            const PREFIX: &str = "CommitDate: ";
            let x: Vec<String> = stdout
                .lines()
                .filter_map(|line| line.strip_prefix(PREFIX).map(|s| s.to_string()))
                .collect();
            if x.len() == 1 {
                return x[0].clone();
            }
        }
        "(unknown date)".into()
    }

    /// Run the test suite once.
    pub fn run_test_suite(
        &self,
        working_dir: &Path,
        timeout: usize,
        tmpdir: &Path,
        run_log: &mut RunLog,
    ) -> anyhow::Result<(String, bool)> {
        RunCmd::new(working_dir, run_log)
            .tmpdir(tmpdir)
            .arg("timeout")
            .arg(&format!("{timeout}s"))
            .arg("bash")
            .arg("-c")
            .arg(&self.command)
            .run()
    }
}

#[derive(Debug)]
struct RunCmd<'a> {
    argv: Vec<OsString>,
    cwd: PathBuf,
    tmpdir: Option<PathBuf>,
    run_log: &'a mut RunLog,
}

impl<'a> RunCmd<'a> {
    fn new<P: AsRef<Path>>(cwd: P, run_log: &'a mut RunLog) -> Self {
        let cwd = cwd.as_ref();
        if !cwd.exists() {
            error!("ERROR: directory {} does not exist", cwd.display());
        }
        assert!(cwd.exists());
        Self {
            argv: vec![],
            cwd: cwd.into(),
            tmpdir: None,
            run_log,
        }
    }

    fn tmpdir(mut self, dirname: &Path) -> Self {
        self.tmpdir = Some(dirname.into());
        self
    }

    fn arg(mut self, arg: &str) -> Self {
        self.argv.push(OsString::from_vec(arg.as_bytes().to_vec()));
        self
    }

    fn path(mut self, arg: &Path) -> Self {
        self.argv.push(arg.as_os_str().into());
        self
    }

    fn run(self) -> anyhow::Result<(String, bool)> {
        trace!("runcmd: {self:#?}");
        let tmpdir = self.tmpdir.unwrap_or(PathBuf::from("/tmp"));
        let output = Command::new("bash")
            .arg("-c")
            .arg(r#""$@" 2>&1"#)
            .arg("--")
            .args(&self.argv)
            .current_dir(&self.cwd)
            .env("TMPDIR", &tmpdir)
            .output()?;
        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
        trace!("runcmd: stdout:\n{stdout}");
        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
        trace!("runcmd: stderr:\n{stderr}");
        trace!("runcmd: success? {}", output.status.success());

        let argv: Vec<&str> = self.argv.iter().map(|os| os.to_str().unwrap()).collect();

        self.run_log
            .runcmd(&argv, output.status.code().unwrap(), &stdout, &stderr);
        Ok((stdout, output.status.success()))
    }
}
