use anyhow::{Context, Result, anyhow, bail};
use futures_util::stream::FuturesUnordered;
use futures_util::{StreamExt, future, stream};
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
use inquire::{Select, Text};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use tokio::fs;
use tokio::fs::{File, OpenOptions};
use tokio::io::BufReader;
use tokio::sync::OnceCell;

use libprotonup::{
    apps,
    downloads::{self, Download, Release, ReleaseList},
    files, hashing,
    sources::{CompatTool, CompatTools},
};

use crate::{architecture_variants, file_path, helper_menus};

static PROGRESS_BAR_STYLE: OnceCell<ProgressStyle> = OnceCell::const_new();
static MESSAGE_BAR_STYLE: OnceCell<ProgressStyle> = OnceCell::const_new();

pub(crate) async fn init_download_progress(
    download: &Download,
    tmp_dir: &Path,
    multi_progress: MultiProgress,
) -> ProgressBar {
    let progress_bar = multi_progress.add(ProgressBar::new(download.size));
    progress_bar.set_style(get_progress_style().await);
    progress_bar.set_message(format!(
        "Downloading {} to {}",
        download.download_url.split('/').next_back().unwrap(),
        tmp_dir.display()
    ));

    progress_bar
}

pub(crate) async fn init_hash_progress(
    path: &Path,
    multi_progress: MultiProgress,
) -> Result<ProgressBar> {
    let progress_bar = multi_progress.add(ProgressBar::new(fs::metadata(path).await?.len()));
    progress_bar.set_style(get_progress_style().await);
    progress_bar.set_message(format!("Validating {}", path.display()));
    Ok(progress_bar)
}

pub(crate) async fn init_unpack_progress(
    target_dir: &Path,
    source_file: &Path,
    multi_progress: MultiProgress,
) -> Result<ProgressBar> {
    let progress_bar = multi_progress.add(ProgressBar::new(fs::metadata(source_file).await?.len()));
    progress_bar.set_style(get_progress_style().await);
    progress_bar.set_message(format!(
        "Unpacking {} to {}",
        source_file.display(),
        target_dir.display()
    ));
    Ok(progress_bar)
}

/// Downloads the requested file to the /tmp directory
pub(crate) async fn download_file(
    download: &Download,
    multi_progress: MultiProgress,
) -> Result<PathBuf> {
    let output_dir = download.download_dir()?;

    if files::check_if_exists(&output_dir).await {
        fs::remove_dir_all(&output_dir).await?;
    }

    let file = OpenOptions::new()
        .create_new(true)
        .write(true)
        .open(&output_dir)
        .await
        .with_context(|| {
            format!(
                "[Download] Failed creating destination file: {}",
                output_dir.display()
            )
        })?;

    let download_progress_bar =
        init_download_progress(download, &output_dir, multi_progress.clone()).await;

    downloads::download_to_async_write(
        &download.download_url,
        &mut download_progress_bar.wrap_async_write(file),
    )
    .await?;

    download_progress_bar.set_style(get_message_bar_style().await);
    download_progress_bar.finish_with_message(download_progress_bar.message().replacen(
        "Downloading",
        "Downloaded",
        1,
    ));

    Ok(output_dir)
}

pub(crate) async fn validate_file(
    file_name: &str,
    path: &Path,
    hash: hashing::HashSums,
    multi_progress: MultiProgress,
) -> Result<()> {
    let file = File::open(path)
        .await
        .context("[Hash Check] Failed opening download file for checking. Was the file moved?")?;

    let hash_progress_bar = init_hash_progress(path, multi_progress).await?;

    if !hashing::hash_check_file(
        file_name,
        &mut hash_progress_bar.wrap_async_read(BufReader::new(file)),
        hash,
    )
    .await?
    {
        bail!("{} failed validation", path.display());
    }

    hash_progress_bar.set_style(get_message_bar_style().await);
    hash_progress_bar.finish_with_message(hash_progress_bar.message().replacen(
        "Validating",
        "Validated",
        1,
    ));

    Ok(())
}

type DedupKey = (String, String, u64);
type DownloadGroup = (Download, Release, CompatTool, Vec<apps::AppInstallations>);

