//! The configuration file loader.

use std::{
    collections::BTreeMap,
    ffi::{OsStr, OsString},
    os::linux::fs::MetadataExt,
    path::{Path, PathBuf},
};

use directories::ProjectDirs;
use log::{debug, error, info, trace};
use voa_core::identifiers::Os;
use xdg::BaseDirectories;

use crate::{CONFIG_DIRS_SYSTEM_MODE, CONFIG_FILE_EXTENSION, file::ConfigFile};

/// The XDG qualifier name of the application.
const QUALIFIER_NAME: &str = "voa";

/// Collects all configuration directories relevant for a real user.
fn collect_user_mode_dirs() -> Vec<PathBuf> {
    // 1. Add the system-wide default location.
    let mut config_dirs = CONFIG_DIRS_SYSTEM_MODE
        .iter()
        .map(PathBuf::from)
        .collect::<Vec<_>>();

    // Look into the XDG Base Directory Specification with qualifier "voa",
    // organization "VOA", and application "VOA".
    //
    // Return early with system-wide default location if the user has no home.
    let Some(project_dirs) = ProjectDirs::from(QUALIFIER_NAME, "VOA", "VOA") else {
        error!("Unable to retrieve XDG dirs for current user as it has no home");
        return config_dirs;
    };
    // Get XDG base directory information.
    let xdg = BaseDirectories::with_prefix(QUALIFIER_NAME);

    // 2. Collect all entries in $XDG_CONFIG_DIRS or set its default value.
    {
        let mut xdg_config_dirs = xdg.get_config_dirs();
        xdg_config_dirs.reverse();

        if xdg_config_dirs.is_empty() {
            xdg_config_dirs.push(PathBuf::from("/etc/xdg/voa"));
        }

        config_dirs.append(&mut xdg_config_dirs);
    }

    // 3. $XDG_CONFIG_HOME/voa/
    config_dirs.push(project_dirs.config_dir().to_path_buf());

    config_dirs
}

/// The mode in which configuration files are loaded.
#[derive(Clone, Copy, Debug)]
pub enum LoadMode {
    /// System mode.
    ///
    /// Only load from system-wide locations.
    System,

    /// User mode.
    ///
    /// Load from system-wide locations and user-specific locations (in accordance with XDG Base
    /// Directory specification).
    User,
}

/// Loader for all valid default and drop-in configuration files.
#[derive(Debug)]
pub struct ConfigLoader {
    defaults: BTreeMap<Os, ConfigFile>,
    drop_ins: BTreeMap<Os, Vec<ConfigFile>>,
}

impl ConfigLoader {
    /// Creates a new [`ConfigLoader`] using a [`LoadMode`].
    ///
    /// If `mode` is [`LoadMode::System`] only system-wide locations are considered for the loading
    /// of default and drop-in configuration files.
    /// If `mode` is [`LoadMode::User`], system-wide and locations of the calling user are
    /// considered for the loading of default and drop-in configuration files.
    pub fn load(mode: LoadMode) -> Self {
        let (defaults, drop_ins) = match mode {
            LoadMode::System => (
                collect_default_config_files(CONFIG_DIRS_SYSTEM_MODE),
                collect_drop_in_config_files(CONFIG_DIRS_SYSTEM_MODE),
            ),
            LoadMode::User => {
                let dirs = collect_user_mode_dirs();
                (
                    collect_default_config_files(&dirs),
                    collect_drop_in_config_files(&dirs),
                )
            }
        };

        Self { defaults, drop_ins }
    }

    /// Returns the map of OS default configuration files.
    pub fn defaults(&self) -> &BTreeMap<Os, ConfigFile> {
        &self.defaults
    }

    /// Returns the map of OS drop-in configuration files.
    pub fn drop_ins(&self) -> &BTreeMap<Os, Vec<ConfigFile>> {
        &self.drop_ins
    }
}

