//! Execute a CI run.

#![allow(clippy::result_large_err)]

use std::{
    collections::HashSet,
    path::{Path, PathBuf},
};

use tempfile::{tempdir_in, TempDir};

use crate::{
    action::Context,
    cloud_init::{CloudInitError, LocalDataStore, LocalDataStoreBuilder},
    config::Config,
    git::{git_head, git_is_clean, is_git, GitError},
    plan::{construct_all_plans, PlanError, RunnablePlan},
    project::{Project, ProjectError, Projects, State},
    qemu::{QemuError, QemuRunner},
    runlog::{RunLog, RunLogError},
    util::{mkdir, mkdir_child, recreate_dir, UtilError},
    vdrive::{create_tar, create_tar_with_size, VirtualDrive, VirtualDriveError},
};

/// Execute a CI run.
#[allow(clippy::too_many_arguments)]
pub fn cmd_run(
    config: &Config,
    runlog: &mut RunLog,
    projects: &Projects,
    chosen_projects: Option<&[String]>,
    dry_run: bool,
    force: bool,
    uefi: bool,
) -> Result<(), RunError> {
    let executor = config.executor().ok_or(RunError::NoRunCi)?;

    let statedir = config.state();
    if !statedir.exists() {
        mkdir(statedir).map_err(|e| RunError::MkdirState(statedir.into(), e))?;
    }

    for (name, project) in chosen(projects, chosen_projects) {
        let (do_run, mut state) = should_run(dry_run, force, statedir, name, project)?;

        if do_run {
            state.remove_run_log()?;
            let log = state.create_run_log()?;
            runlog.to_named_file(&log).map_err(RunError::RunLogCreate)?;

            state.remove_raw_log()?;
            let raw_log = state.create_raw_log()?;

            runlog.run_ci(name);
            let (pre_plan, plan, post_plan) = construct_all_plans(config, name, project, &state)?;

            pre_plan.execute(&mut Context::new(runlog))?;

            let tmp = tempdir_in(config.tmpdir()).map_err(RunError::TempDir)?;

            let source_drive = create_source_vdrive(&tmp, project.source())?;
            let deps_drive = create_dependencies_vdrive(&tmp, &state)?;
            let executor_drive = create_executor_vdrive(&tmp, &plan, executor)?;

            let artifactsdir = state.artifactsdir();
            let artifact_drive = create_artifacts_vdrive(&tmp, config, project, &artifactsdir)?;

            let cachedir = state.cachedir();
            let cache_drive = create_cache_vdrive(&tmp, config, project, &cachedir)?;

            let ds = create_cloud_init_iso(false)?;

            let exit = QemuRunner::default()
                .config(config)
                .base_image(project.image())
                .executor(&executor_drive)
                .source(&source_drive)
                .cache(&cache_drive)
                .dependencies(&deps_drive)
                .artifacts(&artifact_drive)
                .raw_log(&raw_log)
                .cloud_init(&ds)
                .uefi(uefi)
                .run(runlog)?;

            if exit == 0 {
                artifact_drive.extract_to(&artifactsdir)?;

                if cachedir.exists() {
                    recreate_dir(&cachedir)?;
                }

                cache_drive
                    .extract_to(&cachedir)
                    .map_err(|e| QemuError::ExtractCache(cachedir.clone(), Box::new(e)))?;

                post_plan.execute(&mut Context::new(runlog))?;
            }

            if is_git(project.source()) {
                let head = git_head(project.source())?;
                state.set_latest_commot(Some(&head));
            } else {
                state.set_latest_commot(None);
            };
            state.write_to_file()?;

            if exit != 0 {
                return Err(RunError::RunFailed);
            }
        } else {
            runlog.skip_ci(name);
        }
    }

    Ok(())
}

/// Create an ISO disk image for a `cloud-init` data store.
pub fn create_cloud_init_iso(network: bool) -> Result<LocalDataStore, RunError> {
    const BOOTSTRAP: &str = r#"
(set -xeu
env
dir="$(mktemp -d)"
cd "$dir"
tar -xvf /dev/vdb
find -ls || true
ldd ./run-ci || true
echo ================================ BEGIN ================================
export RUST_BACKTRACE=1
if ./run-ci; then
        echo "EXIT CODE: 0"
else
        echo "EXIT CODE: $?"
fi) > /dev/ttyS1 2>&1
"#;
    LocalDataStoreBuilder::default()
        .with_hostname("ambient")
        .with_runcmd("echo xyzzy > /dev/ttyS1")
        .with_runcmd(BOOTSTRAP)
        .with_runcmd("poweroff")
        .with_network(network)
        .build()
        .map_err(RunError::CloudInit)
}