/// Groups download entries by their dedup key and returns a map of
/// (download, release, compat_tool, targets) plus uniquely tagged releases.
fn group_and_dedup_releases(
    entries: Vec<(Download, Release, apps::AppInstallations, CompatTool)>,
) -> (HashMap<DedupKey, DownloadGroup>, Vec<Release>) {
    let mut groups: HashMap<DedupKey, DownloadGroup> = HashMap::new();
    let mut seen_releases: HashSet<String> = HashSet::new();
    let mut unique_releases: Vec<Release> = vec![];

    for (download, release, app_inst, compat_tool) in entries {
        let key = download.dedup_key();

        groups
            .entry(key)
            .and_modify(|(_, _, _, targets)| targets.push(app_inst.clone()))
            .or_insert_with(|| (download, release.clone(), compat_tool, vec![app_inst]));

        if seen_releases.insert(release.tag_name.clone()) {
            unique_releases.push(release);
        }
    }

    (groups, unique_releases)
}

/// Downloads the latest wine version for all the apps found
pub async fn run_quick_downloads(force: bool, whats_new: bool) -> Result<Vec<Release>> {
    let found_apps = apps::list_installed_apps().await;
    if found_apps.is_empty() {
        println!("No apps found. Please install at least one app before using this feature.");
        return Err(anyhow!(
            "No apps found. Please install at least one app before using this feature."
        ));
    }
    println!(
        "Found the following apps: {}",
        found_apps
            .iter()
            .map(|app| app.to_string())
            .collect::<Vec<String>>()
            .join(", ")
    );

    let multi_progress = MultiProgress::with_draw_target(ProgressDrawTarget::stderr_with_hz(20));

    // Cache list_releases results to avoid redundant API calls
    let mut releases_cache: HashMap<String, ReleaseList> = HashMap::new();
    let mut entries: Vec<(Download, Release, apps::AppInstallations, CompatTool)> = vec![];
    let mut release_and_compat_refs: Vec<(Release, CompatTool)> = vec![];

    for app_inst in found_apps {
        let compat_tool = app_inst.as_app().default_compatibility_tool();

        let release_list = match releases_cache.get(&compat_tool.name) {
            Some(list) => list.clone(),
            None => {
                let list = match downloads::list_releases(&compat_tool).await {
                    Ok(list) => list,
                    Err(e) => {
                        eprintln!(
                            "Failed to fetch data, make sure you're connected to the internet.\nError: {e}"
                        );
                        std::process::exit(1)
                    }
                };
                releases_cache.insert(compat_tool.name.clone(), list.clone());
                list
            }
        };

        if release_list.is_empty() {
            continue;
        }

        let release = release_list[0].clone();

        // Handle tools with multiple architecture variants
        let download = if compat_tool.has_multiple_asset_variations {
            let variants = release.get_all_download_variants(&app_inst, &compat_tool);
            architecture_variants::select_architecture_variant(&release.tag_name, variants, true)?
        } else {
            release.get_download_info(&app_inst, &compat_tool)
        };

        // Check if already installed
        let mut download_path = PathBuf::from(&app_inst.default_install_dir().as_str());
        download_path.push(compat_tool.installation_name(&download.version));
        if files::check_if_exists(&download_path.clone()).await && !force {
            continue;
        }

        // if should show release notes, populate this list with the tools to show
        if whats_new {
            release_and_compat_refs.push((release.clone(), compat_tool.clone()));
        }

        entries.push((download, release, app_inst, compat_tool));
    }

    let (groups, unique_releases) = group_and_dedup_releases(entries);

    // Show what is new for each unique release being downloaded
    if whats_new {
        let mut seen_tags = HashSet::new();

        let release_and_compat_refs: Vec<(Release, CompatTool)> = release_and_compat_refs
            .into_iter()
            .filter(|(release, _compat)| seen_tags.insert(release.tag_name.clone()))
            .collect();

        for (release, compat_tool) in release_and_compat_refs {
            show_whatsnew(&release, &compat_tool).await;
        }
    }

    // Collect release+tool pairs for the post-download changelog prompt
    let release_tool_pairs: Vec<(Release, CompatTool)> = groups
        .values()
        .map(|(_, release, compat_tool, _)| (release.clone(), compat_tool.clone()))
        .collect();

    // Queue one download task per group
    let joins = FuturesUnordered::new();

    for (_, (_download, _release, compat_tool, targets)) in groups {
        joins.push(download_validate_unpack_to_targets(
            _download,
            compat_tool,
            targets,
            multi_progress.clone(),
        ));
    }

    joins
        .for_each(|res| {
            if let Err(e) = res {
                multi_progress.println(format!("{e}")).unwrap();
            }
            future::ready(())
        })
        .await;
    multi_progress.clear().unwrap();

    // Prompt to view changelogs after downloads complete
    prompt_changelogs(&release_tool_pairs).await;

    Ok(unique_releases)
}

