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

use clingwrap::config::{ConfigFile, ConfigLoader, ConfigValidator};
use radicle_ci_broker::msg::helper::{AdminLog, LogError};
use serde::{Deserialize, Serialize};
use time::{macros::format_description, OffsetDateTime};

#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
    /// Image to use for Ambient CI run.
    pub image: PathBuf,

    /// Directory where per-run directories are stored. Each run gets
    /// its own dedicated subdirectory.
    pub logdir: PathBuf,

    // File where native CI should write a log.
    log: PathBuf,

    /// Optional base URL to information about each run. The run ID is
    /// appended, with a slash if needed.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub base_url: Option<String>,

    /// Use the repository name as the Ambient project name, instead
    /// of the Radicle repository ID.
    trust_repository_name: Option<bool>,

    /// Give Ambient an extra configuration file, in addition to its
    /// default one.
    extra_ambient_config: Option<PathBuf>,

    /// Extra Ambient configuration, embedded here, not in a separate file.
    #[serde(default)]
    extra_ambient_config_values: HashMap<String, serde_norway::Value>,

    /// Allow post-plan actions? By default they are not allowed.
    allow_post_plan: Option<bool>,

    /// Run `ambient` on this host, over SSH, instead of locally. The argument
    /// is given to `ssh` as a target.
    ssh_target: Option<String>,

    /// Pathname of the Ambient image on the remote host to use in the generated
    /// projects file. Default is same as the `image` field.
    pub remote_image: Option<PathBuf>,
}

impl Config {
    pub fn load_via_env(env: &str) -> Result<Self, ConfigError> {
        let filename = std::env::var(env).map_err(|e| ConfigError::GetEnv(env.into(), e))?;
        let filename = Path::new(&filename);
        let mut loader = ConfigLoader::default();
        loader.allow_yaml(filename);
        let config = loader
            .load(None, None, &File::default())
            .map_err(|err| ConfigError::LoadConfig(filename.to_path_buf(), err))?;
        Ok(config)
    }

    pub fn open_log(&self) -> Result<AdminLog, ConfigError> {
        if self.log.is_dir() {
            let fmt = format_description!(
                "[year]-[month]-[day] [hour]:[minute]:[second].[subsecond digits:3].log"
            );
            let timestamp = OffsetDateTime::now_utc()
                .format(fmt)
                .map_err(|err| ConfigError::Timeformat(Box::new(err)))?;
            Ok(AdminLog::open(&self.log.join(timestamp))?)
        } else {
            Ok(AdminLog::open(&self.log)?)
        }
    }

    /// Return configuration serialized to JSON.
    pub fn as_json(&self) -> String {
        // We don't check the result: we know a configuration can
        // always be serialized to JSON.
        serde_json::to_string_pretty(self).unwrap()
    }

    pub fn trust_repository_name(&self) -> bool {
        self.trust_repository_name.unwrap_or(false)
    }

    pub fn allow_post_plan(&self) -> bool {
        self.allow_post_plan == Some(true)
    }

    pub fn extra_ambient_config(&self) -> Option<&Path> {
        self.extra_ambient_config.as_deref()
    }

    pub fn extra_ambient_config_values(&self) -> &HashMap<String, serde_norway::Value> {
        &self.extra_ambient_config_values
    }

    pub fn ssh_target(&self) -> Option<&str> {
        self.ssh_target.as_deref()
    }
}

#[derive(Debug, Default, Clone, Deserialize)]
struct File {
    image: Option<PathBuf>,
    logdir: Option<PathBuf>,
    log: Option<PathBuf>,
    base_url: Option<String>,
    trust_repository_name: Option<bool>,
    extra_ambient_config: Option<PathBuf>,
    extra_ambient_config_values: Option<HashMap<String, serde_norway::Value>>,
    allow_post_plan: Option<bool>,
    ssh_target: Option<String>,
    remote_image: Option<PathBuf>,
}

impl<'a> ConfigFile<'a> for File {
    type Error = ConfigError;
    fn merge(&mut self, other: Self) -> Result<(), Self::Error> {
        if let Some(v) = other.image {
            self.image = Some(v);
        }
        if let Some(v) = other.logdir {
            self.logdir = Some(v);
        }
        if let Some(v) = other.log {
            self.log = Some(v);
        }
        if let Some(v) = other.base_url {
            self.base_url = Some(v);
        }
        if let Some(v) = other.trust_repository_name {
            self.trust_repository_name = Some(v);
        }
        if let Some(v) = other.extra_ambient_config {
            self.extra_ambient_config = Some(v);
        }
        if let Some(v) = other.extra_ambient_config_values {
            self.extra_ambient_config_values = Some(v);
        }
        if let Some(v) = other.allow_post_plan {
            self.allow_post_plan = Some(v);
        }
        if let Some(v) = other.remote_image {
            self.remote_image = Some(v);
        }
        Ok(())
    }
}

impl ConfigValidator for File {
    type File = File;
    type Valid = Config;
    type Error = ConfigError;
    fn validate(&self, runtime: &Self::File) -> Result<Self::Valid, Self::Error> {
        let runtime = runtime.clone();
        let image = runtime.image.ok_or(ConfigError::Missing("image"))?;
        Ok(Config {
            image: image
                .canonicalize()
                .map_err(|err| ConfigError::Canonicalize(image, err))?,
            logdir: runtime.logdir.ok_or(ConfigError::Missing("logdir"))?,
            log: runtime.log.ok_or(ConfigError::Missing("log"))?,
            base_url: runtime.base_url,
            trust_repository_name: runtime.trust_repository_name,
            extra_ambient_config: runtime.extra_ambient_config,
            extra_ambient_config_values: runtime.extra_ambient_config_values.unwrap_or_default(),
            allow_post_plan: runtime.allow_post_plan,
            ssh_target: runtime.ssh_target,
            remote_image: runtime.remote_image,
        })
    }
}

#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    #[error("failed to format time as a string")]
    Timeformat(#[from] Box<time::error::Format>),

    #[error("failed to read configuration file {0}")]
    ReadConfig(PathBuf, #[source] std::io::Error),

    #[error("failed to parse configuration file as YAML: {0}")]
    ParseConfig(PathBuf, #[source] serde_norway::Error),

    #[error("failed to get environment variable {0}")]
    GetEnv(String, #[source] std::env::VarError),

    #[error(transparent)]
    Log(#[from] LogError),

    #[error("failed to load configuration file {0}")]
    LoadConfig(PathBuf, #[source] clingwrap::config::ConfigError),

    #[error("configuration value {0} is not set")]
    Missing(&'static str),

    #[error("failed to make image path absolute: {0}")]
    Canonicalize(PathBuf, #[source] std::io::Error),
}
