//! Execute a CI run.

#![allow(clippy::result_large_err)]

use std::{
    collections::HashSet,
    fs::Permissions,
    os::unix::fs::PermissionsExt,
    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::{LogKind, Project, ProjectError, Projects, State},
    qemu::{QemuError, QemuRunner},
    runlog::{RunLog, RunLogError, RunLogSource},
    util::{copy_file, mkdir, mkdir_child, recreate_dir, UtilError},
    vdrive::{create_empty_tar, 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)?;
    eprintln!("executor from config: {}", executor.display());

    let abs_executor = find_on_path(executor)?;
    eprintln!("executor from PATH: {}", abs_executor.display());

    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 {
            let mut manager = StageManager::new(
                &abs_executor,
                config,
                name,
                project,
                &mut state,
                runlog,
                uefi,
            )?;
            manager.pre_plan()?;
            if manager.actual_plan().is_ok() {
                manager.post_plan()?;

                // The following is not technically part of the post-plan,
                // it's book keeping to make sure we don't needlessly run
                // CI on a project again, if this run succeeded.
                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()?;
            } else {
                Err(RunError::RunFailed)?;
            }
        } else {
            runlog.skip_ci(RunLogSource::Prelude, name);
        }
    }

    Ok(())
}

struct StageManager<'a> {
    state: &'a State,
    tmp: TempDir,
    abs_executor: &'a Path,
    config: &'a Config,
    project: &'a Project,
    runlog: &'a mut RunLog,
    uefi: bool,
    pre_plan: RunnablePlan,
    actual_plan: RunnablePlan,
    post_plan: RunnablePlan,

    artifactsdir: PathBuf,
    artifact_drive: VirtualDrive,

    cachedir: PathBuf,
    cache_drive: VirtualDrive,

    source_drive: VirtualDrive,

    deps_drive: VirtualDrive,
}

impl<'a> StageManager<'a> {
    fn new(
        abs_executor: &'a Path,
        config: &'a Config,
        name: &'a str,
        project: &'a Project,
        state: &'a mut State,
        runlog: &'a mut RunLog,
        uefi: bool,
    ) -> Result<Self, RunError> {
        let tmp = tempdir_in(config.tmpdir()).map_err(RunError::TempDir)?;

        state.log_remove()?;
        state.log_create()?;

        runlog.run_ci(RunLogSource::Prelude, name);
        let (pre_plan, mut actual_plan, post_plan) = construct_all_plans(config, name, project)?;
        for (name, value) in pre_plan.envs() {
            actual_plan.set_env(name, value);
        }

        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 source_drive = create_source_vdrive(&tmp, project.source())?;

        let deps_drive = create_dependencies_vdrive(&tmp, state, config, project)?;

        Ok(Self {
            state,
            tmp,
            abs_executor,
            config,
            project,
            runlog,
            uefi,
            pre_plan,
            actual_plan,
            post_plan,
            artifactsdir,
            artifact_drive,
            cachedir,
            cache_drive,
            source_drive,
            deps_drive,
        })
    }

    fn pre_plan(&mut self) -> Result<(), RunError> {
        eprintln!("RUN pre-plan");
        let log = self.state.log_filename(LogKind::PrePlanJson);
        self.runlog
            .to_named_file(&log)
            .map_err(RunError::RunLogCreate)?;

        let executor_drive = create_executor_vdrive(&self.tmp, &self.pre_plan, self.abs_executor)?;
        let secrets_drive = create_empty_secrets_vdrive(&self.tmp)?;

        let ds = create_cloud_init_iso(false)?;

        let exit = QemuRunner::default()
            .config(self.config)
            .base_image(self.project.image())
            .executor(&executor_drive)
            .source(&self.source_drive)
            .cache(&self.cache_drive)
            .dependencies_rw(&self.deps_drive)
            .artifacts(&self.artifact_drive)
            .secrets(&secrets_drive)
            .raw_log(&self.state.log_filename(LogKind::PrePlanRaw))
            .console_log(&self.state.log_filename(LogKind::PrePlanConsole))
            .cloud_init(&ds)
            .uefi(self.uefi)
            .network(true)
            .run(RunLogSource::Plan, self.runlog)?;

        if exit != 0 {
            if let Ok(data) = std::fs::read(self.state.log_filename(LogKind::PostPlanRaw)) {
                let data = String::from_utf8_lossy(&data);
                eprintln!("======================== run log:\n{data}\n====================== end of run log");
            }
            return Err(RunError::RunFailed);
        }

        Ok(())
    }

    fn actual_plan(&mut self) -> Result<(), RunError> {
        eprintln!("RUN actual-plan");
        let log = self.state.log_filename(LogKind::PlanJson);
        self.runlog
            .to_named_file(&log)
            .map_err(RunError::RunLogCreate)?;

        let executor_drive =
            create_executor_vdrive(&self.tmp, &self.actual_plan, self.abs_executor)?;
        // let secrets_drive = create_empty_secrets_vdrive(&self.tmp)?;
        let secrets_drive = create_secrets_vdrive(&self.tmp, self.config)?;

        let ds = create_cloud_init_iso(false)?;

        let exit = QemuRunner::default()
            .config(self.config)
            .base_image(self.project.image())
            .executor(&executor_drive)
            .source(&self.source_drive)
            .cache(&self.cache_drive)
            .dependencies_ro(&self.deps_drive)
            .artifacts(&self.artifact_drive)
            .secrets(&secrets_drive)
            .raw_log(&self.state.log_filename(LogKind::PlanRaw))
            .console_log(&self.state.log_filename(LogKind::PlanConsole))
            .cloud_init(&ds)
            .uefi(self.uefi)
            .network(false)
            .run(RunLogSource::Plan, self.runlog)?;

        if exit != 0 {
            if let Ok(data) = std::fs::read(self.state.log_filename(LogKind::PostPlanRaw)) {
                let data = String::from_utf8_lossy(&data);
                eprintln!("======================== run log:\n{data}\n====================== end of run log");
            }
            return Err(RunError::RunFailed);
        }

        Ok(())
    }

