//! Configuration model and file parsing.

use std::{
    collections::HashSet,
    fs,
    io,
    path::{Path, PathBuf},
    sync::Mutex,
};

use aho_corasick::AhoCorasick;
use anyhow::Context as _;

use toml_edit::{
    DocumentMut,
    Item,
    Table,
    Value,
};
use crate::toml_edit_tree::Node;

use sequoia_directories::Component;
use sequoia_directories::Home;

use crate::Result;

/// A configuration.
///
/// To get a configuration, you normally convert a parsed
/// configuration file to a configuration using
/// [`ConfigFile::to_config`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
    /// The PKCS#11 modules.
    modules: Vec<PathBuf>,
}

impl Default for Config {
    fn default() -> Self {
        Config {
            modules: Vec::new(),
        }
    }
}

impl Config {
    /// Returns the PKCS#11 modules that should be loaded.
    pub fn modules(&self) -> &[PathBuf] {
        &self.modules
    }

    /// Converts the configuration to a configuration file.
    ///
    /// Warning: Don't update a configuration file by reading it
    /// using, e.g., [`ConfigFile::parse_home`], converting it back to
    /// a [`ConfigFile`] using this function, and then writing it to
    /// disk.  That is a lossy operation: it loses comments, and any
    /// `ignore_invalid` values.
    pub fn to_config_file(&self) -> Result<ConfigFile> {
        // Start with an empty document.
        let mut doc = DocumentMut::new();

        // Add the values.

        // modules.
        doc.as_table_mut()
            .insert("modules", Item::ArrayOfTables(Default::default()));

        let modules = doc
            .get_mut("modules").expect("created")
            .as_array_of_tables_mut().expect("invalid type");
        for file_name in self.modules.iter() {
            let mut table = Table::new();
            table.set(
                &"path".into(),
                file_name.to_str()
                    .ok_or_else(|| {
                        anyhow::anyhow!("Path ({}) is not valid UTF-8",
                                         file_name.display())
                    })?
                    .into())?;

            modules.push(table);
        }

        // Double check that it is well-formed.
        let mut config = Config::default();
        apply_schema(&mut Some(&mut config), None, doc.iter(),
                     TOP_LEVEL_SCHEMA)?;

        Ok(ConfigFile {
            doc,
            config: Mutex::new(Some(config.clone())),
        })
    }

    /// Adds the specified module to the set of PKCS#11 modules to
    /// load.
    pub fn add_module<P>(&mut self, path: P) -> &mut Self
        where P: Into<PathBuf>
    {
        self.modules.push(path.into());
        self
    }

    /// Adds the specified modules to the set of PKCS#11 modules to
    /// load.
    pub fn extend_modules<P>(&mut self, paths: impl Iterator<Item=P>)
        -> &mut Self
    where P: Into<PathBuf>
    {
        self.modules.extend(paths.map(|p| p.into()));
        self
    }
}

/// A validated configuration file.
///
/// This structure contains the raw document tree.
///
/// # Examples
///
/// ```toml
/// [[modules]]
/// path = "/some/pkcs11/libmod.so"
///
/// [[modules]]
/// path = "/other/pkcs11/libmod.so"
/// ```
#[derive(Debug, Default)]
pub struct ConfigFile {
    doc: DocumentMut,
    config: Mutex<Option<Config>>,
}

impl Clone for ConfigFile {
    fn clone(&self) -> Self {
        if let Ok(config) = self.config.try_lock() {
            Self {
                doc: self.doc.clone(),
                config: Mutex::new(config.clone()),
            }
        } else {
            Self {
                doc: self.doc.clone(),
                config: Mutex::new(None),
            }
        }
    }
}

impl ConfigFile {
    /// A template for the configuration containing the default
    /// values.
    const TEMPLATE: &'static str = "\
# Configuration template for sq-cryptoki <SQ-CRYPTOKI-VERSION>
<SQ-CRYPTOKI-CONFIG-PATH-HINT>

# [[module]]
# path = \"/path/to/some/pkcs11/module\"
#
# [[module]]
# path = \"/path/to/another/pkcs11/module\"
";

