use std::{
    collections::HashMap,
    ffi::OsString,
    io::{pipe, Read},
    os::unix::ffi::OsStringExt,
    path::{Path, PathBuf},
    process::{Command, Stdio},
};

use ambient_ci::{
    action::{PostPlanAction, PrePlanAction, UnsafeAction},
    runlog::RunLog,
};
use clingwrap::{
    runner::CommandRunner,
    ssh::{Args, Ssh},
};
use radicle_ci_broker::msg::helper::{indented, AdminLog};
use serde::{Deserialize, Serialize};

use crate::AdapterError;

// Exit code to indicate we didn't get one from the process.
pub const NO_EXIT: i32 = 999;

// Basename of generated projects file. This MUST be the same on the local
// file system and on the remote host, if SSH is used to run Ambient on another
// host.
const PROJECTS_FILENAME: &str = "projects.yaml";

// Basename of the Ambient extra config file.
const AMBIENT_EXTRA_CONFIG_BASENAME: &str = "extra-ambient-config.yaml";

// Read `.radicle/ambient.yaml` from the source tree.
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct DistilledPlan {
    pre_plan: Option<Vec<PrePlanAction>>,
    plan: Option<Vec<UnsafeAction>>,
    post_plan: Option<Vec<PostPlanAction>>,
}

impl DistilledPlan {
    pub fn read(filename: &Path) -> Result<Self, AdapterError> {
        let data =
            std::fs::read(filename).map_err(|err| AdapterError::ReadPlan(filename.into(), err))?;
        serde_norway::from_slice(&data).map_err(|err| AdapterError::ParsePlan(filename.into(), err))
    }

    pub fn drop_post_plan(&mut self) {
        self.post_plan = None;
    }
}

#[derive(Debug, Serialize)]
pub struct Project {
    source: PathBuf,
    image: PathBuf,
    pre_plan: Vec<PrePlanAction>,
    plan: Vec<UnsafeAction>,
    post_plan: Vec<PostPlanAction>,
    artifact_max_size: Option<u64>,
    cache_max_size: Option<u64>,
}

#[derive(Debug, Serialize)]
pub struct Projects {
    projects: HashMap<String, Project>,
}

impl Projects {
    pub fn write(&self, filename: &Path) -> Result<String, AdapterError> {
        let text = serde_norway::to_string(&self).map_err(AdapterError::ToYaml)?;
        std::fs::write(filename, text.as_bytes()).map_err(AdapterError::WriteProjects)?;
        Ok(text)
    }

    pub fn project_name(&self) -> String {
        let keys: Vec<&str> = self.projects.keys().map(|s| s.as_str()).collect();
        assert_eq!(keys.len(), 1);
        keys[0].to_string()
    }
}

#[derive(Debug, Default)]
pub struct ProjectsBuilder {
    name: Option<String>,
    source: Option<PathBuf>,
    image: Option<PathBuf>,
    pre_plan: Vec<PrePlanAction>,
    plan: Vec<UnsafeAction>,
    post_plan: Vec<PostPlanAction>,
    artifact_max_size: Option<u64>,
    cache_max_size: Option<u64>,
}

impl ProjectsBuilder {
    pub fn name(mut self, name: &str) -> Self {
        self.name = Some(name.into());
        self
    }

    pub fn source(mut self, path: &Path) -> Self {
        self.source = Some(path.into());
        self
    }

    pub fn image(mut self, path: &Path) -> Self {
        self.image = Some(path.into());
        self
    }

    pub fn artifact_max_size(mut self, size: u64) -> Self {
        self.artifact_max_size = Some(size);
        self
    }

    pub fn cache_max_size(mut self, size: u64) -> Self {
        self.cache_max_size = Some(size);
        self
    }

    pub fn plan(mut self, plan: &DistilledPlan) -> Self {
        if let Some(actions) = &plan.pre_plan {
            self.pre_plan = actions.to_vec();
        }
        if let Some(actions) = &plan.plan {
            self.plan = actions.to_vec();
        }
        if let Some(actions) = &plan.post_plan {
            self.post_plan = actions.to_vec();
        }
        self
    }

