use std::{
    env,
    path::{Path, PathBuf},
};

#[cfg(feature = "config-json")]
use figment::providers::Json;
#[cfg(feature = "config-yaml")]
use figment::providers::Yaml;
use figment::{
    providers::{Env, Format, Serialized, Toml},
    Figment, Profile,
};
use log::{debug, info};
use serde::{de::DeserializeOwned, Serialize};
use thiserror::Error;
#[cfg(feature = "xdg")]
use xdg;

use crate::str::to_train_case;

#[allow(clippy::large_enum_variant)]
#[derive(Error, Debug)]
pub enum ConfigError {
    /// The current directory could not be determined.
    #[error("Unable to determine the current directory.")]
    GetCurrentDir {
        #[from]
        source: std::io::Error,
    },
    /// None of the provided paths were valid configuration files.
    #[error("None of the provided paths were valid configuration files.")]
    NoValidPath,
    /// A config file format was not speicfied and we were unable to infer one
    /// from the path.
    #[error("Unable to derive config file format from extension.")]
    UnknownExtension,
    #[error("Unable to extract config from providers: {source}")]
    ExtractConfig {
        #[from]
        source: figment::Error,
    },
}

#[derive(Clone)]
pub enum ConfigFileFormat {
    #[cfg(feature = "config-json")]
    Json,
    Toml,
    #[cfg(feature = "config-yaml")]
    Yaml,
}

/// Attempts to read a Config object from the specified path.
///
/// The expected file format can be optionally specified. If a format is not
/// specified, the library will attempt to infer it from the file extension.
pub fn figment_from_file<P: AsRef<Path>>(
    path: P,
    format: Option<ConfigFileFormat>,
) -> Result<Figment, ConfigError> {
    let path = path.as_ref();

    let format = match format {
        Some(format) => format,
        None => infer_format_from_path(path)?,
    };

    info!("Reading config file from {}", path.display());

    let figment = Figment::new();

    let figment = match format {
        #[cfg(feature = "config-json")]
        ConfigFileFormat::Json => figment.merge(Json::file(path)),
        ConfigFileFormat::Toml => figment.merge(Toml::file(path)),
        #[cfg(feature = "config-yaml")]
        ConfigFileFormat::Yaml => figment.merge(Yaml::file(path)),
    };

    Ok(figment)
}

/// Attempts to read a Config object from the specified path.
///
/// The expected file format can be optionally specified. If a format is not
/// specified, the library will attempt to infer it from the file extension.
pub fn from_file<P: AsRef<Path>, C: DeserializeOwned>(
    path: P,
    format: Option<ConfigFileFormat>,
) -> Result<C, ConfigError> {
    extract(figment_from_file(path, format)?)
}

/// Attempts to read a Config object from the specified paths.
pub fn figment_from_paths<P: AsRef<Path>>(
    paths: Vec<(P, Option<ConfigFileFormat>)>,
) -> Result<Option<Figment>, ConfigError> {
    for (path, format) in paths {
        if path.as_ref().exists() {
            return Ok(Some(figment_from_file(path, format)?));
        }
    }

    Ok(None)
}

/// Attempts to read a Config object from the specified paths.
pub fn from_paths<P: AsRef<Path>, C: DeserializeOwned>(
    paths: Vec<(P, Option<ConfigFileFormat>)>,
) -> Result<C, ConfigError> {
    match figment_from_paths(paths)? {
        Some(figment) => extract(figment),
        None => Err(ConfigError::NoValidPath),
    }
}

/// Attempts to read a Config object from the current directory.
///
/// The configuration file is expected to be a file named `config.ext` where
/// `ext` is one of `json`, `toml`, or `yaml`.
pub fn from_current_dir<C: DeserializeOwned>(format: ConfigFileFormat) -> Result<C, ConfigError> {
    let current_path = get_current_dir_config_path(&format)?;

    from_file(current_path, Some(format))
}