pub(crate) fn format_whatsnew(release: &Release, compat_tool: &CompatTool) -> String {
    const WHATS_NEW_LINES: usize = 40;
    let mut output = String::new();

    let url = format!(
        "{}{}/{}/releases/tag/{}",
        compat_tool.forge.get_user_url(),
        compat_tool.repository_account,
        compat_tool.repository_name,
        release.tag_name
    );

    output.push('\n');
    output.push_str(&format!("  ┌{}┐\n", "─".repeat(50)));
    output.push_str(&format!("  │ {:^48} │\n", "Release Notes"));
    output.push_str(&format!("  └{}┘\n", "─".repeat(50)));
    output.push_str(&format!("\n  {}: {}\n", release.tag_name, url));

    match &release.body {
        Some(body) => {
            let all_lines: Vec<&str> = body.lines().collect();
            let notes = all_lines[..all_lines.len().min(WHATS_NEW_LINES)].join("\n");
            if all_lines.len() > WHATS_NEW_LINES {
                output.push_str(&format!("\n{}\n  ⋯ [truncated]\n", notes));
            } else {
                output.push_str(&format!("\n{}\n", notes));
            }
        }
        None => output.push_str("\n  (no release notes)\n"),
    }

    output.push('\n');
    output
}

pub(crate) async fn show_whatsnew(release: &Release, compat_tool: &CompatTool) {
    print!("{}", format_whatsnew(release, compat_tool));
}

async fn prompt_changelogs(releases: &[(Release, CompatTool)]) {
    for (release, compat_tool) in releases {
        if release.body.is_none() {
            continue;
        }
        let confirmed = inquire::Confirm::new(&format!("View changelog for {}?", release.tag_name))
            .with_default(false)
            .prompt()
            .unwrap_or(false);
        if confirmed {
            show_whatsnew(release, compat_tool).await;
        }
    }
}

/// Standalone mode: check installed apps, fetch latest releases, show changelogs
/// and print "Update available to version $v" for each tool with a newer release.
pub(crate) async fn check_whats_new() -> Result<()> {
    let found_apps = apps::list_installed_apps().await;
    if found_apps.is_empty() {
        println!("No apps found. Please install at least one app before using this feature.");
        return Ok(());
    }
    println!(
        "Found the following apps: {}",
        found_apps
            .iter()
            .map(|app| app.to_string())
            .collect::<Vec<String>>()
            .join(", ")
    );

    let mut releases_cache: HashMap<String, ReleaseList> = HashMap::new();
    let mut tool_entries: Vec<(apps::AppInstallations, CompatTool)> = vec![];

    for app_inst in found_apps {
        let compat_tool = app_inst.as_app().default_compatibility_tool();
        tool_entries.push((app_inst, compat_tool));
    }

    let mut seen_tools: HashSet<String> = HashSet::new();

    for (app_inst, compat_tool) in tool_entries {
        if !seen_tools.insert(compat_tool.name.clone()) {
            continue;
        }

        let release_list = match releases_cache.get(&compat_tool.name) {
            Some(list) => list.clone(),
            None => match downloads::list_releases(&compat_tool).await {
                Ok(list) => {
                    releases_cache.insert(compat_tool.name.clone(), list.clone());
                    list
                }
                Err(e) => {
                    eprintln!("Failed to fetch releases for {}: {}", compat_tool.name, e);
                    continue;
                }
            },
        };

        if release_list.is_empty() {
            continue;
        }

        let latest = &release_list[0];
        show_whatsnew(latest, &compat_tool).await;

        let install_name = compat_tool.installation_name(&latest.tag_name);
        let install_dir = app_inst.default_install_dir();
        let install_path = PathBuf::from(install_dir.as_str()).join(&install_name);

        if files::check_if_exists(&install_path).await {
            println!("Already up to date ({})\n", latest.tag_name);
        } else {
            println!("Update available to version {}\n", latest.tag_name);
        }
    }

    Ok(())
}

