//! Configuration file for Ambient CI application.

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

use byte_unit::Byte;
use clingwrap::{
    config::{ConfigFile, ConfigValidator},
    tildepathbuf::TildePathBuf,
};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};

use crate::{
    linter::{Linter, LinterError},
    project::Projects,
};

const QUAL: &str = "liw.fi";
const ORG: &str = "Ambient CI";
const APP: &str = env!("CARGO_PKG_NAME");

const DEFAULT_CPUS: usize = 1;
const DEFAULT_MEMORY: Byte = Byte::GIBIBYTE;

/// The run time configuration for `ambient`, loaded from files and
/// built in defaults and validated to be as correct as it can be at
/// the time of creation.
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(deny_unknown_fields)]
pub struct Config {
    tmpdir: PathBuf,
    image_store: PathBuf,
    projects: PathBuf,
    state: PathBuf,
    rsync_target: Option<String>,
    rsync_target_base: Option<String>,
    rsync_target_map: Option<HashMap<String, String>>,
    dput_target: Option<String>,
    executor: Option<PathBuf>,
    artifacts_max_size: Byte,
    cache_max_size: Byte,
    qemu: QemuConfig,
    uefi: bool,
    lint: bool,
    secrets: Option<Vec<SecretFile>>,
}

impl Config {
    /// Directory where temporary files are to be created.
    pub fn tmpdir(&self) -> &Path {
        &self.tmpdir
    }

    /// Location of image store.
    pub fn image_store(&self) -> &Path {
        &self.image_store
    }

    /// Projects file.
    pub fn projects(&self) -> &Path {
        &self.projects
    }

    /// Location of pre-project state directories
    pub fn state(&self) -> &Path {
        &self.state
    }

    /// Set `rsync_target`.
    pub fn set_rsync_target(&mut self, rsync_target: &str) {
        self.rsync_target = Some(rsync_target.into());
    }

    /// Target for the `rsync` action.
    pub fn rsync_target(&self) -> Option<&str> {
        self.rsync_target.as_deref()
    }

    /// Base target for `rsync` action, to be combined with per-project
    /// directory from `rsync_target_map`.
    pub fn rsync_target_base(&self) -> Option<&str> {
        self.rsync_target_base.as_deref()
    }

    /// Per-project directories to be combined with `rsync_target_base`
    /// for the `rsync` action.
    pub fn rsync_target_map(&self) -> Option<&HashMap<String, String>> {
        self.rsync_target_map.as_ref()
    }

    /// Get `rsync` target for a named project.
    pub fn rsync_target_for_project(&self, name: &str) -> Option<String> {
        fn join(base: &str, x: &str) -> Option<String> {
            Some(format!("{base}/{x}"))
        }

        match (
            &self.rsync_target,
            &self.rsync_target_base,
            &self.rsync_target_map,
        ) {
            (Some(target), _, _) => Some(target.to_string()),
            (None, None, _) => None,
            (None, Some(base), None) => join(base, name),
            (None, Some(base), Some(map)) => {
                if let Some(x) = map.get(name) {
                    join(base, x)
                } else {
                    join(base, name)
                }
            }
        }
    }

    /// Set `dput_target`.
    pub fn set_dput_target(&mut self, dput_target: &str) {
        self.dput_target = Some(dput_target.into());
    }

    /// Target for the `dput` action.
    pub fn dput_target(&self) -> Option<&str> {
        self.dput_target.as_deref()
    }

    /// Set `executor`.
    pub fn set_executor(&mut self, executor: &Path) {
        self.executor = Some(executor.into());
    }

    /// Program to use to execute runnable plan inside VM.
    pub fn executor(&self) -> Option<&Path> {
        self.executor.as_deref()
    }

    /// Should VM be booted with UEFI support?
    pub fn uefi(&self) -> bool {
        self.uefi
    }

    /// Lint projects, if requested.
    pub fn lint(&self, projects: &Projects) -> Result<(), LinterError> {
        if self.lint {
            Linter::new(self, projects).lint()
        } else {
            Ok(())
        }
    }

    /// Secrets to copy to post-plan VM.
    pub fn secrets(&self) -> &[SecretFile] {
        if let Some(files) = &self.secrets {
            files
        } else {
            &[]
        }
    }

    /// Number of CPUs to emulate in VM.
    pub fn cpus(&self) -> usize {
        self.qemu.cpus
    }

    /// Amount of RAM to allocation for VM.
    pub fn memory(&self) -> Byte {
        self.qemu.memory
    }