    pub fn build(self) -> Projects {
        let projects: HashMap<String, Project> = HashMap::from([(
            self.name.unwrap(),
            Project {
                source: self.source.expect("ProjectsBuilder::source is set"),
                image: self.image.expect("ProjectsBuilder::image is set"),
                pre_plan: self.pre_plan.clone(),
                plan: self.plan.clone(),
                post_plan: self.post_plan.clone(),
                artifact_max_size: self.artifact_max_size,
                cache_max_size: self.cache_max_size,
            },
        )]);
        Projects { projects }
    }
}

/// Run Ambient on a project.
///
/// The `tmp` argument should point at a directory that exists for the duration
/// of the CI run. The sources should be in the `src` sub-directory, and should
/// be the version that should be used for the CI run. This function will write
/// other files to the `tmp` directory. It is on the caller to clean up the
/// directory after the CI run finishes.
pub fn run_ambient_ci(
    tmp: &Path,
    adminlog: &mut AdminLog,
    dry_run: bool,
    projects: &Projects,
    extra_ambient_config: Option<&Path>,
    extra_ambient_config_values: &HashMap<String, serde_norway::Value>,
    ssh_target: Option<&str>,
) -> Result<CiOutput, AdapterError> {
    let projects_filename = create_projects_file(adminlog, tmp, projects)?;
    let mut ambient = AmbientRunner::new(
        tmp,
        adminlog,
        dry_run,
        projects,
        projects_filename,
        extra_ambient_config,
        extra_ambient_config_values,
    );
    if let Some(ssh_target) = ssh_target {
        ambient.remote_run(ssh_target)
    } else {
        ambient.local_run()
    }
}

struct AmbientRunner<'a> {
    tmp: &'a Path,
    adminlog: &'a mut AdminLog,
    dry_run: bool,
    projects: &'a Projects,
    projects_filename: PathBuf,
    extra_ambient_config_filename: PathBuf,
    extra_ambient_config: Option<&'a Path>,
    extra_ambient_config_values: &'a HashMap<String, serde_norway::Value>,
}

impl<'a> AmbientRunner<'a> {
    fn new(
        tmp: &'a Path,
        adminlog: &'a mut AdminLog,
        dry_run: bool,
        projects: &'a Projects,
        projects_filename: PathBuf,
        extra_ambient_config: Option<&'a Path>,
        extra_ambient_config_values: &'a HashMap<String, serde_norway::Value>,
    ) -> Self {
        Self {
            tmp,
            adminlog,
            dry_run,
            projects,
            projects_filename,
            extra_ambient_config_filename: tmp.join(AMBIENT_EXTRA_CONFIG_BASENAME),
            extra_ambient_config,
            extra_ambient_config_values,
        }
    }

    fn local_run(&mut self) -> Result<CiOutput, AdapterError> {
        self.adminlog
            .writeln("execute_ci: run local ambient")
            .map_err(AdapterError::AdminLog)?;

        let mut cmd = Command::new("env");
        self.add_ambient_run_args(&mut cmd)?;
        let (exit, stdout) = self.execute_prepared_ambient_run_command(cmd)?;

        // Get the run log from `ambient`.
        let (_, run_log) = self.local_log()?;

        self.adminlog.writeln("run_ambient_ci: OK")?;

        Ok(CiOutput {
            exit,
            stdout,
            stderr: vec![],
            run_log,
        })
    }

    fn remote_run(&mut self, ssh_target: &str) -> Result<CiOutput, AdapterError> {
        self.adminlog
            .writeln("execute_ci: run remote ambient")
            .map_err(AdapterError::AdminLog)?;

        let remote_tmp = self.mkdtemp(ssh_target)?;

        assert!(self.projects_filename.ends_with(PROJECTS_FILENAME));
        self.projects_filename = remote_tmp.join(PROJECTS_FILENAME);
        self.extra_ambient_config_filename = remote_tmp.join(AMBIENT_EXTRA_CONFIG_BASENAME);

        let mut cmd = Command::new("ssh");
        cmd.arg("--");
        cmd.arg(ssh_target);
        cmd.arg("env");
        self.add_ambient_run_args(&mut cmd)?;

        self.rsync_to(ssh_target, self.tmp, &remote_tmp)?;

        let (exit, stdout) = self.execute_prepared_ambient_run_command(cmd)?;

        // Get the run log from `ambient`.
        let (_, run_log) = self.remote_log(ssh_target)?;

        if exit == 0 {
            self.rmtree(ssh_target, &remote_tmp)?;
        }

        self.adminlog.writeln("run_ambient_ci: OK")?;
        Ok(CiOutput {
            exit,
            stdout,
            stderr: vec![],
            run_log,
        })
    }