/// TUI menu handler: select a tool, pick versions, and show changelogs.
pub(crate) async fn check_changelog_menu() -> Result<Vec<Release>> {
    let available_sources = CompatTools.clone();

    let selected_tool = Select::new(
        "Choose the compatibility tool to check the changelog:",
        available_sources,
    )
    .prompt()
    .unwrap_or_else(|_| std::process::exit(0));

    let release_list = match downloads::list_releases(&selected_tool).await {
        Ok(data) => data,
        Err(e) => {
            eprintln!(
                "Failed to fetch data, make sure you're connected to the internet.\nError: {e}"
            );
            std::process::exit(1)
        }
    };

    let selected_releases = stream::iter(
        helper_menus::multiple_select_menu("Select the versions to view changelog:", release_list)
            .unwrap_or_else(|e| {
                eprintln!("The tag list could not be processed.\nError: {e}");
                vec![]
            }),
    )
    .collect::<Vec<_>>()
    .await;

    for release in &selected_releases {
        show_whatsnew(release, &selected_tool).await;
    }

    if !selected_releases.is_empty() {
        println!("Press Enter to return to menu...");
        let mut buf = String::new();
        let _ = std::io::stdin().read_line(&mut buf);
    }

    Ok(selected_releases)
}

/// Start the Download for the selected app
///
/// If no app is provided, the user is prompted for which version of Wine/Proton to use and what directory to extract to
pub async fn download_to_selected_app(app: Option<apps::App>) -> Result<Vec<Release>> {
    // Get the folder to install Wine/Proton into
    let app_inst = match app.clone() {
        // If the user selected an app (Steam/Lutris)...
        Some(app) => match app.detect_installation_method().await {
            installed_apps if installed_apps.is_empty() => {
                println!("Install location for selected app(s) not found. Exiting.");
                std::process::exit(0);
            }

            // Figure out which versions of the App the user has (Native/Flatpak)
            installed_apps if installed_apps.len() == 1 => {
                println!(
                    "Detected {}. Installing to {}",
                    installed_apps[0], installed_apps[0]
                );
                installed_apps[0].clone()
            }
            // If the user has more than one installation method, ask them which one they would like to use
            installed_apps => Select::new(
                "Detected several app versions, which would you like to use?",
                installed_apps,
            )
            .prompt()
            .unwrap_or_else(|_| std::process::exit(0)),
        },
        // If the user didn't select an app, ask them what directory they want to install to
        None => apps::AppInstallations::new_custom_app_install(
            Text::new("Installation path:")
                .with_autocomplete(file_path::FilePathCompleter::default())
                .with_help_message(&format!(
                    "Current directory: {}",
                    std::env::current_dir()
                        .unwrap_or_else(|_| std::process::exit(0))
                        .to_string_lossy()
                ))
                .with_default(
                    &std::env::current_dir()
                        .unwrap_or_else(|_| std::process::exit(0))
                        .to_string_lossy(),
                )
                .prompt()
                .unwrap_or_else(|_| std::process::exit(0)),
        ),
    };

    // if an app was selected, filter compatible tools
    let available_sources = match app {
        // Use the default for the app
        Some(app) => CompatTool::sources_for_app(&app),
        // Or have the user select which one
        None => CompatTools.clone(),
    };

    // TODO: maybe change to multi-select ?
    let selected_tool = Select::new(
        "Choose the compatibility tool you want to install:",
        available_sources, // variants::ALL_VARIANTS.to_vec(),
    )
    .prompt()
    .unwrap_or_else(|_| std::process::exit(0));

    let releases = {
        let release_list = match downloads::list_releases(&selected_tool).await {
            Ok(data) => data,
            Err(e) => {
                eprintln!(
                    "Failed to fetch data, make sure you're connected to the internet.\nError: {e}"
                );
                std::process::exit(1)
            }
        };

        // Let the user choose which releases they want to use
        stream::iter(
            helper_menus::multiple_select_menu(
                "Select the versions you want to download:",
                release_list,
            )
            .unwrap_or_else(|e| {
                eprintln!("The tag list could not be processed.\nError: {e}");
                vec![]
            }),
        )
        .filter_map(|release| async {
            if should_download(
                &release,
                &mut app_inst
                    .installation_dir(&selected_tool)
                    .unwrap()
                    .join(selected_tool.installation_name(&release.tag_name)),
            )
            .await
            {
                Some(release)
            } else {
                None
            }
        })
        .collect::<Vec<_>>()
        .await
    };

    // let tool = selected_tool.clone();
    // Check if the selected tool has multiple asset variations
    let downloads: Vec<Download> = if selected_tool.has_multiple_asset_variations {
        releases
            .iter()
            .map(|release| {
                let variants = release.get_all_download_variants(&app_inst, &selected_tool);

                architecture_variants::select_architecture_variant(
                    &release.tag_name,
                    variants,
                    false,
                )
                .unwrap_or_else(|_| std::process::exit(1))
            })
            .collect::<Vec<Download>>()
    } else {
        releases
            .iter()
            .map(|release| release.get_download_info(&app_inst, &selected_tool))
            .collect()
    };

    let multi_progress = MultiProgress::with_draw_target(ProgressDrawTarget::stderr_with_hz(20));

    let tasks = downloads.into_iter().map(|download| {
        // let release = release.clone();
        let progress = multi_progress.clone();
        let tool = selected_tool.clone();
        let app_inst = app_inst.clone();

        // Handle tools with multiple architecture variants
        tokio::spawn(async move {
            download_validate_unpack_with_download(download.clone(), app_inst, tool, progress).await
        })
    });

    for task in tasks
        .collect::<FuturesUnordered<_>>()
        .collect::<Vec<_>>()
        .await
    {
        let err: Option<anyhow::Error> = match task {
            Ok(Ok(())) => None,
            Ok(Err(e)) => Some(e),
            Err(join_err) => Some(anyhow!(join_err)),
        };

        if let Some(e) = err {
            eprintln!("{e}");
            return Err(anyhow!("Installation failed with Error"));
        }
    }

    let pairs: Vec<(Release, CompatTool)> = releases
        .iter()
        .map(|r| (r.clone(), selected_tool.clone()))
        .collect();
    prompt_changelogs(&pairs).await;

    Ok(releases)
}