    /// QEMU/KVM binary for executing VM.
    pub fn kvm_binary(&self) -> PathBuf {
        self.qemu.kvm_binary.clone()
    }

    /// UEFI OVMF variables file to use.
    pub fn ovmf_vars_file(&self) -> PathBuf {
        self.qemu.ovmf_vars_file.clone()
    }

    /// UEFI OVMF code file to use.
    pub fn ovmf_code_file(&self) -> PathBuf {
        self.qemu.ovmf_code_file.clone()
    }

    /// Maximum size of per-project artifacts directory.
    pub fn artifacts_max_size(&self) -> u64 {
        self.artifacts_max_size.as_u64()
    }

    /// Maximum size of per-project cache directory.
    pub fn cache_max_size(&self) -> u64 {
        self.cache_max_size.as_u64()
    }

    /// Maximum size of per-project dependencies directory.
    pub fn dependencies_max_size(&self) -> u64 {
        100 * 1024 * 1024 * 1024 // Fixme
    }
}

/// The `Config::qemu` field.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct QemuConfig {
    cpus: usize,
    memory: Byte,
    kvm_binary: PathBuf,
    ovmf_vars_file: PathBuf,
    ovmf_code_file: PathBuf,
}

/// Files to copy to `/ci/secrets` for the post-plan VM.
#[derive(Debug, Default, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct SecretFile {
    /// File or directory to copy.
    pub source: PathBuf,

    /// Directory under `/ci/secrets` where to put the copy.
    pub dest: PathBuf,
}

/// This is a representation of an individual configuration file.
///
/// You probably want [`Config`], which is the result of merging some
/// number of individual files. it is also validated.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StoredConfig {
    /// Temporary directory to use. Default is either the value of the
    /// `TMPDIR` environment variable, if set, or `/tmp`.
    pub tmpdir: Option<TildePathBuf>,

    /// Location of the image store.
    pub image_store: Option<TildePathBuf>,

    /// The projects file to use.
    pub projects: Option<TildePathBuf>,

    /// The project state directory.
    pub state: Option<TildePathBuf>,

    /// Where to publish with the `rsync` action. This will be given
    /// to `rsync` as the "destination" argument.
    #[serde(alias = "target")]
    pub rsync_target: Option<String>,

    /// Like `rsync_target`, but will be combined with the per-project
    /// value from `rsync_target_map`.
    pub rsync_target_base: Option<String>,

    /// A per-project directory names to be combined with `rsync_target_base`.
    pub rsync_target_map: Option<HashMap<String, String>>,

    /// The `dput` target for uploading a Debian package in the `dput` action.
    pub dput_target: Option<String>,

    /// The program to upload to the VM to execute a runnable plan. Defaults to
    /// `ambient-execute-plan`.
    pub executor: Option<TildePathBuf>,

    /// Maximum size of the build artifacts directory for any one project.
    pub artifacts_max_size: Option<Byte>,

    /// Maximum size of the cache directory for any one project.
    pub cache_max_size: Option<Byte>,

    /// Maximum size of the dependencies directory for any one project.
    pub dependencies_max_size: Option<Byte>,

    /// Should VM be booted with UEFI support?
    pub uefi: Option<bool>,

    /// Run linter on projects? Defaults to true.
    pub lint: Option<bool>,

    /// List of files to copy to `/ci/secrets` in the VM, for post-plan actions.
    pub secrets: Option<Vec<SecretFile>>,

    /// Virtual machine QEMU configuration.
    #[serde(default)]
    pub qemu: StoredQemuConfig,

    /// Obsolete: use `qemu.cpus` instead.
    pub cpus: Option<usize>,

    /// Obsolete: use `qemu.memory` instead.
    pub memory: Option<Byte>,
}

impl<'a> ConfigFile<'a> for StoredConfig {
    type Error = ConfigError;