/// Create a drive for source code.
pub fn create_source_vdrive(tmp: &TempDir, source_dir: &Path) -> Result<VirtualDrive, RunError> {
    Ok(create_tar(tmp.path().join("src.tar"), source_dir)?)
}

fn create_artifacts_vdrive(
    tmp: &TempDir,
    config: &Config,
    project: &Project,
    artifactsdir: &Path,
) -> Result<VirtualDrive, RunError> {
    recreate_dir(artifactsdir)?;
    Ok(create_tar_with_size(
        tmp.path().join("artifacts.tar"),
        artifactsdir,
        project
            .artifact_max_size()
            .unwrap_or(config.artifacts_max_size()),
    )?)
}

fn create_dependencies_vdrive(tmp: &TempDir, state: &State) -> Result<VirtualDrive, RunError> {
    let dependencies = state.dependenciesdir();
    if !dependencies.exists() {
        mkdir(&dependencies)
            .map_err(|e| RunError::MkdirProjectSubState(dependencies.clone(), e))?;
    }
    Ok(create_tar(tmp.path().join("deps.tar"), &dependencies)?)
}

fn create_cache_vdrive(
    tmp: &TempDir,
    config: &Config,
    project: &Project,
    cachedir: &Path,
) -> Result<VirtualDrive, RunError> {
    if !cachedir.exists() {
        mkdir(cachedir).map_err(|e| RunError::MkdirProjectSubState(cachedir.into(), e))?;
    }
    Ok(create_tar_with_size(
        tmp.path().join("cache.tar"),
        cachedir,
        project.cache_max_size().unwrap_or(config.cache_max_size()),
    )?)
}

/// Create a virtual drive for the executro and runnable plan.
pub fn create_executor_vdrive(
    tmp: &TempDir,
    plan: &RunnablePlan,
    executor: &Path,
) -> Result<VirtualDrive, RunError> {
    assert!(executor.exists());

    let dirname =
        mkdir_child(tmp.path(), "ambient-execute-plan").map_err(RunError::MkdirProjectRunCi)?;
    let bin2 = dirname.join("run-ci");

    std::fs::copy(executor, &bin2).map_err(|e| RunError::Copy(executor.into(), bin2.clone(), e))?;

    let plan_filename = dirname.join("plan.yaml");
    plan.to_file(&plan_filename)?;

    Ok(create_tar(tmp.path().join("executor.tar"), &dirname)?)
}

fn should_run(
    dry_run: bool,
    force: bool,
    statedir: &Path,
    name: &str,
    project: &Project,
) -> Result<(bool, State), RunError> {
    let mut decision = Decision::default();

    let state = State::from_file(statedir, name)?;
    if let Some(latest_commit) = state.latest_commit() {
        decision.latest_commit(latest_commit);
    } else {
        // No need to set latest commit to anything, the default is OK.
    }

    let is_git = is_git(project.source());
    if is_git {
        decision.is_git();
    } else {
        decision.is_not_git();
    }

    if git_is_clean(project.source()) {
        decision.is_clean();
    } else {
        decision.is_dirty();
    }

    if is_git {
        let head = git_head(project.source())?;
        decision.current_commit(&head);
    } else {
        // No need to set current commit to anything, the default is OK.
    }

    if dry_run {
        decision.dry_run();
    } else {
        decision.no_dry_run();
    }

    if force {
        decision.force();
    } else {
        decision.no_force();
    }

    let do_run = decision.should_run() == ShouldRun::Run;

    Ok((do_run, state))
}

