// SPDX-FileCopyrightText: 2022 Aurélien Gâteau <mail@agateau.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later

use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};

use anyhow::{anyhow, Context, Result};
use semver::Version;
use shell_words;
use tempfile::TempDir;

use clyde::app::App;
use clyde::arch_os::ArchOs;
use clyde::file_utils::{get_file_name, prepend_dir_to_path};
use clyde::package::Package;
use clyde::store::INDEX_NAME;
use clyde::ui::Ui;
use clyde::vars::{expand_vars, VarsMap};

struct FailedPackage {
    package_path: PathBuf,
    error_message: String,
}

impl FailedPackage {
    fn new(package_path: &Path, error_message: &str) -> FailedPackage {
        FailedPackage {
            package_path: package_path.to_path_buf(),
            error_message: error_message.to_string(),
        }
    }

    fn name(&self) -> String {
        let Ok(file_name) = get_file_name(&self.package_path) else {
            return self.package_path.display().to_string();
        };
        if file_name != INDEX_NAME {
            // plain file layout, we are done
            return file_name.replace(".yaml", "");
        }

        // directory layout, return the name of the parent directory
        let Some(parent) = self.package_path.parent() else {
            return self.package_path.display().to_string();
        };
        match get_file_name(parent) {
            Ok(x) => x.to_string(),
            Err(_) => self.package_path.display().to_string(),
        }
    }
}

fn check_has_release_assets(package: &Package) -> Result<()> {
    if package.releases.is_empty() {
        return Err(anyhow!("No releases"));
    }
    for (version, release) in package.releases.iter() {
        if release.assets.is_empty() {
            return Err(anyhow!("No release assets for version {}", version));
        }
    }
    Ok(())
}

fn check_has_installs(package: &Package) -> Result<()> {
    if package.installs.is_empty() {
        return Err(anyhow!("No installs"));
    }
    for (version, installs_for_arch_os) in package.installs.iter() {
        if installs_for_arch_os.is_empty() {
            return Err(anyhow!("No install for version {}", version));
        }
    }
    Ok(())
}

fn get_latest_version(package: &Package) -> Option<Version> {
    let version = package.get_latest_version().unwrap();

    package
        .get_asset(version, &ArchOs::current())
        .map(|_| version.clone())
}

/// Run the test command `test_command`, add results with error details to `report`
fn run_test_command(report: &mut Vec<String>, home_dir: &Path, test_command: &str) -> Result<()> {
    let clyde_bin_dir = home_dir.join("inst").join("bin");

    let new_path = prepend_dir_to_path(&clyde_bin_dir)?;

    let words = shell_words::split(test_command)?;
    let mut iter = words.iter();
    let binary = iter
        .next()
        .ok_or_else(|| anyhow!("Test command is empty"))?;
    let args: Vec<String> = iter.map(|x| x.into()).collect();

    run_command(
        report,
        Command::new(binary)
            .env("PATH", new_path)
            .env("CLYDE_HOME", home_dir)
            .args(args),
    )
}

fn create_vars_map() -> VarsMap {
    // Only add ${exe_ext} for now. We'll see if we need other vars in the future
    let mut map = VarsMap::new();
    map.insert(
        "exe_ext".into(),
        if cfg!(windows) {
            ".exe".into()
        } else {
            "".into()
        },
    );
    map
}

fn report_command_output(report: &mut Vec<String>, output: &Output) {
    report.push("STDOUT".into());
    report.push(String::from_utf8_lossy(&output.stdout).into());
    report.push("STDERR".into());
    report.push(String::from_utf8_lossy(&output.stderr).into());
}

/// Run `command`, add results with error details to `report`
fn run_command(report: &mut Vec<String>, command: &mut Command) -> Result<()> {
    let mut command_str = format!("{:?}", command.get_program());
    for arg in command.get_args() {
        command_str.push_str(&format!(" {:?}", arg));
    }
    report.push(format!("Running {command_str}"));
    let output = match command.output() {
        Ok(x) => x,
        Err(x) => {
            report.push(format!("Failed to execute command: {x}"));
            return Err(anyhow!("Failed to execute command: {x}"));
        }
    };
    report_command_output(report, &output);

    match output.status.code() {
        Some(0) => Ok(()),
        Some(x) => {
            report.push(format!("Command failed with exit code {x}"));
            Err(anyhow!("Command failed with exit code {x}"))
        }
        None => {
            report.push("Command terminated by signal".to_string());
            Err(anyhow!("Command terminated by signal"))
        }
    }
}

/// Checks the package can install, reports failure as a big string in the Err branch of Result
fn check_can_install(package: &Package, package_path: &Path, version: &Version) -> Result<()> {
    let mut report = Vec::<String>::new();

    // Setup temp Clyde home
    report.push("### Setup Clyde home".to_string());
    let home_temp_dir = TempDir::new()?;
    let home_dir = home_temp_dir.path();
    let store_dir = home_dir.join("store");
    fs::create_dir(&store_dir)
        .with_context(|| format!("Could not create store directory {store_dir:?}"))?;

    let app = App::new(home_dir).context("Could not create test Clyde home")?;
    app.database.create()?;

    // Install package
    report.push("\n### Install package\n".to_string());
    let package_str = package_path.as_os_str().to_str().unwrap();
    run_command(
        &mut report,
        Command::new("clyde")
            .arg("install")
            .arg(format!("{package_str}@={version}"))
            .env("CLYDE_HOME", home_dir.as_os_str()),
    )
    .map_err(|_| anyhow!(report.join("\n")))?;

    // Run test commands
    report.push("\n### Running test commands\n".to_string());
    let install = package.get_install(version, &ArchOs::current()).unwrap();

    let vars = create_vars_map();

    for test_command in &install.tests {
        let test_command = expand_vars(test_command, &vars)?;
        run_test_command(&mut report, home_dir, &test_command)
            .map_err(|_| anyhow!(report.join("\n")))?;
    }
    Ok(())
}