    fn merge(&mut self, other: Self) -> Result<(), Self::Error> {
        fn tildepathbuf(us: &mut Option<TildePathBuf>, them: &Option<TildePathBuf>) {
            if let Some(x) = them {
                *us = Some(x.clone());
            }
        }

        fn string(us: &mut Option<String>, them: &Option<String>) {
            if let Some(x) = them {
                *us = Some(x.into());
            }
        }

        fn byte(us: &mut Option<Byte>, them: &Option<Byte>) {
            if let Some(x) = them {
                *us = Some(*x);
            }
        }

        fn bool(us: &mut Option<bool>, them: &Option<bool>) {
            if let Some(x) = them {
                *us = Some(*x);
            }
        }

        fn yousize(us: &mut Option<usize>, them: &Option<usize>) {
            if let Some(x) = them {
                *us = Some(*x);
            }
        }

        fn secrets(us: &mut Option<Vec<SecretFile>>, them: &Option<Vec<SecretFile>>) {
            if let Some(x) = them {
                *us = Some(x.clone());
            }
        }

        if other.cpus.is_some() {
            eprintln!("deprecated: the `cpus` field is replaced by `qemu.cpus`");
        }
        if other.memory.is_some() {
            eprintln!("deprecated: the `memory` field is replaced by `qemu.memory`");
        }
        tildepathbuf(&mut self.tmpdir, &other.tmpdir);
        tildepathbuf(&mut self.image_store, &other.image_store);
        tildepathbuf(&mut self.projects, &other.projects);
        tildepathbuf(&mut self.state, &other.state);
        tildepathbuf(&mut self.executor, &other.executor);

        string(&mut self.rsync_target, &other.rsync_target);
        string(&mut self.rsync_target_base, &other.rsync_target_base);
        string(&mut self.dput_target, &other.dput_target);

        if let Some(map) = &other.rsync_target_map {
            self.rsync_target_map = Some(map.clone());
        }

        byte(&mut self.artifacts_max_size, &other.artifacts_max_size);
        byte(&mut self.cache_max_size, &other.cache_max_size);
        byte(
            &mut self.dependencies_max_size,
            &other.dependencies_max_size,
        );

        yousize(&mut self.qemu.cpus, &other.cpus);
        yousize(&mut self.qemu.cpus, &other.qemu.cpus);

        byte(&mut self.qemu.memory, &other.memory);
        byte(&mut self.qemu.memory, &other.qemu.memory);

        byte(&mut self.qemu.memory, &other.qemu.memory);
        tildepathbuf(&mut self.qemu.kvm_binary, &other.qemu.kvm_binary);
        tildepathbuf(&mut self.qemu.ovmf_code_file, &other.qemu.ovmf_code_file);
        tildepathbuf(&mut self.qemu.ovmf_vars_file, &other.qemu.ovmf_vars_file);

        bool(&mut self.uefi, &other.uefi);
        bool(&mut self.lint, &other.lint);

        secrets(&mut self.secrets, &other.secrets);

        Ok(())
    }
}

impl Default for StoredConfig {
    fn default() -> Self {
        let dirs = ProjectDirs::from(QUAL, ORG, APP).expect("have home directory");
        #[allow(clippy::unwrap_used)]
        let state = dirs.state_dir().unwrap();

        let tmp = std::env::var("TMPDIR")
            .map(PathBuf::from)
            .unwrap_or(PathBuf::from("/tmp"));

        Self {
            tmpdir: Some(TildePathBuf::new(tmp)),
            image_store: Some(TildePathBuf::new(state.join("images"))),
            projects: Some(dirs.config_dir().join("projects.yaml").into()),
            state: Some(TildePathBuf::new(state.join("projects"))),
            rsync_target: None,
            rsync_target_base: None,
            rsync_target_map: None,
            dput_target: None,
            executor: None,
            qemu: Default::default(),
            artifacts_max_size: Byte::MEBIBYTE.multiply(10),
            cache_max_size: Byte::GIBIBYTE.multiply(10),
            dependencies_max_size: Byte::GIBIBYTE.multiply(10),
            cpus: None,
            memory: None,
            uefi: None,
            lint: None,
            secrets: None,
        }
    }
}

impl ConfigValidator for StoredConfig {
    type File = StoredConfig;
    type Valid = Config;
    type Error = ConfigError;