    /// Patterns to match on in `Self::DEFAULT` to be replaced with
    /// the default values.
    const TEMPLATE_PATTERNS: &'static [&'static str] = &[
        "<SQ-CRYPTOKI-VERSION>",
        "<SQ-CRYPTOKI-CONFIG-PATH-HINT>",
    ];

    /// Returns a configuration template with the defaults.
    ///
    /// The specified file is not actually read.  Instead, it embedded
    /// in configuration file as a comment so that the user can figure
    /// out where to save the template.
    ///
    /// If `path_hint` is `None`, no hint is embedded in the
    /// configuration file.
    fn config_template(path_hint: Option<&Path>) -> Result<String> {
        let ac = AhoCorasick::new(Self::TEMPLATE_PATTERNS)?;

        let mut path_hint = path_hint;
        let path_hint_;
        if let Some(ph) = path_hint.as_ref() {
            if let Some(home) = dirs::home_dir() {
                let home = home.display().to_string();
                if let Ok(rest) = ph.strip_prefix(&home) {
                    path_hint_ = PathBuf::from("$HOME").join(rest);
                    path_hint = Some(path_hint_.as_ref());
                }
            }
        };

        Ok(ac.replace_all(Self::TEMPLATE, &[
            &env!("CARGO_PKG_VERSION").to_string(),
            &if let Some(path_hint) = path_hint {
                format!(
                    "\n\
                     # To use it, edit it to your liking and write it to\n\
                     # {}",
                    &path_hint.display())
            } else {
                "".into()
            }
        ]))
    }

    /// Returns the default configuration in template form.
    ///
    /// All the configuration options with their defaults are
    /// commented out.
    ///
    /// The configuration file from the specified home directory is
    /// not read; the home directory is just used to determine the
    /// location of the configuration file, which is embedded in the
    /// returned configuration file as a hint.
    ///
    /// If `home` is `None`, no hint about the location of the
    /// configuration file is embedded in the returned template.
    pub fn default_template(home: Option<&Home>) -> Result<Self> {
        Self::parse_str(&Self::config_template(home.map(Self::file_name).as_deref())?)
    }

    /// Returns the absolute path of the config file for the specified
    /// home directory.
    pub fn file_name(home: &Home) -> PathBuf {
        home.config_dir(Component::Keystore)
            .join("cryptoki")
            .join("config.toml")
    }

    /// Reads and validates the configuration file from the default
    /// home directory.
    ///
    /// If the configuration file does not exist, the default
    /// configuration file is returned.  Any other error that occurs
    /// while reading the configuration is propagated to the caller.
    pub fn parse_default_home() -> Result<Self> {
        if let Some(home) = sequoia_directories::Home::default() {
            Self::parse_home(home)
        } else {
            Err(anyhow::anyhow!("Platform does not have a default home directory"))
        }
    }

    /// Reads and validates the configuration file from the specified
    /// home directory.
    ///
    /// If the configuration file does not exist, the default
    /// configuration file is returned.  Any other error that occurs
    /// while reading the configuration is propagated to the caller.
    pub fn parse_home(home: &Home) -> Result<Self> {
        Self::parse_file(&Self::file_name(home))
    }

    /// Reads and validates the configuration file from the specified
    /// file.
    ///
    /// The file must contain a valid toml document, which is
    /// consistent with the schema.
    ///
    /// If the file does not exist, the default configuration file (as
    /// returned by is used.  Any other error that occurs while
    /// reading the configuration is propagated to the caller.
    pub fn parse_file(path: &Path) -> Result<Self> {
        let config = match fs::read_to_string(&path) {
            Ok(r) => r,
            Err(e) if e.kind() == io::ErrorKind::NotFound =>
                Self::config_template(Some(path))?,
            Err(e) => return Err(anyhow::Error::from(e).context(
                format!("Reading configuration file {} failed",
                        path.display()))),
        };

        Self::parse_str(&config)
            .with_context(|| {
                format!("Reading configuration file {}", path.display())
            })
    }

    /// Reads and validates the configuration.
    ///
    /// The string must contain a valid toml document, which is
    /// consistent with the schema.
    pub fn parse_str(config: &str) -> Result<Self> {
        let doc: DocumentMut = config.parse()
            .context("Parsing configuration failed")?;

        let mut config = Config::default();
        apply_schema(&mut Some(&mut config), None, doc.iter(), TOP_LEVEL_SCHEMA)
            .context("Parsing configuration failed")?;

        Ok(Self {
            doc,
            config: Mutex::new(Some(config)),
        })
    }

    /// Writes the configuration to the home directory.
    pub fn persist_to_home(&self, home: &Home) -> Result<()> {
        let path = Self::file_name(home);
        self.persist(path)
    }

    /// Writes the configuration to the specified file.
    pub fn persist<P>(&self, path: P) -> Result<()>
    where P: AsRef<Path>
    {
        let path = path.as_ref();
        let dir = path.parent().unwrap();

        fs::create_dir_all(dir)?;

        let mut t = tempfile::NamedTempFile::new_in(dir)?;
        self.dump(&mut t)?;
        t.persist(path)?;

        Ok(())
    }

    /// Writes the configuration to the given writer.
    pub fn dump(&self, sink: &mut dyn io::Write) -> Result<()> {
        write!(sink, "{}", self.doc.to_string())?;
        Ok(())
    }

    /// Return a `Config` based on the parsed configuration file.
    ///
    /// This function is falliable, because a caller that modifies the
    /// document returned by [`ConfigFile::as_item_mut`] may modify
    /// the configuration to be invalid.
    pub fn to_config(&self) -> Result<Config> {
        let config = self.config.lock().unwrap();

        if let Some(config) = config.as_ref() {
            Ok(config.clone())
        } else {
            let mut c = Default::default();
            apply_schema(&mut Some(&mut c), None, self.doc.iter(),
                         TOP_LEVEL_SCHEMA)?;
            Ok(c)
        }
    }

    /// Returns the document tree.
    pub fn as_item(&self) -> &Item {
        self.doc.as_item()
    }

    /// Returns the mutable document tree.
    ///
    /// This allows the caller to modify the configuration file
    /// directly.  As such, the resulting configuration may no longer
    /// be valid.
    pub fn as_item_mut(&mut self) -> &mut Item {
        // Invalidate the parsed configuration.
        *self.config.lock().unwrap() = None;
        self.doc.as_item_mut()
    }
}