fn chosen<'a>(projects: &'a Projects, chosen: Option<&[String]>) -> Vec<(&'a str, &'a Project)> {
    let set: HashSet<&str> = match chosen {
        Some(v) if !v.is_empty() => v.iter().map(|s| s.as_str()).collect(),
        _ => projects.iter().map(|(k, _)| k).collect(),
    };
    let mut projects: Vec<(&'a str, &'a Project)> =
        projects.iter().filter(|(k, _)| set.contains(k)).collect();
    projects.sort_by(|(a_name, _), (b_name, _)| a_name.cmp(b_name));
    projects
}

/// Errors from exeucting a CI run.
#[derive(Debug, thiserror::Error)]
pub enum RunError {
    /// Forwarded from the `project` module.
    #[error(transparent)]
    Project(#[from] ProjectError),

    /// Forwarded from the `util` module.
    #[error(transparent)]
    Util(#[from] UtilError),

    /// Can't create a context.
    #[error("failed to create a context for executing actions")]
    Context(#[source] crate::action::ActionError),

    /// Can't create a temporary directory.
    #[error("failed to create temporary directory for running CI build")]
    TempDir(#[source] std::io::Error),

    /// Can't create state directory.
    #[error("failed to create a general state directory")]
    MkdirState(PathBuf, #[source] UtilError),

    /// Can't create project directory.
    #[error("failed to create a project state sub-directory")]
    MkdirProjectSubState(PathBuf, #[source] UtilError),

    /// Can't create temporary directory.
    #[error("failed to create a temporary directory for action runner")]
    MkdirProjectRunCi(#[source] UtilError),

    /// Can't create `cloud-init` ISO file.
    #[error("failed to create a cloud-init ISO file")]
    CloudInit(#[source] CloudInitError),

    /// The created virtual drive is too big.
    #[error("virtual drive is too big: {0} > {1}")]
    DriveTooBig(u64, u64),

    /// Forwarded from the `qemu` module.
    #[error(transparent)]
    Qemu(#[from] QemuError),

    /// CI run failed.
    #[error("CI run failed inside QEMU")]
    RunFailed,

    /// Can't copy file.
    #[error("failed to copy {0} to {1}")]
    Copy(PathBuf, PathBuf, #[source] std::io::Error),

    /// Forwarded from `plan` module.
    #[error(transparent)]
    Plan(#[from] PlanError),

    /// Forwarded from `git` module.
    #[error(transparent)]
    Git(#[from] GitError),

    /// Forwarded from `vdrive` module.
    #[error(transparent)]
    VDrive(#[from] VirtualDriveError),

    /// No executor in config.
    #[error("you must set path to ambient-execute-plan program with option or in configuration")]
    NoRunCi,

    /// Can't open run log.
    #[error("failed to open run log")]
    RunLogCreate(#[source] RunLogError),
}

#[derive(Debug, Default)]
struct Decision {
    dry_run: Option<bool>,
    force_run: Option<bool>,
    is_git: Option<bool>,
    latest_commit: Option<String>,
    current_commit: Option<String>,
    source_is_dirty: Option<bool>,
}

impl Decision {
    fn dry_run(&mut self) {
        self.dry_run = Some(true);
    }

    fn no_dry_run(&mut self) {
        self.dry_run = Some(false);
    }

    fn force(&mut self) {
        self.force_run = Some(true);
    }

    fn no_force(&mut self) {
        self.force_run = Some(false);
    }

    fn is_git(&mut self) {
        self.is_git = Some(true);
    }

    fn is_not_git(&mut self) {
        self.is_git = Some(false);
    }

    fn latest_commit(&mut self, commit: &str) {
        self.latest_commit = Some(commit.into());
    }

    fn current_commit(&mut self, commit: &str) {
        self.current_commit = Some(commit.into());
    }

    fn is_clean(&mut self) {
        self.source_is_dirty = Some(false);
    }

    fn is_dirty(&mut self) {
        self.source_is_dirty = Some(true);
    }

    fn should_run(&self) -> ShouldRun {
        let dry_run = self.dry_run == Some(true);
        let force = self.force_run == Some(true);
        let is_git = self.is_git == Some(true);
        let dirty = self.source_is_dirty == Some(true);

        if dry_run {
            Self::log("dry run");
            ShouldRun::DontRun
        } else if force {
            Self::log("force");
            ShouldRun::Run
        } else if !is_git {
            Self::log("not git");
            ShouldRun::Run
        } else if dirty {
            Self::log("dirty");
            ShouldRun::Run
        } else if self.current_commit == self.latest_commit {
            Self::log("commits are equal");
            ShouldRun::DontRun
        } else {
            Self::log("nothing prevents run");
            ShouldRun::Run
        }
    }

    #[allow(unused_variables)]
    fn log(msg: &str) {
        #[cfg(test)]
        println!("{msg}");
    }
}

// Use a custom enum to avoid confusing reader with interpreting
// boolean values, specially in tests.
#[derive(Debug, Eq, PartialEq, Copy, Clone)]
enum ShouldRun {
    Run,
    DontRun,
}

#[cfg(test)]
mod test_run_decision {
    use super::{Decision, ShouldRun};

    #[test]
    fn is_not_git() {
        let mut d = Decision::default();
        d.no_dry_run();
        d.no_force();
        d.is_not_git();
        d.is_clean();
        assert_eq!(d.should_run(), ShouldRun::Run);
    }

    #[test]
    fn unchanged() {
        let mut d = Decision::default();
        d.no_dry_run();
        d.no_force();
        d.is_git();
        d.is_clean();
        d.latest_commit("abcd");
        d.current_commit("abcd");
        assert_eq!(d.should_run(), ShouldRun::DontRun);
    }

    #[test]
    fn unchanged_with_force() {
        let mut d = Decision::default();
        d.no_dry_run();
        d.force();
        d.is_git();
        d.is_clean();
        d.latest_commit("abcd");
        d.current_commit("abcd");
        assert_eq!(d.should_run(), ShouldRun::Run);
    }

    #[test]
    fn unchanged_commit_but_dirty() {
        let mut d = Decision::default();
        d.no_dry_run();
        d.no_force();
        d.is_git();
        d.is_dirty();
        d.latest_commit("abcd");
        d.current_commit("abcd");
        assert_eq!(d.should_run(), ShouldRun::Run);
    }

    #[test]
    fn commit_changed() {
        let mut d = Decision::default();
        d.no_dry_run();
        d.no_force();
        d.is_git();
        d.is_clean();
        d.latest_commit("abcd");
        d.current_commit("efgh");
        assert_eq!(d.should_run(), ShouldRun::Run);
    }

    #[test]
    fn dry_run_for_unchanged() {
        let mut d = Decision::default();
        d.dry_run();
        d.no_force();
        d.is_git();
        d.is_clean();
        d.latest_commit("abcd");
        d.current_commit("abcd");
        assert_eq!(d.should_run(), ShouldRun::DontRun);
    }

    #[test]
    fn dry_run_for_unchanged_but_dirty() {
        let mut d = Decision::default();
        d.dry_run();
        d.no_force();
        d.is_git();
        d.is_dirty();
        d.latest_commit("abcd");
        d.current_commit("efgh");
        assert_eq!(d.should_run(), ShouldRun::DontRun);
    }

    #[test]
    fn dry_run_for_commit_changed() {
        let mut d = Decision::default();
        d.dry_run();
        d.no_force();
        d.is_git();
        d.is_clean();
        d.latest_commit("abcd");
        d.current_commit("efgh");
        assert_eq!(d.should_run(), ShouldRun::DontRun);
    }

    #[test]
    fn dry_run_for_unchanged_with_force() {
        let mut d = Decision::default();
        d.dry_run();
        d.no_force();
        d.is_git();
        d.is_clean();
        d.latest_commit("abcd");
        d.current_commit("abcd");
        assert_eq!(d.should_run(), ShouldRun::DontRun);
    }

    #[test]
    fn dry_run_for_unchanged_but_dirty_with_force() {
        let mut d = Decision::default();
        d.dry_run();
        d.no_force();
        d.is_git();
        d.is_dirty();
        d.latest_commit("abcd");
        d.current_commit("efgh");
        assert_eq!(d.should_run(), ShouldRun::DontRun);
    }

    #[test]
    fn dry_run_for_commit_changed_with_force() {
        let mut d = Decision::default();
        d.dry_run();
        d.no_force();
        d.is_git();
        d.is_clean();
        d.latest_commit("abcd");
        d.current_commit("efgh");
        assert_eq!(d.should_run(), ShouldRun::DontRun);
    }
}