/// Checks whether a path is masked.
///
/// Paths are considered masked if:
///
/// - they are a symlink to /dev/null
/// - they are empty regular files.
fn is_masked(path: impl AsRef<Path>) -> bool {
    let path = path.as_ref();

    let metadata = match path.symlink_metadata() {
        Ok(metadata) => metadata,
        Err(error) => {
            error!("Unable to retrieve metadata for config candidate {path:?}: {error}");
            return false;
        }
    };

    let Some(file_name) = path.file_name() else {
        error!("The config candidate {path:?} has no file name. Skipping...");
        return false;
    };

    if metadata.is_symlink() {
        let target_path = match path.read_link() {
            Ok(path) => path,
            Err(error) => {
                error!("Unable to read link target of symlink {path:?}: {error}");
                return false;
            }
        };
        if target_path.as_path() == Path::new("/dev/null") {
            info!(
                "The config candidate {path:?} is masked. Skipping all configs with the same file name: {file_name:?}"
            );
            return true;
        }
    }

    if metadata.is_file() && metadata.st_size() == 0 {
        info!(
            "The config candidate {path:?} is empty (masked). Skipping all configs with the same file name: {file_name:?}"
        );
        return true;
    }

    false
}

/// Collects all valid default config files from a list of input `dirs`.
///
/// Load logic follows the [Configuration Files specification].
/// Creates a map of target OSes and relevant file path locations.
/// All masked config files are ignored.
/// Default config files are sorted and read according to the UAPI config file specification.
///
/// [Configuration Files specification]: https://uapi-group.org/specifications/specs/configuration_files_specification/
fn collect_default_config_files(dirs: &[impl AsRef<Path>]) -> BTreeMap<Os, ConfigFile> {
    let config_paths = {
        let extension_check = Some(OsStr::new(CONFIG_FILE_EXTENSION));
        let mut config_paths: BTreeMap<Os, Vec<PathBuf>> = BTreeMap::new();

        for config_dir in dirs {
            let config_dir = config_dir.as_ref();
            let read_dir = match config_dir.read_dir() {
                Ok(read_dir) => read_dir,
                Err(error) => {
                    debug!("Unable to read directory {config_dir:?}: {error}");
                    continue;
                }
            };

            for dir_entry in read_dir {
                let dir_entry = match dir_entry {
                    Ok(dir_entry) => dir_entry,
                    Err(error) => {
                        debug!("Unable to read file in {config_dir:?}: {error}");
                        continue;
                    }
                };

                let config_path = dir_entry.path();

                // Skip files that don't use the correct extension.
                if config_path.extension() != extension_check {
                    trace!(
                        "The drop-in config path {config_path:?} does not use the {CONFIG_FILE_EXTENSION} extension. Skipping..."
                    );
                    continue;
                }

                // Remove the .yaml extension and use the file name to construct an OS identifier.
                let os = {
                    let without_yaml = config_path.with_extension("");
                    let Some(file_name) = without_yaml.file_name() else {
                        trace!(
                            "Path {config_path:?} has no file name after removing the .yaml extension. Skipping..."
                        );
                        continue;
                    };

                    match Os::try_from(file_name) {
                        Ok(os) => os,
                        Err(error) => {
                            error!(
                                "Invalid OS identifier in file name of path {config_path:?}: {error}"
                            );
                            continue;
                        }
                    }
                };

                if let Some(path_list) = config_paths.get_mut(&os) {
                    path_list.push(config_path);
                } else {
                    config_paths.insert(os, vec![config_path]);
                }
            }
        }

        config_paths
    };

    // For each OS, add the config file with the highest priority.
    let mut output: BTreeMap<Os, ConfigFile> = BTreeMap::new();
    for (os, path_list) in config_paths.into_iter() {
        // Skip all configuration files for the OS, if it is masked.
        // Relevant messages are emitted by the `is_masked` function.
        if path_list.as_slice().iter().any(is_masked) {
            continue;
        }

        let config_file_list = {
            let mut config_file_list: Vec<ConfigFile> = Vec::new();
            for path in path_list.iter() {
                // We are only interested in regular files.
                if !path.is_file() {
                    error!("The path {path:?} is not a file. Skipping...");
                    continue;
                }

                match ConfigFile::from_yaml_file(path, super::ConfigFileType::Config) {
                    Ok(config_file) => config_file_list.push(config_file),
                    Err(error) => {
                        error!("Failed to read config file from file path {path:?}: {error}")
                    }
                }
            }
            config_file_list
        };

        let Some(config_file) = config_file_list.into_iter().last() else {
            error!("No valid config file found for OS {os}");
            continue;
        };

        output.insert(os, config_file);
    }

    output
}