/// Similar to `from_paths`. Uses a default set of paths:
///
/// - CURRENT_WORKING_DIRECTORY/config.toml
/// - /etc/CRATE/config.toml
pub fn from_default_paths<P: AsRef<Path>, C: DeserializeOwned>(
    application_name: &str,
    additional_paths: &[(P, Option<ConfigFileFormat>)],
    format: ConfigFileFormat,
) -> Result<C, ConfigError> {
    let mut paths = as_paths(additional_paths);

    paths.push((get_current_dir_config_path(&format)?, Some(format.clone())));
    paths.push((
        get_name_config_path(application_name, &format),
        Some(format),
    ));

    from_paths(paths)
}

/// Similar to `from_paths`. Uses a default set of paths:
///
/// - CURRENT_WORKING_DIRECTORY/config.toml
/// - /etc/CRATE/config.toml
pub fn from_defaults<P: AsRef<Path>, C: DeserializeOwned + Default + Serialize>(
    application_name: &str,
    additional_paths: &[(P, Option<ConfigFileFormat>)],
    format: ConfigFileFormat,
) -> Result<C, ConfigError> {
    let mut paths = as_paths(additional_paths);

    paths.push((get_current_dir_config_path(&format)?, Some(format.clone())));
    paths.push((
        get_name_config_path(application_name, &format),
        Some(format),
    ));

    let default_config: C = Default::default();
    let figment = Figment::from(Serialized::from(default_config, Profile::default()));

    let figment = match figment_from_paths(paths)? {
        Some(file_figment) => figment.merge(file_figment),
        None => figment,
    };

    let env_prefix = format!("{}_", to_train_case(application_name));

    debug!("Using env prefix: {}", &env_prefix);

    extract(figment.merge(Env::prefixed(&env_prefix)))
}

fn as_paths<P: AsRef<Path>>(
    path_refs: &[(P, Option<ConfigFileFormat>)],
) -> Vec<(PathBuf, Option<ConfigFileFormat>)> {
    let mut paths: Vec<(PathBuf, Option<ConfigFileFormat>)> = vec![];

    for (path, format) in path_refs {
        let mut pathbuf = PathBuf::new();

        pathbuf.push(path);

        paths.push((pathbuf, format.clone()));
    }

    paths
}

fn get_current_dir_config_path(format: &ConfigFileFormat) -> Result<PathBuf, ConfigError> {
    let mut current_path =
        env::current_dir().map_err(|source| ConfigError::GetCurrentDir { source })?;

    current_path.push(get_default_filename_from_format(format));

    Ok(current_path)
}

fn get_name_config_path(application_name: &str, format: &ConfigFileFormat) -> PathBuf {
    let mut config_path = PathBuf::from("/etc/");

    config_path.push(application_name);
    config_path.push(get_default_filename_from_format(format));

    config_path
}

#[cfg(feature = "xdg")]
pub fn get_name_xdg_config_path(
    application_name: &str,
    format: &ConfigFileFormat,
) -> Option<PathBuf> {
    let xdg_dirs = xdg::BaseDirectories::with_prefix(application_name).ok();

    if let Some(xdg_dirs) = xdg_dirs {
        xdg_dirs.find_config_file(get_default_filename_from_format(format))
    } else {
        None
    }
}

fn infer_format_from_path<P: AsRef<Path>>(path: P) -> Result<ConfigFileFormat, ConfigError> {
    match path.as_ref().extension() {
        Some(extension) => match extension.to_str() {
            Some("yaml") => Ok(ConfigFileFormat::Yaml),
            Some("yml") => Ok(ConfigFileFormat::Yaml),
            Some("json") => Ok(ConfigFileFormat::Json),
            Some("toml") => Ok(ConfigFileFormat::Toml),
            _ => Err(ConfigError::UnknownExtension),
        },
        _ => Err(ConfigError::UnknownExtension),
    }
}

fn get_default_filename_from_format(format: &ConfigFileFormat) -> &'static str {
    match format {
        #[cfg(feature = "config-json")]
        ConfigFileFormat::Json => "config.json",
        ConfigFileFormat::Toml => "config.toml",
        #[cfg(feature = "config-yaml")]
        ConfigFileFormat::Yaml => "config.yaml",
    }
}

fn extract<C: DeserializeOwned>(figment: Figment) -> Result<C, ConfigError> {
    figment
        .extract()
        .map_err(|source| ConfigError::ExtractConfig { source })
}