    fn mkdtemp(&self, ssh_target: &str) -> Result<PathBuf, AdapterError> {
        let cmd = Ssh::new(ssh_target, Args::from(vec!["mktemp", "-d"])).command();
        let mut runner = CommandRunner::new(cmd);
        runner.capture_stdout();
        let output = runner
            .execute()
            .map_err(|err| AdapterError::Mkdtemp(ssh_target.into(), err))?;
        let filename = if let Some(prefix) = output.stdout.strip_suffix(b"\n") {
            prefix
        } else {
            &output.stdout
        };
        let filename = OsString::from_vec(filename.to_vec());
        let filename = PathBuf::from(filename);
        Ok(filename)
    }

    fn rmtree(&self, ssh_target: &str, path: &Path) -> Result<(), AdapterError> {
        let cmd = Ssh::new(ssh_target, Args::from(vec!["rm", "-rf"]).arg(path)).command();
        CommandRunner::new(cmd)
            .execute()
            .map_err(|err| AdapterError::Mkdtemp(ssh_target.into(), err))?;
        Ok(())
    }

    fn rsync_to(&self, ssh_target: &str, src: &Path, dest: &Path) -> Result<(), AdapterError> {
        let mut cmd = Command::new("rsync");
        cmd.arg("-a");
        cmd.arg("--del");
        cmd.arg(src.join("."));
        cmd.arg(format!("{ssh_target}:{}/.", dest.display()));
        let runner = CommandRunner::new(cmd);
        runner
            .execute()
            .map_err(|err| AdapterError::Mkdtemp(ssh_target.into(), err))?;
        Ok(())
    }

    fn execute_prepared_ambient_run_command(
        &mut self,
        mut cmd: Command,
    ) -> Result<(i32, Vec<u8>), AdapterError> {
        self.adminlog.writeln(&format!("cmd={cmd:#?}"))?;

        // Run `ambient`.
        let (exit, stdout): (i32, Vec<u8>) = if self.dry_run {
            self.adminlog
                .writeln("run_ambient_ci: pretend to run ambient")?;
            (0, vec![])
        } else {
            self.adminlog
                .writeln("run_ambient_ci: run ambient for real")?;
            let (mut r, w) = pipe().unwrap();
            let mut child = cmd
                .stdin(Stdio::null())
                .stdout(w.try_clone().unwrap())
                .stderr(w)
                .spawn()
                .map_err(|err| AdapterError::Command("ambient", err))?;

            std::mem::drop(cmd);
            let mut buf = vec![];
            r.read_to_end(&mut buf).unwrap();

            let status = child.wait().unwrap();

            let exit = status.code().unwrap_or(NO_EXIT);
            self.adminlog
                .writeln(&format!("run_ambient_ci: exit={exit}"))?;

            if exit != 0 {
                indented(self.adminlog, "stdout+stderr", &buf);
            }

            (exit, buf)
        };

        self.adminlog.writeln("ambient finished")?;

        Ok((exit, stdout))
    }

    fn local_log(&mut self) -> Result<(i32, RunLog), AdapterError> {
        let mut cmd = Command::new("env");
        self.add_ambient_log_args(&mut cmd, &self.projects.project_name())?;
        self.execute_prepared_ambient_log(cmd)
    }

    fn remote_log(&mut self, ssh_target: &str) -> Result<(i32, RunLog), AdapterError> {
        let mut cmd = Command::new("ssh");
        cmd.arg("--");
        cmd.arg(ssh_target);
        cmd.arg("env");
        self.add_ambient_log_args(&mut cmd, &self.projects.project_name())?;
        self.execute_prepared_ambient_log(cmd)
    }

