//! Handling of configuration files.

use std::{collections::HashSet, fs::read_to_string, path::Path, str::FromStr};

use garde::Validate;
use serde::{Deserialize, Serialize};

use crate::{
    ConfigOrigin,
    Error,
    core::{Context, Os, Purpose},
    file::ConfigTechnologySettings,
};

/// A thin wrapper around [`Os`] to allow for config specific serialization.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(into = "String", try_from = "String")]
pub struct ConfigOs(Os);

impl From<Os> for ConfigOs {
    fn from(value: Os) -> Self {
        Self(value)
    }
}

impl From<ConfigOs> for String {
    fn from(value: ConfigOs) -> Self {
        value.0.os_to_string()
    }
}

impl FromStr for ConfigOs {
    type Err = Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Ok(ConfigOs(Os::from_str(s)?))
    }
}

impl TryFrom<String> for ConfigOs {
    type Error = Error;

    fn try_from(value: String) -> Result<Self, Self::Error> {
        Self::from_str(&value)
    }
}

/// A thin wrapper around [`Purpose`] to allow for config specific serialization.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(into = "String", try_from = "String")]
pub struct ConfigPurpose(Purpose);

impl From<Purpose> for ConfigPurpose {
    fn from(value: Purpose) -> Self {
        Self(value)
    }
}

impl From<ConfigPurpose> for String {
    fn from(value: ConfigPurpose) -> Self {
        value.0.purpose_to_string()
    }
}

impl FromStr for ConfigPurpose {
    type Err = Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Ok(ConfigPurpose(Purpose::from_str(s)?))
    }
}

impl TryFrom<String> for ConfigPurpose {
    type Error = Error;

    fn try_from(value: String) -> Result<Self, Self::Error> {
        Self::from_str(&value)
    }
}

/// A thin wrapper around [`Context`] to allow for config specific serialization.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(into = "String", try_from = "String")]
pub struct ConfigContext(Context);

impl From<Context> for ConfigContext {
    fn from(value: Context) -> Self {
        Self(value)
    }
}

impl From<ConfigContext> for String {
    fn from(value: ConfigContext) -> Self {
        value.0.to_string()
    }
}

impl FromStr for ConfigContext {
    type Err = Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Ok(ConfigContext(Context::from_str(s)?))
    }
}

impl TryFrom<String> for ConfigContext {
    type Error = Error;

    fn try_from(value: String) -> Result<Self, Self::Error> {
        Self::from_str(&value)
    }
}

/// Settings for a specific `context` in a VOA `purpose`.
///
/// This is a specialization for a specific context, which overrides any default technology settings
/// set on an OS level.
#[derive(Debug, Deserialize, Serialize, Validate)]
pub struct ConfigContextSettings {
    /// The VOA purpose for this override.
    #[garde(skip)]
    pub(crate) purpose: ConfigPurpose,

    /// The VOA context for this override.
    #[garde(skip)]
    pub(crate) context: ConfigContext,

    /// The specific technology settings.
    #[garde(dive)]
    pub(crate) technology_settings: ConfigTechnologySettings,
}

impl ConfigContextSettings {
    /// Creates a new [`ConfigContextSettings`].
    pub fn new(
        purpose: impl Into<ConfigPurpose>,
        context: impl Into<ConfigContext>,
        technologies: ConfigTechnologySettings,
    ) -> Self {
        Self {
            purpose: purpose.into(),
            context: context.into(),
            technology_settings: technologies,
        }
    }

    /// Returns a reference to the [`Purpose`].
    pub fn purpose(&self) -> &Purpose {
        &self.purpose.0
    }

    /// Returns a reference to the [`Context`].
    pub fn context(&self) -> &Context {
        &self.context.0
    }

    /// Returns a reference to the [`ConfigTechnologySettings`].
    pub fn config_technology_settings(&self) -> &ConfigTechnologySettings {
        &self.technology_settings
    }
}