async fn download_validate_unpack_with_download(
    download: Download,
    for_app: apps::AppInstallations,
    compat_tool: CompatTool,
    multi_progress: MultiProgress,
) -> Result<()> {
    let install_dir = for_app.installation_dir(&compat_tool).unwrap();
    let file = download_file(&download, multi_progress.clone())
        .await
        .with_context(|| {
            format!(
                "Error downloading {}, make sure you're connected to the internet",
                download.version
            )
        })?;
    match download.hash_sum {
        Some(ref git_hash_sum) => {
            let hash_content = &downloads::download_file_into_memory(&git_hash_sum.sum_content)
                .await
                .with_context(|| {
                    format!(
                        "Error getting expected download hash for {}",
                        download.version
                    )
                })?;
            let hash_sum = hashing::HashSums {
                sum_content: hash_content.to_owned(),
                sum_type: git_hash_sum.sum_type.clone(),
            };

            validate_file(&download.file_name, &file, hash_sum, multi_progress.clone()).await?;
        }
        None => {
            println!("No sum files available, skipping");
        }
    }

    let install_name = compat_tool.installation_name(&download.version);
    let install_path = install_dir.join(install_name.clone());
    if files::check_if_exists(&install_path).await {
        fs::remove_dir_all(&install_path).await.with_context(|| {
            format!(
                "Error removing existing install at {}",
                install_path.display()
            )
        })?;
    }

    // Open the compressed file
    let compressed_file = File::open(&file)
        .await
        .with_context(|| format!("Error opening compressed file {}", file.display()))?;

    // Wrap the file with progress tracking to read compressed bytes
    let unpack_progress_bar = init_unpack_progress(&install_dir.clone(), &file, multi_progress)
        .await
        .with_context(|| format!("Error unpacking {}", file.display()))?;

    let progress_reader = unpack_progress_bar.wrap_async_read(compressed_file);

    // Wrap with BufReader to provide AsyncBufRead for the decompressor
    let buf_reader = BufReader::new(progress_reader);

    // Create Decompressor from the BufReader
    let path_str = file.to_string_lossy();
    let decompressor = files::Decompressor::from_reader(buf_reader, &path_str)
        .with_context(|| format!("Error checking file type of {}", file.display()))?;

    files::unpack_file(&compat_tool, &download, decompressor, &install_dir)
        .await
        .with_context(|| format!("Error unpacking {}", file.display()))?;

    unpack_progress_bar.set_style(get_message_bar_style().await);
    unpack_progress_bar.finish_with_message(format!(
        "Done! {} installed in {}/{}\nYour app might require a restart to detect {}",
        compat_tool,
        download
            .for_app
            .installation_dir(&compat_tool)
            .unwrap()
            .to_str()
            .unwrap(),
        install_name,
        compat_tool
    ));
    Ok(())
}