    fn post_plan(&mut self) -> Result<(), RunError> {
        eprintln!("RUN post-plan");
        let log = self.state.log_filename(LogKind::PostPlanJson);
        self.runlog
            .to_named_file(&log)
            .map_err(RunError::RunLogCreate)?;

        let executor_drive = create_executor_vdrive(&self.tmp, &self.post_plan, self.abs_executor)?;
        let secrets_drive = create_secrets_vdrive(&self.tmp, self.config)?;

        let ds = create_cloud_init_iso(false)?;

        let exit = QemuRunner::default()
            .config(self.config)
            .base_image(self.project.image())
            .executor(&executor_drive)
            .source(&self.source_drive)
            .cache(&self.cache_drive)
            .dependencies_ro(&self.deps_drive)
            .artifacts(&self.artifact_drive)
            .secrets(&secrets_drive)
            .raw_log(&self.state.log_filename(LogKind::PostPlanRaw))
            .console_log(&self.state.log_filename(LogKind::PostPlanConsole))
            .cloud_init(&ds)
            .uefi(self.uefi)
            .network(true)
            .run(RunLogSource::Plan, self.runlog)?;

        if exit != 0 {
            if let Ok(data) = std::fs::read(self.state.log_filename(LogKind::PostPlanRaw)) {
                let data = String::from_utf8_lossy(&data);
                eprintln!("======================== run log:\n{data}\n====================== end of run log");
            }
            return Err(RunError::RunFailed);
        }

        self.artifact_drive.extract_to(&self.artifactsdir)?; // FIXME? is this in the correct place?

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

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

        Ok(())
    }
}

fn find_on_path(bin: &Path) -> Result<PathBuf, RunError> {
    let path = std::env::var_os("PATH").ok_or(RunError::NoPath)?;
    for dir in std::env::split_paths(&path) {
        let full = dir.join(bin);
        if full.exists() {
            return Ok(full);
        }
    }
    Err(RunError::NotOnPath(bin.to_path_buf()))
}

/// 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 an empty drive for secrets.
pub fn create_empty_secrets_vdrive(tmp: &TempDir) -> Result<VirtualDrive, RunError> {
    Ok(create_empty_tar(tmp.path().join("secrets.tar"))?)
}

/// Create a drive for secrets.
pub fn create_secrets_vdrive(tmp: &TempDir, config: &Config) -> Result<VirtualDrive, RunError> {
    let dirname = tmp.path().join("secrets");
    mkdir(&dirname).map_err(|err| RunError::MkdirSecrets(dirname.clone(), err))?;

    for s in config.secrets() {
        let dest = dirname.join(
            s.dest
                .file_name()
                .ok_or_else(|| RunError::NoBasename(s.dest.clone()))?,
        );
        if dest.exists() {
            std::fs::remove_file(&dest).map_err(|err| RunError::RemoveSecret(dest.clone(), err))?;
        }
        copy_file(&s.source, &dest).map_err(|err| RunError::CopySecret(s.source.clone(), err))?;
        std::fs::set_permissions(&dest, Permissions::from_mode(0o400))
            .map_err(RunError::SecretPerms)?;
    }

    eprintln!("SECRETS: copy OK");
    Ok(create_tar(tmp.path().join("secrets.tar"), &dirname)?)
}

/// 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,
    config: &Config,
    project: &Project,
) -> Result<VirtualDrive, RunError> {
    let dependencies = state.dependenciesdir();
    if !dependencies.exists() {
        mkdir(&dependencies)
            .map_err(|e| RunError::MkdirProjectSubState(dependencies.clone(), e))?;
    }
    Ok(create_tar_with_size(
        tmp.path().join("deps.tar"),
        &dependencies,
        project
            .dependencies_max_size()
            .unwrap_or(config.dependencies_max_size()),
    )?)
}

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_key(|(name, _)| *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 temporary secrets directory.
    #[error("failed to create a temporary secrets directory")]
    MkdirSecrets(PathBuf, #[source] UtilError),

    /// Can't copy secret file.
    #[error("failed to copy secret file {0}")]
    CopySecret(PathBuf, #[source] UtilError),

    /// Secrets file lacks basename.
    #[error("secrets file lacks basename: {0}")]
    NoBasename(PathBuf),

    /// 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),

    /// No PATH.
    #[error("failed to find PATH in the environment")]
    NoPath,

    /// No binary on PATH.
    #[error("failed to find {0} on PATH")]
    NotOnPath(PathBuf),

    /// Can't set permissions on secret file.
    #[error("failed to set secret file permissions")]
    SecretPerms(#[source] std::io::Error),

    /// Can't remove secret file for new copy.
    #[error("failed to remove secret file")]
    RemoveSecret(PathBuf, #[source] std::io::Error),
}

#[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);
    }
}
