// Pets configuration file validator and planner. Given a list of in-memory PetsFile(s),
// see if our sanity constraints are met. For example, we do not want multiple
// files to be installed to the same destination path. Also, all validation
// commands must succeed.

use crate::{
    actions::{self, Package, package_manager::PackageManager},
    pet_files::PetsFile,
};
use std::{
    collections::{HashMap, HashSet},
    str::FromStr,
    string::ToString,
};
use thiserror::Error;

#[derive(Debug, Error)]
#[error("duplicate definition for '{dest}': '{src}' and '{other}'")]
pub struct DuplicateDefinitionError {
    dest: String,
    src: String,
    other: String,
}

impl DuplicateDefinitionError {
    pub fn new(dest: String, src: String, other: String) -> Self {
        Self { dest, src, other }
    }
}

/// validates assumptions that must hold across all configuration files.
pub fn check_global_constraints(files: &[PetsFile]) -> Result<(), DuplicateDefinitionError> {
    let mut seen: HashMap<String, &PetsFile> = HashMap::new();

    for pf in files {
        let dest = pf.destination();
        match seen.entry(dest) {
            std::collections::hash_map::Entry::Occupied(entry) => {
                return Err(DuplicateDefinitionError::new(
                    entry.key().clone(),
                    pf.source().to_owned(),
                    entry.get().source().to_owned(),
                ));
            }
            std::collections::hash_map::Entry::Vacant(entry) => {
                entry.insert(pf);
            }
        }
    }

    Ok(())
}

pub fn plan_actions(files: Vec<PetsFile>) -> Vec<actions::Action> {
    // Check validation errors in individual files. At this stage, the
    // command in the "pre" validation directive may not be installed yet.
    // An error in one file means we're gonna skip it but proceed with the rest.
    let good_pets = files
        .into_iter()
        .filter(|pf| {
            let matches = pf.matches_conditions();
            if !matches {
                log::debug!(
                    "skipping '{}' due to unmatched 'when' condition(s)",
                    pf.source()
                );
            }
            matches
        })
        .filter(PetsFile::is_valid)
        .collect::<Vec<_>>();

    // Determines a list of packages that need to be installed.
    let pkgs = good_pets
        .iter()
        .flat_map(|trigger| {
            trigger
                .packages()
                .iter()
                .filter(|pkg| match pkg.is_installed() {
                    Ok(b) => !b,
                    Err(e) => !matches!(e, actions::ActionError::NoPackageManager),
                })
                .map(std::clone::Clone::clone)
        })
        .collect::<HashSet<Package>>();

    // Generate the list of actions to perform.
    let trigger_actions = good_pets
        .iter()
        .flat_map(Into::<Vec<actions::Action>>::into);
    if pkgs.is_empty() {
        trigger_actions.collect()
    } else {
        let mut packages: HashMap<String, Vec<String>> = HashMap::new();
        for pkg in pkgs {
            packages
                .entry(pkg.package_manager.to_string())
                .or_default()
                .push(pkg.name);
        }
        packages
            .iter()
            .map(|(pkg_manager, packages)| {
                let pkg_manager = PackageManager::from_str(pkg_manager).unwrap();
                let install_vec = pkg_manager.install_command();

                let packages_to_install = install_vec
                    .into_iter()
                    .chain(packages.iter().map(ToString::to_string))
                    .collect();
                if pkg_manager.requires_sudo() {
                    actions::Action::command_with_sudo(actions::Cause::Pkg, packages_to_install)
                } else {
                    actions::Action::command(actions::Cause::Pkg, packages_to_install)
                }
            })
            .chain(trigger_actions)
            .collect()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::{fs::File, io::Write};
    use tempfile::tempdir;

    fn make_pets_file(
        dir: &std::path::Path,
        name: &str,
        destfile: &str,
    ) -> Result<PetsFile, crate::pet_files::ParseError> {
        let path = dir.join(name);
        let mut file = File::create(&path)?;
        writeln!(file, "# pets: destfile={destfile}")?;
        writeln!(file, "body")?;
        PetsFile::from_path(&path, PackageManager::Cargo)
    }

    #[test]
    fn test_check_global_constraints_ok_when_no_duplicate_destfiles() {
        let tmp = tempdir().unwrap();
        let file1 = make_pets_file(tmp.path(), "a.conf", "/tmp/pets-test-a").unwrap();
        let file2 = make_pets_file(tmp.path(), "b.conf", "/tmp/pets-test-b").unwrap();

        let result = check_global_constraints(&[file1, file2]);
        assert!(result.is_ok());
    }

    #[test]
    fn test_check_global_constraints_err_on_duplicate_destfiles() {
        let tmp = tempdir().unwrap();
        let file1 = make_pets_file(tmp.path(), "a.conf", "/tmp/pets-test-dup").unwrap();
        let file2 = make_pets_file(tmp.path(), "b.conf", "/tmp/pets-test-dup").unwrap();

        let err = check_global_constraints(&[file1, file2]).unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("duplicate definition for '/tmp/pets-test-dup'"));
        assert!(msg.contains("a.conf") || msg.contains("b.conf"));
    }

    #[test]
    fn test_duplicate_definition_error_display() {
        let err = DuplicateDefinitionError::new(
            "/etc/example".to_string(),
            "/pets/a".to_string(),
            "/pets/b".to_string(),
        );

        assert_eq!(
            err.to_string(),
            "duplicate definition for '/etc/example': '/pets/a' and '/pets/b'"
        );
    }
}
