//! Actions for Ambient CI.
//!
//! Actual action implementations are in the [`action_impl`](crate::action_impl) module.
//! Actions are divided into [pre-plan](PrePlanAction), [plan](UnsafeAction), and [post-plan](PostPlanAction) actions.
//! These are turned into [runnable actions](RunnableAction).

#![allow(clippy::result_large_err)]

use std::{
    collections::HashMap,
    ffi::OsString,
    path::{Path, PathBuf},
};

use clingwrap::runner::CommandError;
use serde::{Deserialize, Serialize};

use crate::{action_impl::*, plan::RunnablePlan, runlog::RunLog};

/// A context for running an action.
///
/// The context holds state between actions getting executiong,
/// as well as configuration for a specific CI run. The context
/// is created at the start of a CI run and can be modified by
/// actions.
#[derive(Debug)]
pub struct Context<'a> {
    envs: HashMap<OsString, OsString>,
    source_dir: PathBuf,
    deps_dir: PathBuf,
    artifacts_dir: PathBuf,

    #[allow(dead_code)]
    runlog: &'a mut RunLog,
}

impl<'a> Context<'a> {
    /// Create a new `Context`.
    pub fn new(runlog: &'a mut RunLog) -> Self {
        Self {
            envs: HashMap::new(),
            source_dir: PathBuf::default(),
            deps_dir: PathBuf::default(),
            artifacts_dir: PathBuf::default(),
            runlog,
        }
    }

    /// Return a mutable reference to the current run log.
    pub fn runlog(&mut self) -> &mut RunLog {
        self.runlog
    }

    /// Set environment variables from a [`RunnablePlan`].
    pub fn set_envs_from_plan(&mut self, plan: &RunnablePlan) -> Result<(), ActionError> {
        self.source_dir = plan
            .source_dir()
            .ok_or(ActionError::Missing("source_dir"))?
            .into();

        self.deps_dir = plan
            .deps_dir()
            .ok_or(ActionError::Missing("deps_dir"))?
            .into();

        self.artifacts_dir = plan
            .artifacts_dir()
            .ok_or(ActionError::Missing("artifacts_dir"))?
            .into();

        if let Some(path) = plan.cache_dir() {
            self.set_env("CARGO_TARGET_DIR", &format!("{path}/cargo-target"));
        }

        if let Some(path) = plan.deps_dir() {
            self.set_env("CARGO_HOME", path);
        }

        let path = std::env::var("PATH").unwrap_or("/bin".into());
        self.set_env("PATH", &format!("/root/.cargo/bin:{path}"));

        Ok(())
    }

    /// Return all environment variables set in the context.
    /// Note that this only incoludes the once explicitly set.
    /// It does not include ones inherited when Ambient (`ambient-execute-plan`)
    /// starts.
    pub fn env(&self) -> Vec<(OsString, OsString)> {
        self.envs
            .iter()
            .map(|(k, v)| (k.into(), v.into()))
            .collect()
    }

    /// Set environment variable for execution of future actions.
    pub fn set_env<S: Into<OsString>>(&mut self, name: S, value: S) {
        self.envs.insert(name.into(), value.into());
    }

    /// Return source directory for this context.
    pub fn source_dir(&self) -> &Path {
        &self.source_dir
    }

    /// Return dependencies directory for this context.
    pub fn deps_dir(&self) -> &Path {
        &self.deps_dir
    }

    /// Return artifacts directory for this context.
    pub fn artifacts_dir(&self) -> &Path {
        &self.artifacts_dir
    }
}

/// A pair of URL and basename, for an item in a `http_get` action.
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
pub struct Pair {
    /// URL where the file is.
    pub url: String,

    /// File in the depenendencies directory where to store the
    /// downloaded file.
    pub filename: PathBuf,
}

impl Pair {
    /// Return the URL.
    pub fn url(&self) -> &str {
        &self.url
    }

    /// Return the filename.
    pub fn filename(&self) -> &Path {
        &self.filename
    }
}