/// Validates that the in a [`ConfigFile`] technology defaults are set, if no context override
/// is provided.
///
/// # Errors
///
/// Returns an error, if the `technology_defaults` is [`None`] and `context_override` has no
/// entries.
fn validate_technology_defaults(
    context_override: &[ConfigContextSettings],
) -> impl FnOnce(&Option<ConfigTechnologySettings>, &()) -> garde::Result + '_ {
    move |technology_defaults, _| {
        if technology_defaults.is_none() && context_override.is_empty() {
            return Err(garde::Error::new(
                "must be set, if no context_override is defined".to_string(),
            ));
        }

        Ok(())
    }
}

/// Validates the `context_override` in a [`ConfigFile`].
///
/// # Errors
///
/// Returns an error, if the entries in `context_override` are not unique.
fn validate_context_override(
    context_override: &[ConfigContextSettings],
    _context: &(),
) -> garde::Result {
    let duplicates = {
        let mut duplicates = HashSet::new();
        for context_settings in context_override.iter() {
            let count = context_override
                .iter()
                .filter(|settings| {
                    context_settings.purpose() == settings.purpose()
                        && context_settings.context() == settings.context()
                })
                .count();

            if count > 1 {
                duplicates.insert((context_settings.purpose(), context_settings.context()));
            }
        }

        duplicates
    };

    if !duplicates.is_empty() {
        let mut dups_sorted = duplicates
            .iter()
            .map(|(purpose, context)| format!("{purpose}/{context}"))
            .collect::<Vec<_>>();
        dups_sorted.sort();
        return Err(garde::Error::new(format!(
            "cannot contain duplicates, but the following purpose/context combinations are listed more than once: {}",
            dups_sorted.join(", ")
        )));
    }

    Ok(())
}

#[derive(Clone, Copy, Debug)]
pub enum ConfigFileType {
    /// A default configuration file.
    Config,

    /// A drop-in configuration file.
    DropIn,
}

/// Representation of a VOA configuration file.
#[derive(Debug, Deserialize, Serialize, Validate)]
pub struct ConfigFile {
    /// Technology defaults for an OS.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    #[garde(custom(validate_technology_defaults(&self.contexts)))]
    pub(crate) default_technology_settings: Option<ConfigTechnologySettings>,

    /// Context overrides for an OS.
    ///
    /// # Note
    ///
    /// This list can be empty, if `technology_defaults` is set, else there has to be at least one
    /// entry.
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    #[garde(custom(validate_context_override))]
    #[garde(dive)]
    pub(crate) contexts: Vec<ConfigContextSettings>,
}

impl ConfigFile {
    /// Creates a new [`ConfigFile`].
    pub fn new(
        technology_settings: Option<ConfigTechnologySettings>,
        context_override: Vec<ConfigContextSettings>,
    ) -> Result<Self, Error> {
        let settings = Self {
            default_technology_settings: technology_settings,
            contexts: context_override,
        };
        settings.validate().map_err(|source| Error::Validation {
            context: "creating an override configuration file".to_string(),
            source,
        })?;

        Ok(settings)
    }

    /// Creates a [`ConfigFile`] from a string slice containing YAML data.
    pub fn from_yaml_str(s: &str) -> Result<Self, Error> {
        serde_saphyr::from_str(s).map_err(|source| Error::YamlDeserialize {
            context: "creating an OS override configuration from a YAML string".to_string(),
            source,
        })
    }

    /// Creates a [`ConfigFile`] from a file containing YAML data.
    ///
    /// The additional `file_type` sets the origin according to whether the file is a default
    /// configuration file or a drop-in configuration file.
    pub fn from_yaml_file(
        path: impl AsRef<Path>,
        file_type: ConfigFileType,
    ) -> Result<Self, Error> {
        let path = path.as_ref();
        let data = read_to_string(path).map_err(|source| Error::IoPath {
            path: path.to_path_buf(),
            context: "reading the file to string",
            source,
        })?;

        let mut settings = Self::from_yaml_str(&data).map_err(|error| {
            if let Error::Validation { source, .. } = error {
                Error::Validation {
                    context: format!("creating an OS override configuration from file {path:?}"),
                    source,
                }
            } else {
                error
            }
        })?;

        // Add the path from which the ConfigTechnologySettings were added to any OS-level and
        // context-level.
        if let Some(defaults) = settings.default_technology_settings.as_mut() {
            defaults.origins.push(match file_type {
                ConfigFileType::Config => ConfigOrigin::ConfigFile(path.to_path_buf()),
                ConfigFileType::DropIn => ConfigOrigin::DropInFile(path.to_path_buf()),
            });
        }
        settings.contexts.iter_mut().for_each(|settings| {
            settings.technology_settings.origins.push(match file_type {
                ConfigFileType::Config => ConfigOrigin::ConfigFile(path.to_path_buf()),
                ConfigFileType::DropIn => ConfigOrigin::DropInFile(path.to_path_buf()),
            })
        });

        Ok(settings)
    }