pub(crate) async fn init_download_progress_for_targets(
    download: &Download,
    targets: &[apps::AppInstallations],
    multi_progress: MultiProgress,
) -> ProgressBar {
    let progress_bar = multi_progress.add(ProgressBar::new(download.size));
    progress_bar.set_style(get_progress_style().await);
    let app_names: Vec<String> = targets.iter().map(|a| a.to_string()).collect();
    progress_bar.set_message(format!(
        "Downloading {} for {}",
        download.download_url.split('/').next_back().unwrap(),
        app_names.join(", ")
    ));
    progress_bar
}

async fn download_validate_unpack_to_targets(
    download: Download,
    compat_tool: CompatTool,
    targets: Vec<apps::AppInstallations>,
    multi_progress: MultiProgress,
) -> Result<()> {
    if targets.is_empty() {
        return Ok(());
    }

    let output_dir = download.download_dir()?;

    if files::check_if_exists(&output_dir).await {
        fs::remove_dir_all(&output_dir).await?;
    }

    let file = OpenOptions::new()
        .create_new(true)
        .write(true)
        .open(&output_dir)
        .await
        .with_context(|| {
            format!(
                "[Download] Failed creating destination file: {}",
                output_dir.display()
            )
        })?;

    let download_progress_bar =
        init_download_progress_for_targets(&download, &targets, multi_progress.clone()).await;

    downloads::download_to_async_write(
        &download.download_url,
        &mut download_progress_bar.wrap_async_write(file),
    )
    .await?;

    download_progress_bar.set_style(get_message_bar_style().await);
    download_progress_bar.finish_with_message(download_progress_bar.message().replacen(
        "Downloading",
        "Downloaded",
        1,
    ));

    if let Some(ref git_hash_sum) = download.hash_sum {
        let hash_content = &downloads::download_file_into_memory(&git_hash_sum.sum_content)
            .await
            .with_context(|| {
                format!(
                    "Error getting expected download hash for {}",
                    download.version
                )
            })?;
        let hash_sum = hashing::HashSums {
            sum_content: hash_content.to_owned(),
            sum_type: git_hash_sum.sum_type.clone(),
        };

        validate_file(
            &download.file_name,
            &output_dir,
            hash_sum,
            multi_progress.clone(),
        )
        .await?;
    }

    let install_name = compat_tool.installation_name(&download.version);

    let unpack_joins = FuturesUnordered::new();

    for app_inst in targets {
        let install_dir = app_inst.installation_dir(&compat_tool).unwrap();
        let install_path = install_dir.join(&install_name);
        let output_dir = output_dir.clone();
        let download = download.clone();
        let compat_tool = compat_tool.clone();
        let multi_progress = multi_progress.clone();
        let install_name = install_name.clone();

        unpack_joins.push(async move {
            if files::check_if_exists(&install_path).await {
                fs::remove_dir_all(&install_path).await.with_context(|| {
                    format!(
                        "Error removing existing install at {}",
                        install_path.display()
                    )
                })?;
            }

            // Open the compressed file
            let compressed_file = File::open(&output_dir).await.with_context(|| {
                format!("Error opening compressed file {}", output_dir.display())
            })?;

            // Wrap the file with progress tracking to read compressed bytes
            let unpack_progress_bar =
                init_unpack_progress(&install_dir, &output_dir, multi_progress.clone())
                    .await
                    .with_context(|| format!("Error unpacking {}", output_dir.display()))?;

            let progress_reader = unpack_progress_bar.wrap_async_read(compressed_file);

            // Wrap with BufReader to provide AsyncBufRead for the decompressor
            let buf_reader = BufReader::new(progress_reader);

            // Create Decompressor from the BufReader
            let path_str = output_dir.to_string_lossy();
            let decompressor = files::Decompressor::from_reader(buf_reader, &path_str)
                .with_context(|| format!("Error checking file type of {}", output_dir.display()))?;

            files::unpack_file(&compat_tool, &download, decompressor, &install_dir)
                .await
                .with_context(|| format!("Error unpacking {}", output_dir.display()))?;

            unpack_progress_bar.set_style(get_message_bar_style().await);
            unpack_progress_bar.finish_with_message(format!(
                "Done! {} installed in {}/{}",
                compat_tool,
                install_dir.display(),
                install_name,
            ));

            Ok::<_, anyhow::Error>(())
        });
    }

    unpack_joins
        .for_each(|res| {
            if let Err(e) = res {
                multi_progress.println(format!("{e}")).unwrap();
            }
            future::ready(())
        })
        .await;

    Ok(())
}