/// An action that is ready to be executed. These can be executed in any kind of
/// plan, including a runnable plan.
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
#[serde(tag = "action")]
#[serde(rename_all = "snake_case")]
pub enum RunnableAction {
    /// A dummy action that does nothing. Useful for trouble-shooting.
    Dummy(Dummy),

    /// Print current working directory. Useful for trouble-shooting.
    Pwd(Pwd),

    /// Run `cargo fetch` in a safe and secure manner.
    CargoFetch(CargoFetch),

    /// Download files via HTTP.
    HttpGet(HttpGet),

    /// Upload files via `rsync`.
    Rsync(Rsync),

    /// Upload built Debian packages.
    Dput(Dput),

    /// Create a directory.
    Mkdir(Mkdir),

    /// Create a tar archive from a directory.
    TarCreate(TarCreate),

    /// Extract a tar archive into a directory.
    TarExtract(TarExtract),

    /// Execute a shell script snippet with `bash`.
    Shell(Shell),

    /// Run `cargo fmt --check`.
    CargoFmt(CargoFmt),

    /// Run `cargo clippy`.
    CargoClippy(CargoClippy),

    /// Run `cargo deny`.
    CargoDeny(CargoDeny),

    /// Run `cargo doc`.
    CargoDoc(CargoDoc),

    /// Run `cargo build`.
    CargoBuild(CargoBuild),

    /// Run `cargo test`.
    CargoTest(CargoTest),

    /// Run `cargo install`.
    CargoInstall(CargoInstall),

    /// Build a Debian `deb` package.
    Deb(Deb),

    /// Execute a custom action.
    Custom(Custom),

    /// Set environment variables.
    Setenv(Setenv),
}

