use crate::colors::get_colors;
use crate::config::get_config;
use crate::load_data::load_data_json;
use std::fs;
use std::fs::File;
use std::io::{self, BufRead, BufReader, Write};
use std::path::Path;
use std::process::{exit, Command};

/// Runs an external command and waits for it to finish.
///
/// # Exits
///
/// Exits with the command's exit code on failure, or 1 if terminated by signal
/// or if the command is not found.
pub fn run_command(cmd: &str, args: &[String]) {
    let cfg = get_config();
    let color = get_colors(cfg.colors);

    match Command::new(cmd).args(args).status() {
        Ok(status) => match status.code() {
            Some(0) => {}
            Some(code) => {
                eprintln!(
                    "{}Error:{} {} failed: {}",
                    color.red, color.reset, cmd, code
                );
                exit(code);
            }
            None => {
                eprintln!(
                    "{}Error:{} {} terminated by signal",
                    color.red, color.reset, cmd
                );
                exit(1);
            }
        },
        Err(e) => {
            eprintln!(
                "{}Error:{} {} not found: {}",
                color.red, color.reset, cmd, e
            );
            exit(1);
        }
    }
}

/// Extracts the filename from a URL, stripping any query parameters or fragments.
///
/// Returns `None` if the URL has no path component or the filename is not valid UTF-8.
///
/// # Examples
///
/// ```
/// assert_eq!(get_filename_from_url("https://example.com/foo/bar.tar.gz"), Some("bar.tar.gz".to_string()));
/// assert_eq!(get_filename_from_url("https://example.com/foo/bar.tar.gz?v=1"), Some("bar.tar.gz".to_string()));
/// ```
pub fn get_filename_from_url(url: &str) -> Option<String> {
    // Remove any query parameters (?key=value) or fragments (#anchor)
    let path_part = url.split('?').next()?.split('#').next()?;

    // Use the file Path to get the last part
    let filename = Path::new(path_part).file_name()?;

    // Convert OsStr to a regular String
    filename.to_str().map(|s| s.to_string())
}

/// Returns a list of installed `SlackBuilds` packages found in the configured
/// packages directory.
///
/// Only entries whose filenames contain the configured `tag` are included, which identifies
/// packages built from SlackBuilds.org.
///
/// # Exits
///
/// Exits with code 1 if the configured `installed_packages` directory cannot be read.
pub fn get_installed_packages() -> Vec<String> {
    let cfg = get_config();
    let color = get_colors(cfg.colors);
    let mut packages = Vec::new();

    for entry in fs::read_dir(&cfg.installed_packages)
        .unwrap_or_else(|e| {
            eprintln!(
                "{}Error:{} Failed to read {}: {}",
                color.red, color.reset, cfg.installed_packages, e
            );
            exit(1);
        })
        .flatten()
    {
        let name = entry.file_name().to_string_lossy().to_string();
        if name.contains(&cfg.tag) {
            packages.push(name);
        }
    }
    packages
}

/// Returns `true` if a `SlackBuilds` package with the given `prgnam` is present
/// in the `installed` list.
///
/// Compares the `name` component of each installed package (after splitting
/// `{name}-{version}-{arch}-{build}{tag}`) against `prgnam` for an exact match,
/// avoiding prefix-collision false positives (e.g. `x265` vs `x265-extra`).
pub fn is_installed(prgnam: &str, installed: &Vec<String>) -> bool {
    for inst in installed {
        let result = split_package(inst);
        let parts = result.unwrap();

        if prgnam == parts.name {
            return true;
        }
    }
    false
}

/// Returns `true` if a `SlackBuilds` package with the given `prgnam-version` is present
/// in the `installed` list.
///
/// Compares the `name-version` component of each installed package (after splitting
/// `{name}-{version}-{arch}-{build}{tag}`) against `prgnam` for an exact match,
/// avoiding prefix-collision false positives (e.g. `x265-1.0.0` vs `x265-extra-1.0.0`).
pub fn is_installed_with_version(
    prgnam: &str,
    repo_version: &str,
    installed: &Vec<String>,
) -> bool {
    for inst in installed {
        let result = split_package(inst);
        let parts = result.unwrap();
        let repo_pkg = format!("{prgnam}-{repo_version}");
        let inst_pkg = format!("{}-{}", parts.name, parts.version);

        if repo_pkg == inst_pkg {
            return true;
        }
    }
    false
}