/// Checks if the selected Release/version is already installed.
///
/// Will prompt the user to overwrite existing files
async fn should_download(release: &Release, install_dir: &mut PathBuf) -> bool {
    // Check if versions exist in disk.
    // If they do, ask the user if it should be overwritten
    !files::check_if_exists(install_dir).await
        || helper_menus::confirm_menu(
            format!(
                "Version {} exists in the installation path. Overwrite?",
                release.tag_name
            ),
            String::from("If you choose yes, you will re-install it."),
            false,
        )
}

async fn get_progress_style() -> ProgressStyle {
    PROGRESS_BAR_STYLE.get_or_init(|| {
        future::ready(ProgressStyle::default_bar()
            .template("{msg}\n{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec})").unwrap()
            .progress_chars("#>-"))
    }).await.clone()
}

pub(crate) async fn get_message_bar_style() -> ProgressStyle {
    MESSAGE_BAR_STYLE
        .get_or_init(|| future::ready(ProgressStyle::default_bar().template("{msg}").unwrap()))
        .await
        .clone()
}

#[cfg(test)]
mod tests {
    use super::*;
    use libprotonup::apps::AppInstallations;
    use libprotonup::sources::{Forge, ToolType};
    use serde_json::json;

    fn make_download(url: &str, name: &str, size: u64) -> Download {
        Download {
            download_url: url.into(),
            file_name: name.into(),
            size,
            ..Default::default()
        }
    }

    fn make_release(tag: &str) -> Release {
        serde_json::from_value(json!({
            "tag_name": tag,
            "name": tag,
            "url": null,
            "assets": [],
            "body": null
        }))
        .unwrap()
    }

    fn make_compat_tool() -> CompatTool {
        CompatTool::new_custom(
            "TestTool".into(),
            Forge::GitHub,
            "owner".into(),
            "repo".into(),
            ToolType::WineBased,
            None,
            None,
            None,
        )
    }

    #[test]
    fn test_group_and_dedup_same_download_grouped() {
        let d1 = make_download("https://example.com/a.tar.gz", "a.tar.gz", 100);
        let d2 = make_download("https://example.com/a.tar.gz", "a.tar.gz", 100);
        let r1 = make_release("v1.0");
        let r2 = make_release("v1.0");
        let ct = make_compat_tool();
        let entries = vec![
            (d1, r1, AppInstallations::Steam, ct.clone()),
            (d2, r2, AppInstallations::Lutris, ct),
        ];

        let (groups, releases) = group_and_dedup_releases(entries);

        assert_eq!(groups.len(), 1, "identical downloads merge into one group");
        let key = (
            "https://example.com/a.tar.gz".to_string(),
            "a.tar.gz".to_string(),
            100u64,
        );
        assert!(groups.contains_key(&key));
        assert_eq!(groups[&key].3.len(), 2, "group should have two targets");
        assert_eq!(releases.len(), 1, "same tag_name yields one release");
    }

    #[test]
    fn test_group_and_dedup_different_downloads_separate_groups() {
        let d1 = make_download("https://example.com/a.tar.gz", "a.tar.gz", 100);
        let d2 = make_download("https://example.com/b.tar.gz", "b.tar.gz", 200);
        let r1 = make_release("v1.0");
        let r2 = make_release("v2.0");
        let ct = make_compat_tool();
        let entries = vec![
            (d1, r1, AppInstallations::Steam, ct.clone()),
            (d2, r2, AppInstallations::Lutris, ct),
        ];

        let (groups, releases) = group_and_dedup_releases(entries);

        assert_eq!(
            groups.len(),
            2,
            "different downloads create separate groups"
        );
        assert_eq!(
            releases.len(),
            2,
            "different tag_names produce separate releases"
        );
    }