impl RunnableAction {
    /// Name of the action.
    pub fn name(&self) -> &'static str {
        match self {
            Self::Dummy(_) => "dummy",
            Self::Pwd(_) => "pwd",
            Self::CargoFetch(_) => "cargo_fetch",
            Self::CargoFmt(_) => "cargo_fmt",
            Self::CargoClippy(_) => "cargo_clippy",
            Self::CargoDoc(_) => "cargo_doc",
            Self::CargoDeny(_) => "cargo_deny",
            Self::CargoBuild(_) => "cargo_build",
            Self::CargoTest(_) => "cargo_test",
            Self::CargoInstall(_) => "cargo_install",
            Self::HttpGet(_) => "http_get",
            Self::Rsync(_) => "rsync",
            Self::Dput(_) => "dput",
            Self::Mkdir(_) => "mkdir",
            Self::TarCreate(_) => "tar_create",
            Self::TarExtract(_) => "tar_extract",
            Self::Shell(_) => "shell",
            Self::Custom(_) => "custom",
            Self::Deb(_) => "deb",
            Self::Setenv(_) => "setenv",
        }
    }

    /// Execute a runnable action.
    pub fn execute(&self, context: &mut Context) -> Result<(), ActionError> {
        match self {
            Self::Dummy(x) => x.execute(context),
            Self::Pwd(x) => x.execute(context),
            Self::CargoFetch(x) => x.execute(context),
            Self::HttpGet(x) => x.execute(context),
            Self::Rsync(x) => x.execute(context),
            Self::Dput(x) => x.execute(context),
            Self::Mkdir(x) => x.execute(context),
            Self::TarCreate(x) => x.execute(context),
            Self::TarExtract(x) => x.execute(context),
            Self::Shell(x) => x.execute(context),
            Self::CargoFmt(x) => x.execute(context),
            Self::CargoClippy(x) => x.execute(context),
            Self::CargoDeny(x) => x.execute(context),
            Self::CargoDoc(x) => x.execute(context),
            Self::CargoBuild(x) => x.execute(context),
            Self::CargoTest(x) => x.execute(context),
            Self::CargoInstall(x) => x.execute(context),
            Self::Deb(x) => x.execute(context),
            Self::Custom(x) => x.execute(context),
            Self::Setenv(x) => x.execute(context),
        }
    }

    /// Create a runnable action from a pre-plan action.
    pub fn from_pre_plan_action(action: &PrePlanAction) -> Self {
        match action {
            PrePlanAction::Dummy => Self::Dummy(Dummy),
            PrePlanAction::Pwd => Self::Pwd(Pwd),
            PrePlanAction::CargoFetch => Self::CargoFetch(CargoFetch),
            PrePlanAction::HttpGet { items } => {
                let items: Vec<Pair> = items.to_vec();
                Self::HttpGet(HttpGet::new(items))
            }
        }
    }

    /// Create a runnable action from a post-plan action.
    pub fn from_post_plan_action(
        action: &PostPlanAction,
        rsync_target: Option<&str>,
        dput_target: Option<&str>,
    ) -> Self {
        match action {
            PostPlanAction::Dummy => Self::Dummy(Dummy),
            PostPlanAction::Pwd => Self::Pwd(Pwd),
            PostPlanAction::Rsync => {
                Self::Rsync(Rsync::new(".", rsync_target.map(|s| s.to_string())))
            }
            PostPlanAction::Rsync2 => {
                Self::Rsync(Rsync::new("rsync", rsync_target.map(|s| s.to_string())))
            }
            PostPlanAction::Dput => Self::Dput(Dput::new(".", dput_target.map(|s| s.into()))),
            PostPlanAction::Dput2 => Self::Dput(Dput::new("debian", dput_target.map(|s| s.into()))),
        }
    }

    /// Create a runnable action from a plan (or unsafe) action.
    pub fn from_unsafe_action(action: &UnsafeAction) -> Self {
        match action {
            UnsafeAction::Mkdir { pathname } => Self::Mkdir(Mkdir::new(pathname.to_path_buf())),
            UnsafeAction::TarCreate { archive, directory } => {
                Self::TarCreate(TarCreate::new(archive.clone(), directory.clone()))
            }
            UnsafeAction::TarExtract { archive, directory } => {
                Self::TarExtract(TarExtract::new(archive.clone(), directory.clone()))
            }
            UnsafeAction::Shell { shell } => Self::Shell(Shell::new(shell.clone())),
            UnsafeAction::CargoFmt => Self::CargoFmt(CargoFmt),
            UnsafeAction::CargoClippy => Self::CargoClippy(CargoClippy),
            UnsafeAction::CargoDeny => Self::CargoDeny(CargoDeny),
            UnsafeAction::CargoDoc => Self::CargoDoc(CargoDoc),
            UnsafeAction::CargoBuild => Self::CargoBuild(CargoBuild),
            UnsafeAction::CargoTest => Self::CargoTest(CargoTest),
            UnsafeAction::CargoInstall => Self::CargoInstall(CargoInstall),
            UnsafeAction::Deb => Self::Deb(Deb::new(".")),
            UnsafeAction::Deb2 => Self::Deb(Deb::new("debian")),
            UnsafeAction::Custom(x) => Self::Custom(x.clone()),
            UnsafeAction::Setenv(setenv) => Self::Setenv(setenv.clone()),
        }
    }
}

/// Actions that can be used in a pre-plan.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action")]
#[serde(rename_all = "snake_case")]
pub enum PrePlanAction {
    /// Do nothing. This is only really useful for testing pre-plans.
    Dummy,

    /// Write out the current working directory. This is only really
    /// useful for troubleshooting.
    Pwd,

    /// Fetch Rust language crate dependencies using `cargo fetch`.
    CargoFetch,

    /// Download files from the given URLs, if they've changed or are
    /// missing.
    HttpGet {
        /// List of URL/filename pairs to retrieve.
        items: Vec<Pair>,
    },
}

impl PrePlanAction {
    /// Names of all pre-plan actions.
    pub fn names() -> &'static [&'static str] {
        &["dummy", "pwd", "cargo_fetch"]
    }
}