/// Prompts the user to enter package numbers to exclude from the upgrade and
/// returns the trimmed input string.
///
/// # Errors
///
/// Returns an [`io::Error`] if reading from stdin or flushing stdout fails.
pub fn get_user_input(msg: &str) -> Result<String, io::Error> {
    let mut input = String::new();
    print!("{msg}");
    io::stdout().flush()?;

    io::stdin().read_line(&mut input)?;

    Ok(input.trim().to_string())
}

/// Prompts the user for confirmation and exits if they decline.
///
/// Returns immediately without prompting when `skip` is true (i.e. `--no-confirm`).
pub fn confirm(msg: &str, skip: bool) {
    if skip {
        return;
    }

    let mut input = String::new();
    print!("{msg}");
    let _ = io::stdout().flush();

    let _ = io::stdin().read_line(&mut input);

    if !matches!(input.trim(), "Y" | "y") {
        exit(0);
    }
}

/// Validates packages against the repository index and returns only those found.
///
/// Reports missing packages as errors. Exits immediately if only one package
/// was requested and it is not found; otherwise prompts to continue with the rest.
pub fn package_exists(packages: &[String]) -> Vec<String> {
    let repo = load_data_json();
    let mut not_found: Vec<&str> = Vec::new();
    let mut found: Vec<String> = Vec::new();

    let cfg = get_config();
    let color = get_colors(cfg.colors);

    for pkg in packages {
        if repo.contains_key(pkg) {
            found.push(pkg.clone());
        } else {
            not_found.push(pkg);
        }
    }

    if !not_found.is_empty() {
        eprintln!(
            "{}Error:{} package(s) '{}' not found in repository",
            color.red,
            color.reset,
            not_found.join(", ")
        );
        if packages.len() == 1 {
            exit(1);
        }

        confirm(
            &format!(
                "\n{} package(s) {}missing{}, continue with the rest? [Y/n]: ",
                not_found.len(),
                color.red,
                color.reset
            ),
            false,
        );
        println!();
    }
    found
}

/// Deduplicates a package list preserving the original order.
pub fn prepare_packages(packages: &[String]) -> Vec<String> {
    let mut packages = packages.to_vec();
    let mut seen = std::collections::HashSet::new();
    packages.retain(|p| seen.insert(p.clone()));
    packages
}

/// Returns `true` if `prgnam` matches any pattern in the blacklist.
///
/// Patterns ending with `*` are treated as prefix wildcards (e.g. `python3*`
/// matches `python3`, `python3-pip`). All other patterns require an exact match.
pub fn matches_blacklist(prgnam: &str, blacklist: &[String]) -> bool {
    blacklist.iter().any(|pattern| {
        if pattern.ends_with('*') {
            prgnam.starts_with(&pattern[..pattern.len() - 1])
        } else {
            prgnam == pattern
        }
    })
}

/// Recursively calculates the total size in bytes of all files in a directory.
pub fn get_dir_size(path: impl AsRef<Path>) -> std::io::Result<u64> {
    let mut size = 0;

    for entry in fs::read_dir(path)? {
        let entry = entry?;
        let metadata = entry.metadata()?;

        if metadata.is_dir() {
            size += get_dir_size(entry.path())?;
        } else {
            size += metadata.len();
        }
    }

    Ok(size)
}

/// Converts a byte count to a human-readable string (e.g. `1.23 MiB`).
pub fn format_size(bytes: u64) -> String {
    let units = ["B", "KiB", "MiB", "GiB", "TiB"];
    let mut size = bytes as f64;
    let mut unit_index = 0;

    while size >= 1024.0 && unit_index < units.len() - 1 {
        size /= 1024.0;
        unit_index += 1;
    }

    format!("{:.2} {}", size, units[unit_index])
}

/// Components of a Slackware binary package name.
#[allow(dead_code)]
pub struct PackageParts {
    pub name: String,
    pub version: String,
    pub arch: String,
    pub build: String,
    pub tag: String,
}