    fn execute_prepared_ambient_log(
        &mut self,
        cmd: Command,
    ) -> Result<(i32, RunLog), AdapterError> {
        let (exit, stdout) = self.execute_helper(cmd)?;

        // FIXME: Ambient 0.13.0 has a bug where the `ambient log --format=json`
        // command doens't output just JSON. We work around that by filtering
        // out non-JSON.
        let mut new = vec![];
        for line in String::from_utf8_lossy(&stdout).lines() {
            if line.starts_with("{") {
                new.extend_from_slice(line.as_bytes());
                new.push(b'\n');
            }
        }
        let stdout = new;

        // self.adminlog
        //     .writeln(&format!(
        //         "THIS SHOULD BE JSON: {}",
        //         String::from_utf8_lossy(&stdout)
        //     ))
        //     .ok();
        let run_log = RunLog::parse_jsonl(stdout).map_err(AdapterError::RunLog)?;
        Ok((exit, run_log))
    }

    fn execute_helper(&mut self, mut cmd: Command) -> Result<(i32, Vec<u8>), AdapterError> {
        self.adminlog.writeln(&format!("cmd={cmd:#?}"))?;
        if self.dry_run {
            self.adminlog.writeln("pretend to run ambient log")?;
            Ok((0, vec![]))
        } else {
            let output = cmd
                .output()
                .map_err(|err| AdapterError::Command("ambient log", err))?;
            let exit = output.status.code().unwrap_or(NO_EXIT);
            self.adminlog.writeln(&format!("get_log: exit={exit}"))?;

            if exit != 0 {
                indented(self.adminlog, "stdout", &output.stdout);
                indented(self.adminlog, "stderr", &output.stderr);
            }

            Ok((exit, output.stdout))
        }
    }

    fn add_ambient_run_args(&mut self, cmd: &mut Command) -> Result<(), AdapterError> {
        cmd.args(["AMBIENT_LOG=debug", "ambient"]);

        // If requested add an extra config file.
        if let Some(extra) = &self.extra_ambient_config {
            cmd.arg("--config");
            cmd.arg(extra);
        }

        // If requested create a new config file with specific config
        // values, and add that to the argument list.
        if !self.extra_ambient_config_values.is_empty() {
            let yaml: String = serde_norway::to_string(&self.extra_ambient_config_values)
                .map_err(AdapterError::AmbientConfigToYaml)?;

            // We write it to the _local_ temp, since that's where we can write it.
            let filename = self.tmp.join(AMBIENT_EXTRA_CONFIG_BASENAME);
            self.adminlog
                .writeln(&format!(
                    "ambient extra config: {} (local {})",
                    self.extra_ambient_config_filename.display(),
                    filename.display()
                ))
                .ok();
            std::fs::write(filename, &yaml).map_err(AdapterError::WriteConfig)?;

            cmd.arg("--config");

            // However, use the filename set in self so that it'll be correct on the
            // remote end, if any.
            cmd.arg(&self.extra_ambient_config_filename);
        }

        cmd.args(["run", "--force", "--projects"]);
        cmd.arg(&self.projects_filename);
        Ok(())
    }

    fn add_ambient_log_args(
        &mut self,
        cmd: &mut Command,
        project_name: &str,
    ) -> Result<(), AdapterError> {
        cmd.args(["AMBIENT_LOG=debug", "ambient"]);

        // If requested add an extra config file.
        if let Some(extra) = &self.extra_ambient_config {
            cmd.arg("--config");
            cmd.arg(extra);
        }

        cmd.args(["log"]);
        cmd.arg(project_name);
        cmd.arg("--format=json");
        Ok(())
    }
}

fn create_projects_file(
    adminlog: &mut AdminLog,
    tmp: &Path,
    projects: &Projects,
) -> Result<PathBuf, AdapterError> {
    let projects_filename = tmp.join(PROJECTS_FILENAME);
    let projects = projects.write(&projects_filename)?;
    indented(adminlog, "projects", projects.as_bytes());
    Ok(projects_filename)
}

#[derive(Debug)]
pub struct CiOutput {
    pub exit: i32,
    pub stdout: Vec<u8>,
    pub stderr: Vec<u8>,
    pub run_log: RunLog,
}