/// Actions that can be used in a post-plan.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action")]
#[serde(rename_all = "snake_case")]
pub enum PostPlanAction {
    /// Do nothing. This is only really useful for testing post-plans.
    Dummy,

    /// Write out the current working directory. This is only really
    /// useful for troubleshooting.
    Pwd,

    /// Upload, with `rsync`, the artifacts directory to the server
    /// configured for Ambient ([`Config::rsync_target`](crate::config::StoredConfig#structfield.rsync_target)).
    Rsync,

    /// Upload, with `rsync`, the `rsync` subdirectory of the artifacts
    /// directory to the server configured for Ambient
    /// ([`Config::rsync_target`](crate::config::StoredConfig#structfield.rsync_target)).
    /// The difference with the `Rsync` variant is that only the `rsync`
    /// subdirectory is uploaded.
    Rsync2,

    /// Upload, with `dput`, the Debian (`deb`) packages in the artifacts
    /// directory to the server configured for Ambient
    /// ([`Config::dput_target`](crate::config::StoredConfig#structfield.dput_target)).
    Dput,

    /// Upload, with `dput`, the Debian (`deb`) packages in the `debian`
    /// subdirectory of the artifacts directory to the server configured
    /// for Ambient
    /// ([`Config::dput_target`](crate::config::StoredConfig#structfield.dput_target)).
    /// The difference with the `Dput` variant is that only the `debian`
    /// subdirectory is searched for packages.
    Dput2,
}

impl PostPlanAction {
    /// Return names of all post-plan actions.
    pub fn names() -> &'static [&'static str] {
        &["dummy", "pwd", "cargo_fetch", "rsync", "dput"]
    }
}

/// Untrusted actions that are run in a virtual machine, where they can't
/// do any damage.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action")]
#[serde(rename_all = "snake_case")]
pub enum UnsafeAction {
    /// Create a directory if it doesn't exist.
    Mkdir {
        /// Path to directory.
        pathname: PathBuf,
    },

    /// Create a tar archive from a directory.
    /// This is meant to be used in a [`RunnablePlan`] to  new cache or
    /// artifacts.
    TarCreate {
        /// Location of the tar archive to be created.
        archive: PathBuf,

        /// Directory to package in a tar archive.
        directory: PathBuf,
    },

    /// Extract a tar archive from a directory.
    /// This is meant to be used in a [`RunnablePlan`] to extract
    /// source code, dependencies, etc.
    TarExtract {
        /// Location of the tar archive to be extracted.
        archive: PathBuf,

        /// Directory where the archive is to be unpacked.
        directory: PathBuf,
    },

    /// Execute a shell script snippet. It is run with `bash` with
    /// `set -xeuo pipefail` to catch problems as early as possible.
    Shell {
        /// Shell snippet.
        shell: String,
    },

    /// Check that Rust source code is formatted in the idiomatic way,
    /// by running `cargo fmt --check`.
    CargoFmt,

    /// Check that Rust source code is correct and idiomatic, by
    /// running `cargo clippy`. No warnings are allowed.
    CargoClippy,

    /// Check that Rust source code lacks dependencies, licenses, and
    /// other things that are unwanted, by running `cargo deny`.
    CargoDeny,

    /// Format documentation from Rust source code, by running `cargo doc`.
    CargoDoc,

    /// Build a Rust project by running `cargo build`.
    CargoBuild,

    /// Run the test suite of a Rust project, by running `cargo test`.
    CargoTest,

    /// Build and install a Rust project by running `cargo install`.
    /// The installed files go into the artifacts directory.
    CargoInstall,

    /// Build a Debian `deb` package. The built files go into the
    /// artifacts directory.
    Deb,

    /// Build a Debian `deb` package. The built files go into the
    /// `debian` subdirectory of the artifacts directory.
    Deb2,

    /// Run a custom Ambient action from the `.ambient` directory in
    /// the root of the source tree.
    Custom(Custom),