/// Collects drop-in directories from a list of `dirs`.
///
/// Ignores any paths or path entries in `dirs` that cannot be read.
/// Paths for drop-in configuration files are only valid if they
///
/// - are directories
/// - have the ".yaml.d" extension
fn collect_drop_in_dirs(dirs: &[impl AsRef<Path>]) -> BTreeMap<Os, Vec<PathBuf>> {
    let mut drop_in_dirs: BTreeMap<Os, Vec<PathBuf>> = BTreeMap::new();

    for config_dir in dirs {
        let config_dir = config_dir.as_ref();
        let read_dir = match config_dir.read_dir() {
            Ok(read_dir) => read_dir,
            Err(error) => {
                debug!("Unable to read directory {config_dir:?}: {error}");
                continue;
            }
        };

        for dir_entry in read_dir {
            let dir_entry = match dir_entry {
                Ok(dir_entry) => dir_entry,
                Err(error) => {
                    debug!("Unable to read entry in {config_dir:?}: {error}");
                    continue;
                }
            };

            let path = dir_entry.path();

            // Skip all non-directory paths.
            if !path.is_dir() {
                trace!("Non-directory path {path:?} cannot be a drop-in directory. Skipping...");
                continue;
            }

            // Skip all dirs without the ".yaml.d" extension.
            if path.extension() != Some(OsStr::new("d"))
                || path.with_extension("").extension() != Some(OsStr::new(CONFIG_FILE_EXTENSION))
            {
                trace!("Directory {path:?} has no .yaml.d extension. Skipping...");
                continue;
            }

            // Remove the .yaml.d extension and use the file name to construct an OS identifier.
            let os = {
                let without_d = path.with_extension("");
                let without_yaml = without_d.with_extension("");
                let Some(file_name) = without_yaml.file_name() else {
                    trace!(
                        "Directory {path:?} has no file name after removing the .yaml.d extension. Skipping..."
                    );
                    continue;
                };

                match Os::try_from(file_name) {
                    Ok(os) => os,
                    Err(error) => {
                        error!("Invalid OS identifier in drop-in directory path {path:?}: {error}");
                        continue;
                    }
                }
            };

            if let Some(paths) = drop_in_dirs.get_mut(&os) {
                paths.push(path);
            } else {
                drop_in_dirs.insert(os, vec![path]);
            }
        }
    }

    drop_in_dirs
}