    #[test]
    fn test_group_and_dedup_three_entries_two_match() {
        let d1 = make_download("https://example.com/same.tar.gz", "same.tar.gz", 100);
        let d2 = make_download("https://example.com/same.tar.gz", "same.tar.gz", 100);
        let d3 = make_download("https://example.com/diff.tar.gz", "diff.tar.gz", 200);
        let r1 = make_release("v1.0");
        let r2 = make_release("v1.0");
        let r3 = make_release("v2.0");
        let ct = make_compat_tool();
        let entries = vec![
            (d1, r1, AppInstallations::Steam, ct.clone()),
            (d2, r2, AppInstallations::SteamFlatpak, ct.clone()),
            (d3, r3, AppInstallations::Lutris, ct),
        ];

        let (groups, releases) = group_and_dedup_releases(entries);

        assert_eq!(groups.len(), 2);
        let key_same = (
            "https://example.com/same.tar.gz".to_string(),
            "same.tar.gz".to_string(),
            100u64,
        );
        assert_eq!(
            groups[&key_same].3.len(),
            2,
            "group for same download has two targets"
        );
        let key_diff = (
            "https://example.com/diff.tar.gz".to_string(),
            "diff.tar.gz".to_string(),
            200u64,
        );
        assert_eq!(
            groups[&key_diff].3.len(),
            1,
            "group for different download has one target"
        );
        assert_eq!(releases.len(), 2);
    }

    #[test]
    fn test_group_and_dedup_different_releases_same_tag_deduplicated() {
        let d1 = make_download("https://example.com/a.tar.gz", "a.tar.gz", 100);
        let d2 = make_download("https://example.com/b.tar.gz", "b.tar.gz", 200);
        let r1 = make_release("GE-Proton9-10");
        let r2 = make_release("GE-Proton9-10");
        let ct = make_compat_tool();
        let entries = vec![
            (d1, r1, AppInstallations::Steam, ct.clone()),
            (d2, r2, AppInstallations::Lutris, ct),
        ];

        let (groups, releases) = group_and_dedup_releases(entries);

        assert_eq!(groups.len(), 2);
        assert_eq!(
            releases.len(),
            1,
            "releases with identical tag_name are deduplicated"
        );
        assert_eq!(releases[0].tag_name, "GE-Proton9-10");
    }

    #[test]
    fn test_group_and_dedup_single_entry_passes_through() {
        let d = make_download("https://example.com/a.tar.gz", "a.tar.gz", 100);
        let r = make_release("v1.0");
        let ct = make_compat_tool();
        let entries = vec![(d, r, AppInstallations::Steam, ct)];

        let (groups, releases) = group_and_dedup_releases(entries);

        assert_eq!(groups.len(), 1);
        assert_eq!(releases.len(), 1);
        let key = (
            "https://example.com/a.tar.gz".to_string(),
            "a.tar.gz".to_string(),
            100u64,
        );
        assert_eq!(groups[&key].3.len(), 1);
    }

    #[test]
    fn test_group_and_dedup_empty_entries() {
        let entries: Vec<(Download, Release, AppInstallations, CompatTool)> = vec![];

        let (groups, releases) = group_and_dedup_releases(entries);

        assert!(groups.is_empty());
        assert!(releases.is_empty());
    }

    fn make_release_with_body(tag: &str, body: Option<&str>) -> Release {
        let body_value = body.map(|b| serde_json::Value::String(b.into()));
        serde_json::from_value(json!({
            "tag_name": tag,
            "name": tag,
            "url": null,
            "assets": [],
            "body": body_value
        }))
        .unwrap()
    }

    #[test]
    fn test_format_whatsnew_with_body() {
        let release = make_release_with_body("GE-Proton9-10", Some("Fixed bug\nAdded feature"));
        let ct = make_compat_tool();
        let output = format_whatsnew(&release, &ct);
        assert!(output.contains("Release Notes"));
        assert!(output.contains("GE-Proton9-10"));
        assert!(output.contains("github.com"));
        assert!(output.contains("Fixed bug"));
        assert!(output.contains("Added feature"));
    }

    #[test]
    fn test_format_whatsnew_without_body() {
        let release = make_release_with_body("GE-Proton9-10", None);
        let ct = make_compat_tool();
        let output = format_whatsnew(&release, &ct);
        assert!(output.contains("no release notes"));
    }

    #[test]
    fn test_format_whatsnew_truncated() {
        let long_body = (0..50)
            .map(|i| format!("line {}", i))
            .collect::<Vec<_>>()
            .join("\n");
        let release = make_release_with_body("GE-Proton9-10", Some(&long_body));
        let ct = make_compat_tool();
        let output = format_whatsnew(&release, &ct);
        assert!(
            output.contains("[truncated]"),
            "body with >40 lines should show [truncated]"
        );
        assert!(
            !output.contains("line 41"),
            "line 41 (42nd line) should be beyond the 40-line cutoff"
        );
    }
}