    /// Serializes `self` as a YAML string.
    pub fn to_yaml_string(&self) -> Result<String, Error> {
        serde_saphyr::to_string(&self).map_err(|source| Error::YamlSerialize {
            context: "serializing OS settings",
            source,
        })
    }

    /// Returns the optional [`ConfigTechnologySettings`].
    pub fn default_technology_settings(&self) -> Option<&ConfigTechnologySettings> {
        self.default_technology_settings.as_ref()
    }

    /// Returns a reference to the list of [`ConfigContextSettings`].
    pub fn contexts(&self) -> &[ConfigContextSettings] {
        &self.contexts
    }

    /// Returns a reference to the list of config file origins.
    ///
    /// # Note
    ///
    /// This returns an empty list, if neither settings nor context settings contain origins.
    /// However, this can not happen, as the validation of [`ConfigFile`] does not allow it.
    pub fn origins(&self) -> &[ConfigOrigin] {
        if let Some(settings) = self.default_technology_settings() {
            &settings.origins
        } else if let Some(context_settings) = self.contexts().first() {
            &context_settings.technology_settings.origins
        } else {
            &[]
        }
    }
}

#[cfg(test)]
mod tests {
    use std::{
        collections::HashSet,
        fmt::Display,
        num::{NonZeroU8, NonZeroUsize},
        path::PathBuf,
        str::FromStr,
        thread::current,
    };

    use insta::{assert_snapshot, with_settings};
    use rstest::rstest;
    use testresult::TestResult;

    use super::*;
    use crate::{
        core::{Context, Purpose},
        file::{
            ConfigOpenpgpSettings,
            ConfigTrustAnchorMode,
            ConfigVerificationMethod,
            ConfigWebOfTrustMode,
            ConfigWebOfTrustRoot,
        },
        openpgp::{
            DomainName,
            NumCertifications,
            NumDataSignatures,
            OpenpgpFingerprint,
            TrustAmountFlow,
            TrustAmountPartial,
            TrustAmountRoot,
        },
    };

    const SNAPSHOT_PATH: &str = "fixtures/config_file/";

    #[derive(Debug)]
    enum ConfigDataRepresentation {
        Concise,
        Full,
    }