/// Splits a Slackware binary package filename into its components.
///
/// The expected format is `{name}-{version}-{arch}-{build}{tag}`, where
/// `name` may contain hyphens (e.g. `python3-pip`). The last three
/// dash-separated fields are always `version`, `arch`, and `{build}{tag}`.
///
/// Returns `None` if the string has fewer than four dash-separated parts.
pub fn split_package(package: &str) -> Option<PackageParts> {
    let parts: Vec<&str> = package.split('-').collect();
    if parts.len() < 4 {
        return None;
    }

    let name = parts[..parts.len() - 3].join("-");
    let version = parts[parts.len() - 3].to_string();
    let arch = parts[parts.len() - 2].to_string();
    let build_tag = parts[parts.len() - 1];

    let build_end = build_tag
        .find(|c: char| !c.is_ascii_digit())
        .unwrap_or(build_tag.len());
    let build = build_tag[..build_end].to_string();
    let tag = build_tag[build_end..].to_string();

    Some(PackageParts {
        name,
        version,
        arch,
        build,
        tag,
    })
}

/// Reads and prints the contents of `filename`.
///
/// If `filter` is `Some` and non-empty, only lines containing at least one of the filter
/// strings are printed. If `filter` is `Some` but empty, the full file is printed.
///
/// # Arguments
///
/// * `filename` - Path to the file to read.
/// * `filter` - Optional list of strings; only matching lines are printed when non-empty.
///
/// # Exits
///
/// Exits with code 1 if the file is not found.
pub fn read_and_print(filename: &str, filter: Option<&[String]>) {
    let cfg = get_config();
    let color = get_colors(cfg.colors);
    let content = fs::read_to_string(filename).unwrap_or_else(|_| {
        eprintln!(
            "{}Error:{} {} file not found",
            color.red, color.reset, filename
        );
        exit(1);
    });

    if let Some(filters) = filter {
        if filters.is_empty() {
            println!("{}", content.trim());
        } else {
            for line in content.lines() {
                if filters.iter().any(|f| line.contains(f)) {
                    println!("{line}");
                }
            }
        }
    } else {
        println!("{}", content.trim());
    }
}

/// Opens the given file and returns its first line with trailing whitespace removed.
///
/// # Arguments
///
/// * `filename` - Path to the file to read.
///
/// # Errors
///
/// Returns an [`io::Error`] if the file cannot be opened or read.
pub fn read_first_line(filename: &str) -> io::Result<String> {
    let file = File::open(filename)?;
    let mut reader = BufReader::new(file);

    let mut first_line = String::new();
    reader.read_line(&mut first_line)?;

    Ok(first_line.trim_end().to_string())
}

/// Returns the path of the built package file inside the given OUTPUT directory.
///
/// The build step creates a per-package temporary OUTPUT directory that contains
/// exactly one Slackware package file. Reads the directory and returns that
/// file's full path as a `String`.
///
/// # Exits
///
/// Exits with a non-zero code if the directory cannot be read or is empty.
pub fn built_package_path(output_dir: &Path) -> String {
    let cfg = get_config();
    let color = get_colors(cfg.colors);
    let packages = fs::read_dir(output_dir).unwrap_or_else(|e| {
        eprintln!(
            "{}Error:{} Failed to read {}: {}",
            color.red,
            color.reset,
            output_dir.display(),
            e
        );
        exit(1);
    });

    let package = packages.flatten().next().unwrap_or_else(|| {
        eprintln!(
            "{}Error:{} no package found in {}",
            color.red,
            color.reset,
            output_dir.display()
        );
        exit(1);
    });

    package.path().to_string_lossy().to_string()
}

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

    /// Simple package name without hyphens.
    #[test]
    fn test_simple_name() {
        let p = split_package("python3-3.11.0-x86_64-1_SBo").unwrap();
        assert_eq!(p.name, "python3");
        assert_eq!(p.version, "3.11.0");
        assert_eq!(p.arch, "x86_64");
        assert_eq!(p.build, "1");
        assert_eq!(p.tag, "_SBo");
    }

    /// Package name containing hyphens.
    #[test]
    fn test_hyphenated_name() {
        let p = split_package("python3-pip-23.3.1-x86_64-1_SBo").unwrap();
        assert_eq!(p.name, "python3-pip");
        assert_eq!(p.version, "23.3.1");
        assert_eq!(p.arch, "x86_64");
        assert_eq!(p.build, "1");
        assert_eq!(p.tag, "_SBo");
    }

    /// Package name starting with a digit.
    #[test]
    fn test_name_starts_with_digit() {
        let p = split_package("7zip-21.07-x86_64-1_SBo").unwrap();
        assert_eq!(p.name, "7zip");
        assert_eq!(p.version, "21.07");
        assert_eq!(p.arch, "x86_64");
        assert_eq!(p.build, "1");
        assert_eq!(p.tag, "_SBo");
    }

    /// Too few parts returns None.
    #[test]
    fn test_too_few_parts() {
        assert!(split_package("python3-3.11.0").is_none());
    }
}