/// Collects all valid [drop-in] config files from a list of input `dirs`.
///
/// First uses `collect_drop_in_dirs` to collect all drop-in directories from the top-level `dirs`.
/// Then creates a map of target OSes and relevant file names (with specific file path locations).
/// All masked drop-ins are ignored.
/// Drop-in config files are sorted and read according to the UAPI config file specification.
///
/// [drop-in]: https://uapi-group.org/specifications/specs/configuration_files_specification/#drop-ins
fn collect_drop_in_config_files(dirs: &[impl AsRef<Path>]) -> BTreeMap<Os, Vec<ConfigFile>> {
    let drop_in_dirs = collect_drop_in_dirs(dirs);
    let extension_check = Some(OsStr::new(CONFIG_FILE_EXTENSION));

    let mut os_drop_ins: BTreeMap<Os, BTreeMap<OsString, Vec<PathBuf>>> = BTreeMap::new();
    // Create a map of target OSes and their respective drop-in configuration file paths.
    // Configuration file paths are tracked based on their file name, mapped to lists of config
    // paths in increasing priority.
    for (os, drop_in_dir_list) in drop_in_dirs.iter() {
        for drop_in_dir in drop_in_dir_list.iter() {
            let read_dir = match drop_in_dir.read_dir() {
                Ok(read_dir) => read_dir,
                Err(error) => {
                    error!("Unable to read drop-in directory {drop_in_dir:?}: {error}");
                    continue;
                }
            };

            for dir_entry in read_dir {
                let dir_entry = match dir_entry {
                    Ok(dir_entry) => dir_entry,
                    Err(error) => {
                        error!("Unable to read entry in {drop_in_dir:?}: {error}");
                        continue;
                    }
                };

                let config_path = dir_entry.path();

                // Skip files that don't use the correct extension.
                if config_path.extension() != extension_check {
                    trace!(
                        "The drop-in config path {config_path:?} does not use the {CONFIG_FILE_EXTENSION} extension. Skipping..."
                    );
                    continue;
                }

                // Extract the file name and skip if there isn't any.
                let Some(file_name) = config_path.file_name().map(ToOwned::to_owned) else {
                    error!(
                        "The drop-in config file path {config_path:?} has no file name. Skipping..."
                    );
                    continue;
                };

                if let Some(file_name_paths) = os_drop_ins.get_mut(os) {
                    if let Some(path_list) = file_name_paths.get_mut(&file_name) {
                        path_list.push(config_path.to_path_buf());
                    } else {
                        file_name_paths.insert(file_name, vec![config_path.to_path_buf()]);
                    }
                } else {
                    os_drop_ins.insert(
                        os.clone(),
                        BTreeMap::from_iter([(file_name, vec![config_path.to_path_buf()])]),
                    );
                }
            }
        }
    }

    let mut output: BTreeMap<Os, Vec<ConfigFile>> = BTreeMap::new();
    // For each OS, go over the file names and associated configuration file paths.
    for (os, file_name_paths) in os_drop_ins.into_iter() {
        // For each file name, collect the file path with the highest priority.
        for (file_name, path_list) in file_name_paths.into_iter() {
            debug!(
                "Precedence for {file_name:?}: {}",
                path_list
                    .iter()
                    .map(|path| path.to_string_lossy().to_string())
                    .collect::<Vec<_>>()
                    .join(" < ")
            );
            // Skip all drop-in config file locations of an OS that use the same file name, if one
            // is masked.
            // Relevant messages are emitted by the `is_masked` function.
            if path_list.as_slice().iter().any(is_masked) {
                continue;
            }

            let config_file_list = {
                let mut config_file_list: Vec<ConfigFile> = Vec::new();
                for path in path_list.iter() {
                    // We are only interested in regular files.
                    if !path.is_file() {
                        error!("The path {path:?} is not a file. Skipping...");
                        continue;
                    }

                    match ConfigFile::from_yaml_file(path, crate::file::ConfigFileType::DropIn) {
                        Ok(config_file) => config_file_list.push(config_file),
                        Err(error) => {
                            error!("Failed to read config file from file path {path:?}: {error}")
                        }
                    }
                }
                config_file_list
            };

            let Some(config_file) = config_file_list.into_iter().last() else {
                error!("No valid config file with file name {file_name:?} found for OS {os}");
                continue;
            };

            info!(
                "Using {} as drop-in config file path with highest priority for file name {file_name:?}",
                config_file
                    .origins()
                    .iter()
                    .map(ToString::to_string)
                    .collect::<Vec<_>>()
                    .join(", ")
            );

            if let Some(config_file_list) = output.get_mut(&os) {
                config_file_list.push(config_file);
            } else {
                output.insert(os.clone(), vec![config_file]);
            }
        }
    }

    output
}