    impl Display for ConfigDataRepresentation {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            write!(
                f,
                "{}",
                match self {
                    Self::Concise => "concise",
                    Self::Full => "full",
                }
            )
        }
    }

    /// Ensures that [`ConfigFile::new`] fails on duplicate entries in `context_override`.
    #[test]
    fn config_file_new_fails_on_duplicate_context_settings() -> TestResult {
        match ConfigFile::new(
            None,
            vec![
                ConfigContextSettings::new(
                    Purpose::from_str("purpose")?,
                    Context::from_str("context")?,
                    ConfigTechnologySettings::default(),
                ),
                ConfigContextSettings::new(
                    Purpose::from_str("purpose")?,
                    Context::from_str("context")?,
                    ConfigTechnologySettings::default(),
                ),
            ],
        ) {
            Ok(settings) => panic!(
                "Should have failed with Error::Validation, but succeeded instead: {settings:?}"
            ),
            Err(Error::Validation { source, .. }) => {
                assert!(source.to_string().contains("cannot contain duplicates"))
            }
            Err(error) => {
                panic!("Should have failed with Error::Validation, but failed differently: {error}")
            }
        }

        Ok(())
    }

    /// Ensures that the string representation of [`ConfigFile`] remains
    /// consistent when using a default OS-specific technology configuration.
    #[rstest]
    #[case::yaml_concise(ConfigDataRepresentation::Concise)]
    #[case::yaml_full(ConfigDataRepresentation::Full)]
    fn config_file_default_string_representation(
        #[case] data_representation: ConfigDataRepresentation,
    ) -> TestResult {
        let description = "Configuration with default technology settings and no context-level technology settings";
        let config = match data_representation {
            ConfigDataRepresentation::Concise => {
                ConfigFile::new(Some(ConfigTechnologySettings::default()), Vec::new())?
            }
            ConfigDataRepresentation::Full => ConfigFile::new(
                Some(ConfigTechnologySettings::new(
                    vec![ConfigOrigin::DropInFile(PathBuf::from(
                        "/usr/share/voa/example.yaml.d/10-example.yml",
                    ))],
                    Some(ConfigOpenpgpSettings::new(
                        Some(NumDataSignatures::default()),
                        ConfigVerificationMethod::TrustAnchor(ConfigTrustAnchorMode::new(
                            Some(NumCertifications::default()),
                            Some(HashSet::new()),
                            Some(HashSet::new()),
                        )?),
                    )?),
                )),
                Vec::new(),
            )?,
        };
        let config_str = config.to_yaml_string()?;

        with_settings!({
            description => format!("{data_representation}: {description}"),
            snapshot_path => SNAPSHOT_PATH,
            prepend_module_to_snapshot => false,
        }, {
            assert_snapshot!(current().name().expect("current thread should have a name").to_string().replace("::", "__"), config_str);
        });

        Ok(())
    }

    /// Ensures that the string representation of [`ConfigFile`] remains
    /// consistent when providing an OS specific technology configuration.
    #[test]
    fn config_file_as_yaml_defaults_with_wot_openpgp() -> TestResult {
        let description = "Configuration with OS-level technology settings using the 'web of trust' verification method for OpenPGP and no context-level technology settings";
        let config = ConfigFile::new(
            Some(ConfigTechnologySettings::new(
                vec![ConfigOrigin::DropInFile(PathBuf::from(
                    "/usr/share/voa/example.yaml.d/10-example.yml",
                ))],
                Some(ConfigOpenpgpSettings::new(
                    Some(NumDataSignatures::new(
                        NonZeroUsize::new(2).expect("2 is larger than 0"),
                    )),
                    ConfigVerificationMethod::WebOfTrust(ConfigWebOfTrustMode::new(
                        Some(TrustAmountFlow::new(
                            NonZeroUsize::new(100).expect("100 is larger than 0"),
                        )),
                        Some(TrustAmountPartial::new(
                            NonZeroU8::new(50).expect("50 is larger than 0"),
                        )?),
                        Some(HashSet::from_iter([
                            ConfigWebOfTrustRoot::new(
                                OpenpgpFingerprint::from_str(
                                    "e242ed3bffccdf271b7fbaf34ed72d089537b42f",
                                )?,
                                Some(TrustAmountRoot::new(
                                    NonZeroU8::new(100).expect("100 is larger than 0"),
                                )?),
                            ),
                            ConfigWebOfTrustRoot::new(
                                OpenpgpFingerprint::from_str(
                                    "d3b0f7c0b825ecbb0f0d7398072947e7b1537b6f",
                                )?,
                                Some(TrustAmountRoot::new(
                                    NonZeroU8::new(120).expect("100 is larger than 0"),
                                )?),
                            ),
                            ConfigWebOfTrustRoot::new(
                                OpenpgpFingerprint::from_str(
                                    "b787a81c32997fd39a5f4c0188363902d3586e7b",
                                )?,
                                Some(TrustAmountRoot::new(
                                    NonZeroU8::new(110).expect("100 is larger than 0"),
                                )?),
                            ),
                        ])),
                        Some(HashSet::from_iter([DomainName::from_str("example.org")?])),
                    )),
                )?),
            )),
            Vec::new(),
        )?;
        let config_str = config.to_yaml_string()?;

        with_settings!({
            description => description,
            snapshot_path => SNAPSHOT_PATH,
            prepend_module_to_snapshot => false,
        }, {
            assert_snapshot!(current().name().expect("current thread should have a name").to_string().replace("::", "__"), config_str);
        });

        Ok(())
    }

    /// Ensures that the string representation of [`ConfigFile`] remains
    /// consistent when providing context specific technology configurations.
    #[test]
    fn config_file_as_yaml_default_trust_anchor_openpgp_context_trust_anchor_overrides()
    -> TestResult {
        let description = "Configuration with OS-level technology settings using the 'trust anchor' verification method for OpenPGP and two context-level technology settings overriding some settings.";
        let config = ConfigFile::new(
            Some(ConfigTechnologySettings::new(
                vec![ConfigOrigin::DropInFile(PathBuf::from(
                    "/usr/share/voa/example.yaml.d/10-example.yml",
                ))],
                Some(ConfigOpenpgpSettings::new(
                    Some(NumDataSignatures::new(
                        NonZeroUsize::new(2).expect("2 is larger than 0"),
                    )),
                    ConfigVerificationMethod::TrustAnchor(ConfigTrustAnchorMode::new(
                        Some(NumCertifications::new(
                            NonZeroUsize::new(4).expect("4 is larger than 0"),
                        )),
                        Some(HashSet::from_iter([DomainName::from_str("example.org")?])),
                        Some(HashSet::from_iter([
                            OpenpgpFingerprint::from_str(
                                "e242ed3bffccdf271b7fbaf34ed72d089537b42f",
                            )?,
                            OpenpgpFingerprint::from_str(
                                "d3b0f7c0b825ecbb0f0d7398072947e7b1537b6f",
                            )?,
                            OpenpgpFingerprint::from_str(
                                "b787a81c32997fd39a5f4c0188363902d3586e7b",
                            )?,
                            OpenpgpFingerprint::from_str(
                                "6132b58967cf1ebc05062492c17145e5ee9f82a8",
                            )?,
                            OpenpgpFingerprint::from_str(
                                "6eadeac2dade6347e87c0d24fd455feffa7069f0",
                            )?,
                        ])),
                    )?),
                )?),
            )),
            vec![
                ConfigContextSettings::new(
                    Purpose::from_str("package")?,
                    Context::from_str("default")?,
                    ConfigTechnologySettings::new(
                        vec![ConfigOrigin::DropInFile(PathBuf::from(
                            "/usr/share/voa/example.yaml.d/10-example.yml",
                        ))],
                        Some(ConfigOpenpgpSettings::new(
                            Some(NumDataSignatures::new(
                                NonZeroUsize::new(3).expect("3 is larger than 0"),
                            )),
                            ConfigVerificationMethod::TrustAnchor(ConfigTrustAnchorMode::new(
                                Some(NumCertifications::new(
                                    NonZeroUsize::new(5).expect("5 is larger than 0"),
                                )),
                                Some(HashSet::from_iter([DomainName::from_str(
                                    "packages.example.org",
                                )?])),
                                Some(HashSet::from_iter([
                                    OpenpgpFingerprint::from_str(
                                        "e242ed3bffccdf271b7fbaf34ed72d089537b42f",
                                    )?,
                                    OpenpgpFingerprint::from_str(
                                        "d3b0f7c0b825ecbb0f0d7398072947e7b1537b6f",
                                    )?,
                                    OpenpgpFingerprint::from_str(
                                        "b787a81c32997fd39a5f4c0188363902d3586e7b",
                                    )?,
                                    OpenpgpFingerprint::from_str(
                                        "6132b58967cf1ebc05062492c17145e5ee9f82a8",
                                    )?,
                                    OpenpgpFingerprint::from_str(
                                        "6eadeac2dade6347e87c0d24fd455feffa7069f0",
                                    )?,
                                ])),
                            )?),
                        )?),
                    ),
                ),
                ConfigContextSettings::new(
                    Purpose::from_str("image")?,
                    Context::from_str("installation-medium")?,
                    ConfigTechnologySettings::new(
                        vec![ConfigOrigin::DropInFile(PathBuf::from(
                            "/usr/share/voa/example.yaml.d/10-example.yml",
                        ))],
                        Some(ConfigOpenpgpSettings::new(
                            Some(NumDataSignatures::new(
                                NonZeroUsize::new(1).expect("1 is larger than 0"),
                            )),
                            ConfigVerificationMethod::TrustAnchor(ConfigTrustAnchorMode::new(
                                Some(NumCertifications::new(
                                    NonZeroUsize::new(3).expect("3 is larger than 0"),
                                )),
                                Some(HashSet::from_iter([DomainName::from_str(
                                    "packages.example.org",
                                )?])),
                                Some(HashSet::from_iter([
                                    OpenpgpFingerprint::from_str(
                                        "e242ed3bffccdf271b7fbaf34ed72d089537b42f",
                                    )?,
                                    OpenpgpFingerprint::from_str(
                                        "d3b0f7c0b825ecbb0f0d7398072947e7b1537b6f",
                                    )?,
                                    OpenpgpFingerprint::from_str(
                                        "b787a81c32997fd39a5f4c0188363902d3586e7b",
                                    )?,
                                    OpenpgpFingerprint::from_str(
                                        "6132b58967cf1ebc05062492c17145e5ee9f82a8",
                                    )?,
                                    OpenpgpFingerprint::from_str(
                                        "6eadeac2dade6347e87c0d24fd455feffa7069f0",
                                    )?,
                                ])),
                            )?),
                        )?),
                    ),
                ),
            ],
        )?;
        let config_str = config.to_yaml_string()?;

        with_settings!({
            description => description,
            snapshot_path => SNAPSHOT_PATH,
            prepend_module_to_snapshot => false,
        }, {
            assert_snapshot!(current().name().expect("current thread should have a name").to_string().replace("::", "__"), config_str);
        });

        Ok(())
    }

    /// Ensures that the string representation of [`ConfigFile`] remains
    /// consistent when only providing context specific technology configurations.
    #[test]
    fn config_file_as_yaml_context_trust_anchor_openpgp() -> TestResult {
        let description = "Configuration with no OS-level technology settings and one context-level technology settings using the 'trust anchor' verification method for OpenPGP.";
        let config = ConfigFile::new(
            None,
            vec![ConfigContextSettings::new(
                Purpose::from_str("package")?,
                Context::from_str("my-repo")?,
                ConfigTechnologySettings::new(
                    vec![ConfigOrigin::DropInFile(PathBuf::from(
                        "/usr/share/voa/example.yaml.d/10-example.yml",
                    ))],
                    Some(ConfigOpenpgpSettings::new(
                        Some(NumDataSignatures::new(
                            NonZeroUsize::new(3).expect("3 is larger than 0"),
                        )),
                        ConfigVerificationMethod::TrustAnchor(ConfigTrustAnchorMode::new(
                            Some(NumCertifications::new(
                                NonZeroUsize::new(5).expect("5 is larger than 0"),
                            )),
                            Some(HashSet::from_iter([DomainName::from_str(
                                "packages.example.org",
                            )?])),
                            Some(HashSet::from_iter([
                                OpenpgpFingerprint::from_str(
                                    "e242ed3bffccdf271b7fbaf34ed72d089537b42f",
                                )?,
                                OpenpgpFingerprint::from_str(
                                    "d3b0f7c0b825ecbb0f0d7398072947e7b1537b6f",
                                )?,
                                OpenpgpFingerprint::from_str(
                                    "b787a81c32997fd39a5f4c0188363902d3586e7b",
                                )?,
                                OpenpgpFingerprint::from_str(
                                    "6132b58967cf1ebc05062492c17145e5ee9f82a8",
                                )?,
                                OpenpgpFingerprint::from_str(
                                    "6eadeac2dade6347e87c0d24fd455feffa7069f0",
                                )?,
                            ])),
                        )?),
                    )?),
                ),
            )],
        )?;
        let config_str = config.to_yaml_string()?;

        with_settings!({
            description => description,
            snapshot_path => SNAPSHOT_PATH,
            prepend_module_to_snapshot => false,
        }, {
            assert_snapshot!(current().name().expect("current thread should have a name").to_string().replace("::", "__"), config_str);
        });

        Ok(())
    }
}