    fn validate(&self, merged: &Self::File) -> Result<Self::Valid, Self::Error> {
        fn mkabs(name: &'static str, path: &Option<TildePathBuf>) -> Result<PathBuf, ConfigError> {
            if let Some(path) = path {
                let path = path.path();
                let path = std::path::absolute(path)
                    .map_err(|err| ConfigError::Absolute(path.to_path_buf(), err))?;
                Ok(path)
            } else {
                Err(ConfigError::Missing(name))
            }
        }

        if merged.cpus.is_some() {
            eprintln!("deprecated: the `cpus` field is replaced by `qemu.cpus`");
        }
        if merged.memory.is_some() {
            eprintln!("deprecated: the `memory` field is replaced by `qemu.memory`");
        }

        let qemu = QemuConfig {
            cpus: if let Some(cpus) = merged.qemu.cpus {
                cpus
            } else if let Some(cpus) = merged.cpus {
                cpus
            } else {
                DEFAULT_CPUS
            },
            memory: if let Some(memory) = merged.qemu.memory {
                memory
            } else if let Some(memory) = merged.memory {
                memory
            } else {
                DEFAULT_MEMORY
            },
            kvm_binary: mkabs("kvm_binary", &merged.qemu.kvm_binary)?,
            ovmf_vars_file: mkabs("ovmf_vars_file", &merged.qemu.ovmf_vars_file)?,
            ovmf_code_file: mkabs("ovmf_code_file", &merged.qemu.ovmf_code_file)?,
        };

        Ok(Config {
            tmpdir: mkabs("tmpdir", &merged.tmpdir)?,
            image_store: mkabs("image_store", &merged.image_store)?,
            projects: mkabs("projects", &merged.projects)?,
            state: mkabs("state", &merged.state)?,
            rsync_target: merged.rsync_target.clone(),
            rsync_target_base: merged.rsync_target_base.clone(),
            rsync_target_map: merged.rsync_target_map.clone(),
            dput_target: merged.dput_target.clone(),
            executor: merged.executor.as_ref().map(|path| path.path().into()),
            uefi: merged.uefi.unwrap_or_default(),
            lint: merged.lint.unwrap_or(true),
            secrets: merged.secrets.clone(),
            artifacts_max_size: merged
                .artifacts_max_size
                .ok_or(ConfigError::Missing("artifacts_max_size"))?,
            cache_max_size: merged
                .cache_max_size
                .ok_or(ConfigError::Missing("cache_max_size"))?,
            qemu,
        })
    }
}

/// Per-VM configuration.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct StoredQemuConfig {
    /// The QEMU/KVM binary to use.
    pub kvm_binary: Option<TildePathBuf>,

    /// The UEFI OVMF variables file to use. Default is `/usr/share/ovmf/OVMF.fd`.
    pub ovmf_vars_file: Option<TildePathBuf>,

    /// The UEFI OVMF code file to use. Default is `/usr/share/ovmf/OVMF.fd`.
    pub ovmf_code_file: Option<TildePathBuf>,

    /// Number of CPUs in the VM.
    pub cpus: Option<usize>,

    /// Amount of RAM to allocate for the VM.
    pub memory: Option<Byte>,
}

impl Default for StoredQemuConfig {
    fn default() -> Self {
        Self {
            cpus: None,
            memory: None,
            kvm_binary: Some(TildePathBuf::new("/usr/bin/kvm".into())),
            ovmf_vars_file: Some(TildePathBuf::new("/usr/share/ovmf/OVMF.fd".into())),
            ovmf_code_file: Some(TildePathBuf::new("/usr/share/ovmf/OVMF.fd".into())),
        }
    }
}