fn check_package_name(package: &Package, path: &Path) -> Result<()> {
    let file_name = get_file_name(path)?;
    let package_file_name = if file_name == INDEX_NAME {
        get_file_name(path.parent().unwrap())?
    } else {
        match file_name.rsplit_once('.') {
            Some((stem, _ext)) => stem,
            None => {
                return Err(anyhow!("Invalid package name ({})", path.display()));
            }
        }
    };
    if package.name != package_file_name {
        return Err(anyhow!(
            "Package name ({}) must match the package file name ({})",
            package.name,
            package_file_name
        ));
    }
    Ok(())
}

fn load_package(path: &Path) -> Result<Package> {
    let path = path.canonicalize().context("canonicalize() failed")?;
    let package = Package::from_file(&path).context("Package::from_file() failed")?;
    check_package_name(&package, &path).context("check_package_name() failed")?;
    Ok(package)
}

/// The bool indicates if an asset was available for the current arch-os
fn check_package(package: &Package, path: &Path) -> Result<bool> {
    check_has_release_assets(package)?;
    check_has_installs(package)?;

    let version = match get_latest_version(package) {
        Some(x) => x,
        None => {
            return Ok(false);
        }
    };
    check_can_install(package, path, &version)?;
    Ok(true)
}

fn print_summary_line(ui: &Ui, count: usize, header: &str, packages: &[String]) {
    let mut msg = format!("{header} ({}/{count})", packages.len());
    if !packages.is_empty() {
        let joined = packages.join(", ");
        msg = format!("{msg}: {joined}");
    }
    ui.info(&msg);
}

pub fn check_packages(ui: &Ui, paths: &[PathBuf]) -> Result<()> {
    let mut ok_packages = Vec::<String>::new();
    let mut not_on_arch_os_packages = Vec::<String>::new();
    let mut failed_packages = Vec::<FailedPackage>::new();

    let count = paths.len();
    for (idx, path) in paths.iter().enumerate() {
        ui.info(&format!("{}/{count}: {}", idx + 1, path.display()));
        let ui2 = ui.nest();
        ui2.info("Loading package file");
        let package = match load_package(path) {
            Ok(x) => x,
            Err(message) => {
                failed_packages.push(FailedPackage::new(path, &message.to_string()));
                ui2.error(&message.to_string());
                continue;
            }
        };
        let name = package.name.clone();
        ui2.info("Checking package");
        match check_package(&package, path) {
            Ok(true) => {
                ok_packages.push(name);
                ui2.info("OK");
            }
            Ok(false) => {
                not_on_arch_os_packages.push(name);
                ui2.info("Skipped (not on arch-os)");
            }
            Err(message) => {
                failed_packages.push(FailedPackage::new(path, &message.to_string()));
                ui2.error(&message.to_string());
            }
        };
    }

    ui.info("Finished");
    let failed_package_names: Vec<_> = failed_packages.iter().map(|x| x.name()).collect();
    let ui2 = ui.nest();
    print_summary_line(&ui2, count, "OK", &ok_packages);
    print_summary_line(&ui2, count, "Skipped", &not_on_arch_os_packages);
    print_summary_line(&ui2, count, "Failed", &failed_package_names);

    if !failed_packages.is_empty() {
        ui.error("Failed packages details");
        let ui2 = ui.nest();
        for failed_package in &failed_packages {
            ui2.println(&failed_package.name());
            ui2.nest().println(&failed_package.error_message);
        }

        return Err(anyhow!("{} package(s) failed", failed_packages.len()));
    }
    Ok(())
}

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

    #[test]
    fn check_has_release_assets_fails_if_a_release_has_no_assets() {
        // GIVEN a package with a release containing no assets
        let package = Package::from_yaml_str(
            "
        name: test
        description: desc
        homepage:
        releases:
            1.2.0:
                assets: {}
        ",
        )
        .unwrap();

        // WHEN check_has_release_assets() is called
        let result = check_has_release_assets(&package);

        // THEN it fails
        assert!(result.is_err());
    }

    #[test]
    fn check_has_release_assets_fails_if_it_has_no_releases() {
        // GIVEN a package with no release
        let package = Package::from_yaml_str(
            "
        name: test
        description: desc
        homepage:
        releases:
        ",
        )
        .unwrap();

        // WHEN check_has_release_assets() is called
        let result = check_has_release_assets(&package);

        // THEN it fails
        assert!(result.is_err());
    }

    #[test]
    fn run_command_reports_include_command_output() {
        let mut report = Vec::<String>::new();

        // GIVEN a command to run that fails
        // WHEN calling run_command()
        let result = run_command(&mut report, Command::new("cargo").args([&"foo"]));

        // THEN it fails
        assert!(result.is_err());

        // AND the report contains the command output
        let running_line = report.get(0).unwrap();
        assert!(running_line.contains("Running \"cargo"));
        assert_eq!(report.get(1).unwrap(), "STDOUT");
        assert_eq!(report.get(3).unwrap(), "STDERR");
        assert!(report.get(4).unwrap().contains("no such command: `foo`"));
        assert_eq!(report.last().unwrap(), "Command failed with exit code 101");
    }
}