    /// Set enviornment variables for later actions.
    Setenv(Setenv),
}

impl UnsafeAction {
    /// Names of all plan actions.
    pub fn names() -> &'static [&'static str] {
        &[
            "mkdir",
            "tar_create",
            "tar_extract",
            "shell",
            "cargo_fmt",
            "cargo_clippy",
            "cargo_build",
            "cargo_test",
            "cargo_install",
            "deb",
            "custom",
        ]
    }

    /// Construct a `Mkdir` action.
    pub fn mkdir<P: AsRef<Path>>(pathname: P) -> Self {
        Self::Mkdir {
            pathname: pathname.as_ref().into(),
        }
    }

    /// Construct a `TarCreate` action.
    pub fn tar_create<P: AsRef<Path>>(archive: P, directory: P) -> Self {
        Self::TarCreate {
            archive: archive.as_ref().into(),
            directory: directory.as_ref().into(),
        }
    }

    /// Construct a `TarExtract` action.
    pub fn tar_extract<P: AsRef<Path>>(archive: P, directory: P) -> Self {
        Self::TarExtract {
            archive: archive.as_ref().into(),
            directory: directory.as_ref().into(),
        }
    }

    /// Construct a `Shell` action.
    pub fn shell(shell: &str) -> Self {
        Self::Shell {
            shell: shell.into(),
        }
    }
}

/// Errors returned from handling actions.
#[derive(Debug, thiserror::Error)]
pub enum ActionError {
    /// Problem in a Cargo action.
    #[error("cargo action failed")]
    Cargo(#[source] crate::action_impl::CargoError),

    /// Problem in a HttpGet action.
    #[error("http_get action failed")]
    HttpGet(#[source] crate::action_impl::HttpGetError),

    /// Problem in a Dput action.
    #[error("dput action failed")]
    Dput(#[source] crate::action_impl::DputError),

    /// Problem in an Rsync action.
    #[error("rsync action failed")]
    Rsync(#[source] crate::action_impl::RsyncError),

    /// Problem in a tar action.
    #[error("tar action failed")]
    Tar(#[source] crate::action_impl::TarError),

    /// Problem in a custom action.
    #[error("custom action failed")]
    Custom(#[source] crate::action_impl::CustomError),

    /// Problem in a pwd action.
    #[error("pwd actio failed")]
    Pwd(#[source] crate::action_impl::PwdError),

    /// Problem in an mkdir action.
    #[error("failed to create directory")]
    Mkdir(#[source] crate::action_impl::MkdirError),

    /// Problem in a deb action.
    #[error("deb action failed")]
    Deb(#[source] crate::action_impl::DebError),

    /// Can't execute a command.
    #[error("failed to execute {0}")]
    Execute(String, #[source] CommandError),

    /// Internal error: can't spawn a program.
    #[error("failed to invoke command: empty argv")]
    SpawnNoArgv0,

    /// Programming error: [`RunnablePlan`] is missing a field.
    #[error("runnable plan does not have field {0} set")]
    Missing(&'static str),
}

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

    fn plan() -> RunnablePlan {
        let mut plan = RunnablePlan::default();
        plan.set_cache_dir("/tmp");
        plan.set_deps_dir("/tmp");
        plan.set_source_dir("/tmp");
        plan.set_artifacts_dir("/tmp");
        plan
    }

    #[test]
    fn mkdir_action() -> Result<(), Box<dyn std::error::Error>> {
        let tmp = tempdir()?;
        let path = tmp.path().join("testdir");
        let action = RunnableAction::from_unsafe_action(&UnsafeAction::mkdir(&path));
        let plan = plan();
        let mut runlog = RunLog::default();
        let mut context = Context::new(&mut runlog);
        context.set_envs_from_plan(&plan)?;
        assert!(!path.exists());
        assert!(action.execute(&mut context).is_ok());
        assert!(path.exists());
        Ok(())
    }
}