/// Errors from configuration file handling.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    /// Can't find home directory.
    #[error("failed to find home directory, while looking for configuration file")]
    ProjectDirs,

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

    /// Can't parse configuration file as YAML.
    #[error("failed to parse configuration file as YAML: {0}")]
    Yaml(PathBuf, #[source] serde_norway::Error),

    /// Programming error.
    #[error("programming error: stored config field {0} is missing")]
    Missing(&'static str),

    /// Can't load configuration files.
    #[error("failed to load configuration from files")]
    Load(#[source] clingwrap::config::ConfigError),

    /// Can't convert filename to absolute.
    #[error("failed to make filename absolute: {0}")]
    Absolute(PathBuf, #[source] std::io::Error),
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
    use super::*;

    #[test]
    fn does_not_merge_unset() {
        let stored = StoredConfig::default();
        let mut config = StoredConfig::default();

        assert!(stored.rsync_target.is_none());
        assert!(config.rsync_target.is_none());

        config.merge(stored).unwrap();
        assert!(config.rsync_target.is_none());
    }

    #[test]
    fn merges_set_value() {
        let stored = StoredConfig {
            tmpdir: Some(TildePathBuf::new(PathBuf::from("/yo"))),
            image_store: Some(TildePathBuf::new(PathBuf::from("/images"))),
            projects: Some(TildePathBuf::new(PathBuf::from("/projects.yaml"))),
            state: Some(TildePathBuf::new(PathBuf::from("/state"))),
            rsync_target: Some("xyzzy".into()),
            rsync_target_base: Some("plugh".into()),
            rsync_target_map: Some(HashMap::from([("yo".into(), "yo.liw.fi".into())])),
            dput_target: Some("colossal-cave".into()),
            executor: Some(TildePathBuf::new(PathBuf::from("/run-ci"))),
            artifacts_max_size: Some(Byte::MEBIBYTE),
            cache_max_size: Some(Byte::GIBIBYTE),
            dependencies_max_size: Some(Byte::MEBIBYTE),
            qemu: StoredQemuConfig {
                cpus: Some(42),
                memory: Some(Byte::TEBIBYTE),
                kvm_binary: Some(TildePathBuf::from(PathBuf::from("/run-ci"))),
                ovmf_code_file: Some(TildePathBuf::from(PathBuf::from("/ovmf-code"))),
                ovmf_vars_file: Some(TildePathBuf::from(PathBuf::from("/ovmf-vars"))),
            },
            uefi: Some(true),
            lint: Some(true),
            secrets: Some(vec![SecretFile {
                source: PathBuf::from("/etc/passwd"),
                dest: PathBuf::from("misc"),
            }]),
            cpus: Some(4),
            memory: Some(Byte::PEBIBYTE),
        };
        let mut config = StoredConfig::default();

        assert!(config.rsync_target.is_none());

        config.merge(stored.clone()).unwrap();
        assert_eq!(config.tmpdir.unwrap().path(), stored.tmpdir.unwrap().path());
        assert_eq!(
            config.image_store.unwrap().path(),
            stored.image_store.unwrap().path()
        );
        assert_eq!(
            config.projects.unwrap().path(),
            stored.projects.unwrap().path()
        );
        assert_eq!(config.state.unwrap().path(), stored.state.unwrap().path());
        assert_eq!(config.rsync_target, stored.rsync_target);
        assert_eq!(config.rsync_target_base, stored.rsync_target_base);
        assert_eq!(config.rsync_target_map, stored.rsync_target_map);
        assert_eq!(config.dput_target, stored.dput_target);
        assert_eq!(
            config.executor.unwrap().path(),
            stored.executor.unwrap().path(),
        );
        assert_eq!(config.uefi, Some(true));
        assert_eq!(config.lint, Some(true));
        assert_eq!(config.secrets, stored.secrets);
        assert_eq!(config.artifacts_max_size, stored.artifacts_max_size);
        assert_eq!(config.cache_max_size, stored.cache_max_size);
        assert_eq!(config.qemu.cpus, stored.qemu.cpus);
        assert_eq!(config.qemu.memory, stored.qemu.memory);
        assert_eq!(
            config.qemu.kvm_binary.unwrap().path(),
            stored.qemu.kvm_binary.unwrap().path()
        );
        assert_eq!(
            config.qemu.ovmf_code_file.unwrap().path(),
            stored.qemu.ovmf_code_file.unwrap().path()
        );
        assert_eq!(
            config.qemu.ovmf_vars_file.unwrap().path(),
            stored.qemu.ovmf_vars_file.unwrap().path()
        );
    }

    #[test]
    fn merges_legacy_value_into_qemu() {
        let stored = StoredConfig {
            qemu: StoredQemuConfig {
                cpus: None,
                memory: None,
                ..Default::default()
            },
            cpus: Some(4),
            memory: Some(Byte::PEBIBYTE),
            ..Default::default()
        };
        let mut config = StoredConfig::default();

        config.merge(stored.clone()).unwrap();
        assert_eq!(config.qemu.cpus, stored.cpus);
        assert_eq!(config.qemu.memory, stored.memory);
    }

    #[test]
    fn rsync_target_for_project_with_rsync_target_set() {
        let config = Config {
            rsync_target: Some("root@server:/".to_string()),
            rsync_target_base: Some("root@server:/srv/http".to_string()),
            rsync_target_map: Some(HashMap::from([("foo".to_string(), "foo".to_string())])),
            ..Default::default()
        };
        assert_eq!(
            config.rsync_target_for_project("bar"),
            Some("root@server:/".into())
        );
        assert_eq!(
            config.rsync_target_for_project("foo"),
            Some("root@server:/".into())
        );
    }

    #[test]
    fn rsync_target_for_project_with_base_and_map_only() {
        let config = Config {
            rsync_target_base: Some("root@server:/srv/http".to_string()),
            rsync_target_map: Some(HashMap::from([(
                "foo".to_string(),
                "foo-website".to_string(),
            )])),
            ..Default::default()
        };
        assert_eq!(
            config.rsync_target_for_project("bar"),
            Some("root@server:/srv/http/bar".into())
        );
        assert_eq!(
            config.rsync_target_for_project("foo"),
            Some("root@server:/srv/http/foo-website".into())
        );
    }
}