/// Validates a configuration section using a schema, and optionally
/// applies changes to the configuration.
///
/// Returns an error if a key is unknown and not explicitly ignored.
///
/// `schema`'s keys must be lowercase, and sorted.
fn apply_schema<'toml>(config: &mut Option<&mut Config>,
                       path: Option<&str>,
                       section: toml_edit::Iter<'toml>,
                       schema: Schema) -> Result<()> {
    let section = section.collect::<Vec<_>>();
    let known_keys: Vec<_> =
        schema.iter().map(|(key, _)| *key).collect();

    // Schema keys better be lowercase.
    debug_assert!(known_keys.iter().all(|&s| &s.to_lowercase() == s),
                  "keys in schema must be lowercase");

    // Schema keys better be sorted.
    debug_assert!(known_keys.windows(2).all(|v| v[0] <= v[1]),
                  "keys in schema must be sorted");
    // XXX: once [].is_sorted is stabilized:
    // debug_assert!(known_keys.is_sorted(), "keys in schema must be sorted");

    let prefix = if let Some(path) = path {
        format!("{}.", path)
    } else {
        "".to_string()
    };

    let keys: HashSet<&str> = section
        .iter().map(|(key, _value)| *key)
        .collect();

    // The set of allowed keys are the known keys, plus
    // "ignore_invalid", and the value of "ignore_invalid".
    let mut allowed_keys: Vec<&str> = known_keys.to_vec();
    if let Some(ignore) = section.iter()
        .find_map(|(k, v)| (*k == "ignore_invalid").then_some(*v))
    {
        allowed_keys.push("ignore_invalid");
        match ignore {
            Item::Value(Value::String(k)) =>
                allowed_keys.push(k.value().as_str()),
            Item::Value(Value::Array(ks)) => {
                for k in ks {
                    if let Value::String(k) = k {
                        allowed_keys.push(k.value().as_str());
                    } else {
                        Err(Error::ParseError(format!(
                            "'{}ignore_invalid' takes a string \
                             or an array of strings",
                            prefix)))?
                    }
                }
            }
            _ => {
                return Err(Error::ParseError(format!(
                    "Invalid value for '{}ignore_invalid': {}, \
                     expected a string or an array of strings",
                    prefix, ignore)).into());
            }
        }
    }

    // Now check if there are any unknown sections.
    let unknown_keys = keys
        .difference(&allowed_keys.into_iter().collect())
        .map(|s| *s)
        .collect::<Vec<_>>();
    if ! unknown_keys.is_empty() {
        return Err(Error::ParseError(format!(
            "{} has unknown keys: {}, valid keys are: {}",
            if let Some(path) = path {
                path
            } else {
                "top-level section"
            },
            unknown_keys.join(", "),
            // We don't include the keys listed in ignore_invalid.
            known_keys.join(", "))).into());
    }

    // Now validate the values.
    for (key, value) in &section {
        if let Ok(i) = schema.binary_search_by_key(key, |(k, _)| k) {
            let apply = schema[i].1;
            (apply)(config, &format!("{}{}", prefix, key), value)
                .with_context(|| format!("Error validating {:?}", key))?;
        }
    }

    Ok(())
}

/// Errors used in this module.
///
/// Note: This enum cannot be exhaustively matched to allow future
/// extensions.
#[non_exhaustive]
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum Error {
    /// Parse error
    #[error("Parse error: {0}")]
    ParseError(String),

    #[error("Configuration item {0:?} is not a {1} but a {2}")]
    BadType(String, &'static str, &'static str),
}

