//! Obnam configuration file handling.

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

use clingwrap::config::{ConfigFile, ConfigLoader, ConfigValidator};
use serde::{Deserialize, Serialize};
use url::Url;

use crate::{
    credential::CredentialMethod,
    sop::{OpenPgpCert, OpenPgpKey, Sop, SopCard},
};

const QUAL: &str = "org.obnam";
const ORG: &str = "The Obnam project";
const APP: &str = "obnam";

/// Default SOP implementation to use.
pub const DEFAULT_SOP: &str = "rsop";

/// Default SOP-like program to use for OpenPGP cards.
pub const DEFAULT_SOP_CARD: &str = "rsoct";

/// The run time configuration for Obnam, merged from any number of
/// individual files and checked to be valid.
#[derive(Debug, Default, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
    client_name: String,
    repository: Option<PathBuf>,
    credentials: HashMap<String, CredentialSpec>,
    sop: PathBuf,
    sop_card: PathBuf,
    server_url: Option<Url>,
    server_token: Option<String>,
}

impl Config {
    /// Name of client.
    pub fn client_name<'a>(&'a self, forced: Option<&'a str>) -> &'a str {
        if let Some(v) = forced {
            v
        } else {
            &self.client_name
        }
    }

    /// The directory used as the backup repository.
    pub fn repository(&self) -> Option<&Path> {
        self.repository.as_deref()
    }

    /// Credentials. This may be an empty hash map if none were
    /// specified in the configuration files.
    pub fn credentials(&self) -> &HashMap<String, CredentialSpec> {
        &self.credentials
    }

    /// Return list of credential encryption and decryption methods
    /// for all specified credentials.
    pub fn credential_methods(&self) -> Vec<CredentialMethod> {
        let mut methods = vec![];
        for spec in self.credentials().values() {
            methods.push(spec.method(self));
        }
        methods
    }

    /// Name of SOP implementation to use.
    pub fn sop(&self) -> &Path {
        &self.sop
    }

    /// Name of SOP-like command to use when using OpenPGP cards
    /// for the private key.
    pub fn sop_card(&self) -> &Path {
        &self.sop_card
    }

    /// Format configuration as JSON.
    #[mutants::skip] // Can't figure a way to make this fail in a test.
    pub fn to_json(&self) -> Result<String, ObnamConfigError> {
        serde_json::to_string_pretty(self).map_err(ObnamConfigError::ToJson)
    }

    /// Return server URL, if any.
    pub fn server_url(&self) -> Option<&Url> {
        self.server_url.as_ref()
    }

    /// Return server API token, if any.
    pub fn server_token(&self) -> Option<&str> {
        self.server_token.as_deref()
    }
}

impl Config {
    /// Load the configuration from the default location, plus any additional files specified by user.
    #[mutants::skip] // this is tested by subplot
    pub fn load(extra: &[PathBuf], overrides: &File) -> Result<Self, ObnamConfigError> {
        let mut loader = ConfigLoader::default();
        loader.xdg(QUAL, ORG, APP);

        for filename in extra.iter() {
            loader.require_yaml(filename);
        }

        let valid = loader
            .load(None, Some(overrides.clone()), &File::default())
            .map_err(ObnamConfigError::Read)?;

        Ok(valid)
    }
}

/// A specification for a credential.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
#[serde(deny_unknown_fields)]
pub enum CredentialSpec {
    /// An OpenPGP software key credential.
    OpenOpenPgpSoft {
        /// Private OpenPGP software key.
        #[serde(rename = "openpgp-key")]
        openpgp_key: String,
    },
    /// An OpenPGP card credential.
    OpenOpenPgpCard {
        /// OpenPGP certificate.
        #[serde(rename = "openpgp-cert")]
        openpgp_cert: String,
    },
}

impl CredentialSpec {
    /// Return an implementation of the [`CredentialMethod`] for this specification.
    pub fn method(&self, config: &Config) -> CredentialMethod {
        match self {
            Self::OpenOpenPgpSoft { openpgp_key } => {
                let sop = Sop::new(config.sop());
                let openpgp_key = OpenPgpKey::new(openpgp_key.as_bytes());
                CredentialMethod::openpgp_soft(sop, openpgp_key)
            }
            Self::OpenOpenPgpCard { openpgp_cert } => {
                let sop_card = SopCard::new(config.sop(), config.sop_card());
                let openpgp_cert = OpenPgpCert::new(openpgp_cert.as_bytes());
                CredentialMethod::openpgp_card(sop_card, openpgp_cert)
            }
        }
    }
}

/// The on-disk configuration file.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[allow(missing_docs)]
pub struct File {
    /// Name of client. Defaults to hostname.
    pub client_name: Option<String>,

    /// The location of the backup repository.
    pub repository: Option<PathBuf>,

    /// A set of credentials for accessing the client chunk.
    #[serde(default)]
    pub credentials: HashMap<String, CredentialSpec>,