/// Unit tests for URL filename extraction.
#[cfg(test)]
mod url_tests {
    use super::*;

    /// Plain URL returns the filename.
    #[test]
    fn test_simple_url() {
        assert_eq!(
            get_filename_from_url("https://example.com/foo/bar.tar.gz"),
            Some("bar.tar.gz".to_string())
        );
    }

    /// Query parameters are stripped before extracting the filename.
    #[test]
    fn test_url_with_query() {
        assert_eq!(
            get_filename_from_url("https://example.com/foo/bar.tar.gz?v=1&x=2"),
            Some("bar.tar.gz".to_string())
        );
    }

    /// Fragment is stripped before extracting the filename.
    #[test]
    fn test_url_with_fragment() {
        assert_eq!(
            get_filename_from_url("https://example.com/foo/bar.tar.gz#section"),
            Some("bar.tar.gz".to_string())
        );
    }
}

/// Unit tests for human-readable size formatting.
#[cfg(test)]
mod size_tests {
    use super::*;

    #[test]
    fn test_bytes() {
        assert_eq!(format_size(512), "512.00 B");
    }

    #[test]
    fn test_kibibytes() {
        assert_eq!(format_size(1024), "1.00 KiB");
    }

    #[test]
    fn test_mebibytes() {
        assert_eq!(format_size(1024 * 1024), "1.00 MiB");
    }

    #[test]
    fn test_zero() {
        assert_eq!(format_size(0), "0.00 B");
    }
}

/// Unit tests for package list deduplication.
#[cfg(test)]
mod prepare_tests {
    use super::*;

    /// Duplicates are removed while preserving order.
    #[test]
    fn test_dedup_preserves_order() {
        let pkgs: Vec<String> = ["b", "a", "b", "c", "a"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        assert_eq!(prepare_packages(&pkgs), vec!["b", "a", "c"]);
    }

    /// Empty input returns empty output.
    #[test]
    fn test_empty() {
        assert!(prepare_packages(&[]).is_empty());
    }

    /// No duplicates — list is unchanged.
    #[test]
    fn test_no_duplicates() {
        let pkgs: Vec<String> = ["a", "b", "c"].iter().map(|s| s.to_string()).collect();
        assert_eq!(prepare_packages(&pkgs), vec!["a", "b", "c"]);
    }
}

/// Unit tests for blacklist pattern matching.
#[cfg(test)]
mod tests {
    use super::*;

    fn bl(patterns: &[&str]) -> Vec<String> {
        patterns.iter().map(|s| s.to_string()).collect()
    }

    /// Exact match returns true.
    #[test]
    fn test_exact_match() {
        assert!(matches_blacklist("ffmpeg", &bl(&["ffmpeg"])));
    }

    /// Non-matching package returns false.
    #[test]
    fn test_no_match() {
        assert!(!matches_blacklist("ffmpeg", &bl(&["vlc"])));
    }

    /// Wildcard matches packages with the given prefix.
    #[test]
    fn test_wildcard_match() {
        assert!(matches_blacklist("python3-pip", &bl(&["python3*"])));
        assert!(matches_blacklist("python3", &bl(&["python3*"])));
    }

    /// Wildcard does not match unrelated packages.
    #[test]
    fn test_wildcard_no_match() {
        assert!(!matches_blacklist("python2", &bl(&["python3*"])));
    }

    /// Empty blacklist always returns false.
    #[test]
    fn test_empty_blacklist() {
        assert!(!matches_blacklist("ffmpeg", &bl(&[])));
    }
}