impl Error {
    /// Returns an `Error::BadType` given an item.
    fn bad_item_type(path: &str, i: &Item, want_type: &'static str)
                     -> anyhow::Error
    {
        Error::BadType(path.into(), want_type, i.type_name()).into()
    }

    /// Returns an `Error::BadType` given a value.
    #[allow(unused)]
    fn bad_value_type(path: &str, v: &Value, want_type: &'static str)
                      -> anyhow::Error
    {
        Error::BadType(path.into(), want_type, v.type_name()).into()
    }
}

/// A function that validates a node in the configuration tree with
/// the given path, and optionally adds it to the configuration.
type Applicator = fn(&mut Option<&mut Config>, &str, &Item)
                     -> Result<()>;

/// Ignores a node.
#[allow(unused)]
fn apply_nop(_: &mut Option<&mut Config>,
             _: &str, _: &Item)
             -> Result<()>
{
    Ok(())
}

/// A [`Schema`] maps keys to [`Applicator`]s.
type Schema = &'static [(&'static str, Applicator)];

/// Schema for the toplevel.
const TOP_LEVEL_SCHEMA: Schema = &[
    ("modules", apply_modules),
];

/// Schema for the `modules` section.
const MODULES_SCHEMA: Schema = &[
    ("path", apply_modules_path),
];

/// Validates the `modules` array of tables.
fn apply_modules(config: &mut Option<&mut Config>,
                 path: &str, item: &Item)
            -> Result<()>
{
    let tables = item.as_array_of_tables()
        .ok_or_else(|| Error::bad_item_type(path, item, "array of tables"))?;
    for (i, table) in tables.iter().enumerate() {
        let path = format!("{}.{}", path, i);
        apply_schema(config, Some(&path), table.iter(), MODULES_SCHEMA)?;
    }
    Ok(())
}

/// Validates the `modules.path` value.
fn apply_modules_path(config: &mut Option<&mut Config>,
                      path: &str, item: &Item)
                      -> Result<()>
{
    let path = item.as_str()
        .ok_or_else(|| Error::bad_item_type(path, item, "string"))?;

    if let Some(config) = config {
        config.modules.push(path.into());
    }

    Ok(())
}

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

    use toml_edit::Array;
    use toml_edit::Formatted;

    /// Checks that the configuration template matches the default
    /// configuration.
    #[test]
    fn default_template_is_default() {
        let default_template
            = ConfigFile::default_template(None).expect("invalid template")
            .to_config().expect("invalid template");

        assert_eq!(Config::default(), default_template);
    }

    /// Checks that a configure file is parsed correctly.
    #[test]
    fn config_file() {
        let modules = [ "/some/pkcs11.dll", "/other/pkcs11.dll" ];

        // A valid config with two modules.
        let base = modules.iter()
            .map(|module| {
                format!("\
[[modules]]
path = \"{}\"", module)
            })
            .collect::<Vec<String>>()
            .join("\n");

        let config = ConfigFile::parse_str(&base)
            .expect("invalid template")
            .to_config()
            .expect("invalid template");

        let modules = modules
            .into_iter()
            .map(PathBuf::from).collect::<Vec<PathBuf>>();
        assert_eq!(&config.modules(), &modules);

        let invalid = format!("{}
foo = \"blah\"", base);
        assert!(ConfigFile::parse_str(&invalid).is_err());

        let ignore_invalid = format!("{}
ignore_invalid = [ \"foo\", \"nonexistent\" ]", invalid);
        let config = ConfigFile::parse_str(&ignore_invalid)
            .expect("invalid template")
            .to_config()
            .expect("invalid template");

        assert_eq!(&config.modules(), &modules);
    }

    /// Checks that a configure file is parsed correctly.
    #[test]
    fn modify_config_file() {
        let modules = [ "/some/pkcs11.dll", "/other/pkcs11.dll" ];

        // A valid config with two modules.
        let base = modules.iter()
            .map(|module| {
                format!("\
[[modules]]
path = \"{}\"", module)
            })
            .collect::<Vec<String>>()
            .join("\n");

        let mut config_file = ConfigFile::parse_str(&base)
            .expect("invalid template");

        let config = config_file
            .to_config()
            .expect("invalid template");

        let modules = modules
            .into_iter()
            .map(PathBuf::from).collect::<Vec<PathBuf>>();
        assert_eq!(&config.modules(), &modules);

        // Add an unknown key to the configuration document and make
        // sure ConfigFile::to_config fails to reparse it.
        let doc = config_file.as_item_mut();
        doc.set(&"foo".into(), Value::String(Formatted::new("bar".to_string())))
            .expect("couldn't set");

        assert!(config_file.to_config().is_err());

        // Add the key to ignore_invalid.
        let doc = config_file.as_item_mut();
        let mut ignore_invalid = Array::new();
        ignore_invalid.push("foo");
        doc.set(&"ignore_invalid".into(), Value::Array(ignore_invalid))
            .expect("couldn't set");

        let config = config_file
            .to_config()
            .expect("invalid template");

        let modules = modules
            .into_iter()
            .map(PathBuf::from).collect::<Vec<PathBuf>>();
        assert_eq!(&config.modules(), &modules);
    }
}