    /// Stateless OpenPGP (SOP) implementation to use.
    pub sop: Option<PathBuf>,

    /// SOP-like command to use with OpenPGP cards.
    pub sop_card: Option<PathBuf>,

    /// URL to Obnam server to use.
    pub server_url: Option<Url>,

    /// API token for Obnam server.
    pub server_token: Option<String>,
}

impl<'a> ConfigFile<'a> for File {
    type Error = ObnamConfigError;

    fn merge(&mut self, other: Self) -> Result<(), Self::Error> {
        if let Some(v) = &other.client_name {
            self.client_name = Some(v.into());
        }

        if let Some(v) = &other.repository {
            self.repository = Some(v.into());
        }

        if let Some(v) = &other.sop {
            self.sop = Some(v.into());
        }

        if let Some(v) = &other.sop_card {
            self.sop_card = Some(v.into());
        }

        for (k, v) in other.credentials.iter() {
            self.credentials.insert(k.to_string(), v.clone());
        }

        if let Some(v) = &other.server_url {
            self.server_url = Some(v.clone());
        }

        if let Some(v) = &other.server_token {
            self.server_token = Some(v.into());
        }

        Ok(())
    }
}

impl ConfigValidator for File {
    type File = File;
    type Valid = Config;
    type Error = ObnamConfigError;

    fn validate(&self, merged: &Self::File) -> Result<Self::Valid, Self::Error> {
        Ok(Config {
            client_name: if let Some(v) = &merged.client_name {
                v.into()
            } else {
                hostname()?
            },
            repository: merged.repository.to_owned(),
            credentials: merged.credentials.to_owned(),
            sop: merged.to_owned().sop.unwrap_or(PathBuf::from(DEFAULT_SOP)),
            sop_card: merged
                .sop_card
                .to_owned()
                .unwrap_or(PathBuf::from(DEFAULT_SOP_CARD)),
            server_url: merged.server_url.to_owned(),
            server_token: merged.server_token.to_owned(),
        })
    }
}

fn hostname() -> Result<String, ObnamConfigError> {
    hostname::get()
        .map(|os| String::from_utf8_lossy(os.as_encoded_bytes()).to_string())
        .map_err(ObnamConfigError::Hostname)
}

/// Errors from reading Obnam configuration files.
#[derive(Debug, thiserror::Error)]
pub enum ObnamConfigError {
    /// Can't load project directories.
    #[error("failed to determine project directories")]
    ProjectDirs,

    /// Can't read all configuration files.
    #[error("failed to read all configuration files")]
    Read(#[source] clingwrap::config::ConfigError),

    /// Can't validate.
    #[error("configuration is not valid")]
    Invalid(#[source] clingwrap::config::ConfigError),

    /// Configuration is missing a field.
    #[error("configuration does not set field {0}")]
    Missing(&'static str),

    /// Can't serialize to JSON.
    #[error("failed to serialize configuration to JSON")]
    ToJson(#[source] serde_json::Error),

    /// Can't get hostname.
    #[error("client name it not set and failed to get hostname")]
    Hostname(#[source] std::io::Error),
}

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

    #[test]
    fn merge_files() {
        let mut file = File::default();
        file.merge(File::default()).unwrap();
        assert!(file.repository.is_none());

        let other = File {
            client_name: Some("laptop".into()),
            repository: Some(PathBuf::from("/tmp/repo")),
            credentials: HashMap::from([(
                "softy".to_string(),
                CredentialSpec::OpenOpenPgpSoft {
                    openpgp_key: "TEST".to_string(),
                },
            )]),
            sop: Some(PathBuf::from("soppy")),
            sop_card: Some(PathBuf::from("soppy-card")),
            server_url: Some("http://example.com".try_into().unwrap()),
            server_token: Some("TOKEN".into()),
        };
        file.merge(other).unwrap();
        assert_eq!(file.client_name, Some("laptop".into()));
        assert_eq!(file.repository, Some(PathBuf::from("/tmp/repo")));
        assert!(file.credentials.contains_key("softy"));
        assert!(file.sop.is_some());
        assert!(file.sop_card.is_some());
    }

    #[test]
    fn valideted_config() {
        let file: File = serde_json::from_str(
            r#"{
    "client_name": "laptop",
    "repository": "/tmp/repo",
    "credentials": {
        "softy": {
            "openpgp-key": "TEST"
        }
    }
}"#,
        )
        .unwrap();
        let config = file.validate(&file).unwrap();
        assert_eq!(config.client_name(None), "laptop");
        assert_eq!(config.repository(), Some(Path::new("/tmp/repo")));
        assert!(config.credentials().contains_key("softy"));
        assert!(!config.credential_methods().is_empty());
        assert_eq!(config.sop(), DEFAULT_SOP);
        assert_eq!(config.sop_card(), DEFAULT_SOP_CARD);
    }
}
