extern crate cargo_update;
extern crate jobserver;
extern crate tabwriter;
extern crate git2;

use std::io::{ErrorKind as IoErrorKind, Write, stdout, sink};
use std::fmt::{self, Formatter, Display};
use std::process::{Command, exit};
use std::collections::BTreeMap;
use std::iter::FromIterator;
use tabwriter::TabWriter;
use std::{env, mem};
#[cfg(target_os = "windows")]
use std::fs::File;
use std::fs;


fn main() {
    let result = actual_main().err().unwrap_or(0);
    exit(result);
}

fn actual_main() -> Result<(), i32> {
    let opts = cargo_update::Options::parse();

    if cfg!(target_os = "windows") {
        for old_version in fs::read_dir(env::current_exe().unwrap().parent().unwrap().canonicalize().unwrap())
            .unwrap()
            .map(Result::unwrap)
            .filter(|f| f.file_name().to_string_lossy().starts_with("cargo-install-update.exe-v")) {
            fs::remove_file(old_version.path()).unwrap();
        }
    }

    let crates_file = cargo_update::ops::crates_file_in(&opts.cargo_dir.1);
    let http_proxy = cargo_update::ops::find_proxy(&crates_file);
    let configuration = cargo_update::ops::PackageConfig::read(&crates_file.with_file_name(".install_config.toml"),
                                                               &crates_file.with_file_name(".crates2.json")).map_err(|(e, r)| {
            eprintln!("Reading config: {}", e);
            r
        })?;
    let cargo_config = cargo_update::ops::CargoConfig::load(&crates_file);
    let mut packages = cargo_update::ops::installed_registry_packages(&crates_file);
    let installed_git_packages = if opts.update_git || (opts.update && opts.install) {
        cargo_update::ops::installed_git_repo_packages(&crates_file)
    } else {
        vec![]
    };

    if !opts.filter.is_empty() {
        packages.retain(|p| configuration.get(&p.name).map(|p_cfg| opts.filter.iter().all(|f| f.matches(&p.name, p_cfg))).unwrap_or(false));
    }
    match (opts.all, opts.to_update.is_empty()) {
        (true, true) => {}
        (true, false) => {
            for pkg in cargo_update::ops::intersect_packages(&packages, &opts.to_update, opts.install, &installed_git_packages).into_iter() {
                if !packages.iter().any(|p| p.name == pkg.name) {
                    packages.push(pkg);
                }
            }
        }
        (false, true) => {
            if opts.update {
                panic!("No packages to update and neither --list nor --all specified, this should've been caught by option parser \
                        (please report to http://github.com/nabijaczleweli/cargo-update)")
            }
        }
        (false, false) => packages = cargo_update::ops::intersect_packages(&packages, &opts.to_update, opts.install, &installed_git_packages),
    }

    // These are all in the same order and (item => [package names]) maps
    let mut registry_urls = BTreeMap::<_, Vec<_>>::new();
    let mut registry_urls_err = None;
    packages.retain(|package| {
        let mut iu = match cargo_update::ops::get_index_url(&crates_file, &package.registry, cargo_config.registries_crates_io_protocol_sparse)
            .map(Some)
            .or_else(|e| {
                if opts.update || !opts.quiet {
                    eprintln!("Couldn't get registry for {}: {}.", package.name, e);
                }
                if opts.update { Err(2) } else { Ok(None) }
            }) {
            Ok(iu) => iu,
            Err(err) => {
                registry_urls_err = Some(err);
                return true;
            }
        };
        if let Some(iu) = iu.as_mut().map(|x| mem::replace(x, Default::default())) {
            registry_urls.entry(iu)
                .or_default()
                .push(package.name.clone());
        }
        iu.is_some()
    });
    if let Some(ret) = registry_urls_err {
        return Err(ret);
    }
    let registry_urls: Vec<_> = registry_urls.into_iter().collect();

    let registries: Vec<_> = Result::from_iter(registry_urls.iter()
        .map(|((registry_url, sparse, _), pkg_names)| {
            cargo_update::ops::assert_index_path(&opts.cargo_dir.1, &registry_url[..], *sparse)
                .map(|path| (path, *sparse, &pkg_names[..]))
                .map_err(|e| {
                    eprintln!("Couldn't get package repository: {}.", e);
                    2
                })
        }))?;
    let mut registry_repos: Vec<_> = Result::from_iter(registries.iter().map(|(registry, sparse, _)| {
        cargo_update::ops::open_index_repository(registry, *sparse).map_err(|(init, e)| {
            match init {
                true => {
                    eprintln!("Failed to initialise fresh registry repository at {}: {}.\nTry running 'cargo search cargo-update' to initialise the \
                               repository.",
                              registry.display(),
                              e)
                }
                false => eprintln!("Failed to open registry repository at {}: {}.", registry.display(), e),
            }
            2
        })
    }))?;
    for (i, mut registry_repo) in registry_repos.iter_mut().enumerate() {
        let auth_providers = cargo_update::ops::auth_providers(&crates_file,
                                                               &opts.install_cargo.1,
                                                               &cargo_config.sparse_registries,
                                                               (registry_urls[i].0).1,
                                                               &(registry_urls[i].0).2,
                                                               &(registry_urls[i].0).0);
        cargo_update::ops::update_index(&mut registry_repo,
                                        &(registry_urls[i].0).0,
                                        registry_urls[i].1.iter(),
                                        http_proxy.as_ref().map(String::as_str),
                                        cargo_config.net_git_fetch_with_cli,
                                        &cargo_config.http,
                                        auth_providers.try().as_deref(),
                                        &mut if !opts.quiet {
                                            Box::new(stdout()) as Box<dyn Write>
                                        } else {
                                            Box::new(sink()) as Box<dyn Write>
                                        }).map_err(|e| {
                eprintln!("Failed to update index repository {}: {}.", registry_urls[i].0.2, e);
                2
            })?;
    }

    let latest_registries: Vec<_> = Result::from_iter(registry_repos.iter().zip(registries.iter()).map(|(registry_repo, (registry, ..))| {
        cargo_update::ops::parse_registry_head(registry_repo).map_err(|e| {
            eprintln!("Failed to read remote HEAD of registry repository at {}: {}.", registry.display(), e);
            2
        })
    }))?;

    for package in &mut packages {
        let registry_idx = match registries.iter().position(|(.., pkg_names)| pkg_names.contains(&package.name)) {
            Some(i) => i,
            None => {
                panic!("Couldn't find registry for package {} (please report to http://github.com/nabijaczleweli/cargo-update)",
                       &package.name[..])
            }
        };

        let install_prereleases = configuration.get(&package.name).and_then(|c| c.install_prereleases);
        package.pull_version(&latest_registries[registry_idx],
                             &registry_repos[registry_idx],
                             install_prereleases,
                             opts.released_after);
    }

    if !opts.quiet {
        let mut out = TabWriter::new(stdout());
        writeln!(out, "Package\tInstalled\tLatest\tNeeds update").unwrap();
        for (package, package_target_version, package_install_prereleases) in
            {
                let mut pkgs = packages.iter()
                    .map(|p| {
                        let cfg = configuration.get(&p.name);
                        (p, cfg.as_ref().and_then(|c| c.target_version.as_ref()), cfg.as_ref().and_then(|c| c.install_prereleases))
                    })
                    .collect::<Vec<_>>();
                pkgs.sort_by(|&(ref lhs, lhstv, lhsip), &(ref rhs, rhstv, rhsip)| {
                    (!lhs.needs_update(lhstv, lhsip, opts.downdate), &lhs.name).cmp(&(!rhs.needs_update(rhstv, rhsip, opts.downdate), &rhs.name))
                });
                pkgs
            } {
            write!(out, "{}\t", package.name).unwrap();

            if let Some(ref v) = package.version {
                write!(out, "v{}", v).unwrap();
            } else {
                write!(out, "No").unwrap();
            }

            if let Some(tv) = package_target_version {
                write!(out, "\t{}", tv).unwrap();
            } else if let Some(upd_v) = package.update_to_version() {
                write!(out, "\tv{}", upd_v).unwrap();
                if let Some(alt_v) = package.alternative_version.as_ref() {
                    write!(out, " (v{} available)", alt_v).unwrap();
                }
            } else {
                write!(out, "\tN/A").unwrap();
            }

            writeln!(out,
                     "\t{}",
                     if package.needs_update(package_target_version, package_install_prereleases, opts.downdate) {
                         "Yes"
                     } else {
                         "No"
                     })
                .unwrap();
        }
        writeln!(out).unwrap();
        out.flush().unwrap();
    }

    note_removed_executables(&opts, packages.iter().map(|p| (&p.name, &p.executables)));

    let jobserver_jobs = opts.recursive_jobs.map(|nz| nz.get()).unwrap_or(1);
    let jobserver = jobserver::Client::new(jobserver_jobs).expect("jobserver");
    let jobserverise = |mut cmd| {
        if opts.recursive_jobs.is_some() {
            jobserver.configure_make(&mut cmd)
        }
        cmd
    };
    for _ in 0..jobserver_jobs {
        // Initialising to 0 then filling up errors on Windows
        jobserver.acquire_raw().unwrap();
    }
    let cargo_limiter = opts.concurrent_cargos.map(|cc| jobserver::Client::new(cc.get()).expect("cargo_limiter"));
    std::thread::scope(|scope| {
        let mut updaters = vec![];
        if opts.update {
            if !opts.force {
                packages.retain(|p| {
                    let cfg = configuration.get(&p.name);
                    p.needs_update(cfg.as_ref().and_then(|c| c.target_version.as_ref()),
                                   cfg.as_ref().and_then(|c| c.install_prereleases),
                                   opts.downdate)
                });
            }

            packages.retain(|pkg| pkg.update_to_version().is_some());

            if !packages.is_empty() {
                updaters = packages.into_iter()
                    .map(|package| {
                        scope.spawn(|| -> (bool, String, Result<(), i32>) {
                            let _limit = cargo_limiter.as_ref().map(|cl| cl.acquire());
                            let _job = jobserver.acquire();
                            if !opts.quiet {
                                println!("{} {}",
                                         if package.version.is_some() {
                                             "Updating"
                                         } else {
                                             "Installing"
                                         },
                                         package.name);
                            }

                            if cfg!(target_os = "windows") && package.version.is_some() && package.name == "cargo-update" {
                                save_cargo_update_exec(package.version.as_ref().unwrap());
                            }

                            let registry_name = match registry_urls.iter().find(|(_, pkg_names)| pkg_names.contains(&package.name)) {
                                Some(u) => &(u.0).2,
                                None => {
                                    panic!("Couldn't find registry URL for package {} (please report to http://github.com/nabijaczleweli/cargo-update)",
                                           &package.name[..])
                                }
                            };
                            let install_res = {
                                    let cfg = configuration.get(&package.name);
                                    if !opts.install_cargo.0 && registry_name == "crates-io" && opts.cargo_install_args.is_empty() &&
                                       (cfg == None || cfg == Some(&Default::default())) {
                                            jobserverise(Command::new("cargo-binstall"))
                                                .arg("--roots")
                                                .arg(&opts.cargo_dir.0)
                                                .arg("--no-confirm")
                                                .arg("--version")
                                                .arg(&format!("={}", package.update_to_version().unwrap()))
                                                .arg("--force")
                                                .args(if opts.quiet { Some("--quiet") } else { None })
                                                .args(if opts.locked { Some("--locked") } else { None })
                                                .arg(&package.name)
                                                .status()
                                        } else {
                                            Err(IoErrorKind::NotFound.into())
                                        }
                                        .or_else(|_| if let Some(cfg) = cfg {
                                            let mut cmd = jobserverise(Command::new(&opts.install_cargo.1));
                                            cfg.environmentalise(&mut cmd)
                                                .args(cfg.cargo_args(&package.executables).iter().map(AsRef::as_ref))
                                                .arg("--root")
                                                .arg(&opts.cargo_dir.0)
                                                .args(if opts.quiet { Some("--quiet") } else { None })
                                                .args(if opts.locked { Some("--locked") } else { None })
                                                .arg("--version")
                                                .arg(if let Some(tv) = cfg.target_version.as_ref() {
                                                    tv.to_string()
                                                } else {
                                                    package.update_to_version().unwrap().to_string()
                                                })
                                                .arg("--registry")
                                                .arg(registry_name.as_ref());
                                            if let Some(ref j) = opts.jobs.as_ref() {
                                                cmd.arg("-j").arg(j.to_string());
                                            }
                                            cmd.arg(&package.name)
                                                .args(&opts.cargo_install_args)
                                                .status()
                                        } else {
                                            let mut cmd = jobserverise(Command::new(&opts.install_cargo.1));
                                            cmd.arg("install")
                                                .arg("--root")
                                                .arg(&opts.cargo_dir.0)
                                                .arg("-f")
                                                .args(if opts.quiet { Some("--quiet") } else { None })
                                                .args(if opts.locked { Some("--locked") } else { None })
                                                .arg("--version")
                                                .arg(package.update_to_version().unwrap().to_string())
                                                .arg("--registry")
                                                .arg(registry_name.as_ref());
                                            if let Some(ref j) = opts.jobs.as_ref() {
                                                cmd.arg("-j").arg(j.to_string());
                                            }
                                            cmd.arg(&package.name)
                                                .args(&opts.cargo_install_args)
                                                .status()
                                        })
                                }
                                .unwrap();

                            if !opts.quiet {
                                println!();
                            }
                            if !install_res.success() {
                                if cfg!(target_os = "windows") && package.version.is_some() && package.name == "cargo-update" {
                                    restore_cargo_update_exec(package.version.as_ref().unwrap());
                                }

                                (false, package.name, Err(install_res.code().unwrap_or(-1)))
                            } else {
                                (false, package.name, Ok(()))
                            }
                        })
                    })
                    .collect();
            } else {
                if !opts.quiet {
                    println!("No packages need updating.");
                }
            }
        }

        if opts.update_git {
            let mut packages = installed_git_packages;

            if !opts.filter.is_empty() {
                packages.retain(|p| configuration.get(&p.name).map(|p_cfg| opts.filter.iter().all(|f| f.matches(&p.name, p_cfg))).unwrap_or(false));
            }
            if opts.update && !opts.all {
                packages.retain(|p| opts.to_update.iter().any(|u| p.name == u.0));
            }

            if !opts.quiet {
                print!("    Checking {} git package{}", packages.len(), if packages.len() == 1 { "" } else { "s" });
                let _ = stdout().flush();
            }

            let git_db_dir = crates_file.with_file_name("git").join("db");
            for package in &mut packages {
                package.pull_version(&opts.temp_dir,
                                     &git_db_dir,
                                     http_proxy.as_ref().map(String::as_str),
                                     cargo_config.net_git_fetch_with_cli);
                if !opts.quiet {
                    let _ = stdout().write_all(b".").and_then(|_| stdout().flush());
                }
            }

            if !opts.quiet {
                println!("\n");

                let mut out = TabWriter::new(stdout());
                writeln!(out, "Package\tInstalled\tLatest\tNeeds update").unwrap();
                packages.sort_by(|lhs, rhs| (!lhs.needs_update(), &lhs.name).cmp(&(!rhs.needs_update(), &rhs.name)));
                for package in &packages {
                    struct OidOrError<'a, Oid: Display, GitError: Display>(&'a Result<Oid, GitError>);
                    impl<Oid: Display, GitError: Display> Display for OidOrError<'_, Oid, GitError> {
                        fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), fmt::Error> {
                            match self.0 {
                                Ok(oid) => write!(f, "{}", oid),
                                Err(err) => write!(f, "git error: {}", err),
                            }
                        }
                    }
                    writeln!(out,
                             "{}\t{}\t{}\t{}",
                             package.name,
                             package.id,
                             OidOrError(&package.newest_id),
                             if package.needs_update() { "Yes" } else { "No" })
                        .unwrap();
                }
                writeln!(out).unwrap();
                out.flush().unwrap();
            }

            note_removed_executables(&opts, packages.iter().map(|p| (&p.name, &p.executables)));

            if opts.update {
                if !opts.force {
                    packages.retain(cargo_update::ops::GitRepoPackage::needs_update);
                }

                if !packages.is_empty() {
                    updaters.extend(packages.into_iter()
                        .map(|package| {
                            scope.spawn(|| -> (bool, String, Result<(), i32>) {
                                let _limit = cargo_limiter.as_ref().map(|cl| cl.acquire());
                                let _job = jobserver.acquire();
                                if !opts.quiet {
                                    println!("Updating {} from {}", package.name, package.url);
                                }

                                if cfg!(target_os = "windows") && package.name == "cargo-update" {
                                    save_cargo_update_exec(&package.id);
                                }

                                let install_res = if let Some(cfg) = configuration.get(&package.name) {
                                        let mut cmd = Command::new(&opts.install_cargo.1);
                                        cmd.args(cfg.cargo_args(package.executables).iter().map(AsRef::as_ref))
                                            .arg("--root")
                                            .arg(&opts.cargo_dir.0)
                                            .args(if opts.quiet { Some("--quiet") } else { None })
                                            .args(if opts.locked { Some("--locked") } else { None })
                                            .arg("--git")
                                            .arg(&package.url)
                                            .arg(&package.name);
                                        if let Some(ref b) = package.branch.as_ref() {
                                            cmd.arg("--branch").arg(b);
                                        }
                                        if let Some(ref j) = opts.jobs.as_ref() {
                                            cmd.arg("-j").arg(j.to_string());
                                        }
                                        cmd.args(&opts.cargo_install_args).status()
                                    } else {
                                        let mut cmd = Command::new(&opts.install_cargo.1);
                                        cmd.arg("install")
                                            .arg("--root")
                                            .arg(&opts.cargo_dir.0)
                                            .arg("-f")
                                            .args(if opts.quiet { Some("--quiet") } else { None })
                                            .args(if opts.locked { Some("--locked") } else { None })
                                            .arg("--git")
                                            .arg(&package.url)
                                            .arg(&package.name);
                                        if let Some(ref b) = package.branch.as_ref() {
                                            cmd.arg("--branch").arg(b);
                                        }
                                        if let Some(ref j) = opts.jobs.as_ref() {
                                            cmd.arg("-j").arg(j.to_string());
                                        }
                                        cmd.args(&opts.cargo_install_args).status()
                                    }
                                    .unwrap();

                                if !opts.quiet {
                                    println!();
                                }
                                if !install_res.success() {
                                    if cfg!(target_os = "windows") && package.name == "cargo-update" {
                                        restore_cargo_update_exec(&package.id);
                                    }

                                    (true, package.name, Err(install_res.code().unwrap_or(-1)))
                                } else {
                                    (true, package.name, Ok(()))
                                }
                            })
                        }));
                } else {
                    if !opts.quiet {
                        println!("No git packages need updating.");
                    }
                }
            }
        }

        for _ in 0..jobserver_jobs {
            jobserver.release_raw().unwrap();
        }

        if opts.update {
            let (success, errored, result): (Vec<(bool, String)>, Vec<(bool, String)>, Option<i32>) = updaters.into_iter()
                .map(|u| u.join().unwrap())
                .fold((vec![], vec![], None), |(mut s, mut e, r), (g, pn, p)| match p {
                    Ok(()) => {
                            s.push((g, pn));
                            (s, e, r)
                        }
                    Err(pr) => {
                        e.push((g, pn));
                        (s, e, r.or(Some(pr)))
                    }
                });

            fn commas<'p>(mut into: impl Write, pkgs: impl Iterator<Item = &'p (bool, String)>) {
                for (i, (_, e)) in pkgs.enumerate() {
                    if i != 0 {
                        let _ = write!(into, ", ");
                    }
                    let _ = write!(into, "{}", e);
                }
            }
            if !opts.quiet {
                let groupsum = |git, label| if success.iter().chain(errored.iter()).any(|(g, _)| *g == git) {
                    let count = success.iter().filter(|(g, _)| *g == git).count();
                    println!();
                    println!("Updated {} {}package{}.", count, label, if count == 1 { "" } else { "s" });

                    let mut errored = errored.iter().filter(|(g, _)| *g == git).peekable();
                    if errored.peek().is_some() && result.is_some() {
                        eprint!("Failed to update ");
                        commas(std::io::stderr(), errored);
                        eprintln!(".");
                        eprintln!();
                    }
                };
                groupsum(false, "");
                groupsum(true, "git ");

                print!("Overall updated {} package{}",
                       success.len(),
                       match success.len() {
                           0 => "s",
                           1 => ": ",
                           _ => "s: ",
                       });
                commas(std::io::stdout(), success.iter());
                println!(".");
            }

            if !errored.is_empty() && result.is_some() {
                eprint!("Overall failed to update {} package{}",
                        errored.len(),
                        match errored.len() {
                            0 => "s",
                            1 => ": ",
                            _ => "s: ",
                        });
                commas(std::io::stderr(), errored.iter());
                eprintln!(".");
                return Err(result.unwrap());
            }
        }

        Ok(())
    })
}


fn note_removed_executables<'a, T: Iterator<Item = (&'a String, &'a Vec<String>)>>(opts: &cargo_update::Options, packages: T) {
    if opts.quiet {
        return;
    }

    // https://manpages.debian.org/unstable/cargo/cargo-install.1.en.html
    // •   --root option                            we render this as -c
    // •   CARGO_INSTALL_ROOT environment variable  same
    // •   install.root Cargo config value          we don't support this
    // •   CARGO_HOME environment variable          same
    // •   $HOME/.cargo                             same
    let mut any = false;
    let bindir = opts.cargo_dir.1.join("bin");
    for (name, executables) in packages {
        let mut first = true;
        for e in executables.iter().filter(|e| !fs::exists(bindir.join(e)).unwrap_or(false)) {
            if mem::replace(&mut first, false) {
                print!("{} contains removed executables ({}", name, e);
            } else {
                print!(", {}", e);
            }
        }
        if !first {
            println!("), which will be re-installed on update — you can remove it with cargo uninstall {}", name);
            any = true;
        }
    }
    if any {
        println!();
    }
}


/// This way the past-current exec will be "replaced" and we'll get no dupes in .cargo.toml
#[cfg(target_os="windows")]
fn save_cargo_update_exec<D: Display>(version: &D) {
    save_cargo_update_exec_impl(format!("exe-v{}", version));
}

#[cfg(target_os="windows")]
fn save_cargo_update_exec_impl(extension: String) {
    let cur_exe = env::current_exe().unwrap();
    fs::rename(&cur_exe, cur_exe.with_extension(extension)).unwrap();
    File::create(cur_exe).unwrap();
}

#[cfg(target_os="windows")]
fn restore_cargo_update_exec<D: Display>(version: &D) {
    restore_cargo_update_exec_impl(format!("exe-v{}", version))
}

#[cfg(target_os="windows")]
fn restore_cargo_update_exec_impl(extension: String) {
    let cur_exe = env::current_exe().unwrap();
    fs::remove_file(&cur_exe).unwrap();
    fs::rename(cur_exe.with_extension(extension), cur_exe).unwrap();
}


#[cfg(not(target_os="windows"))]
fn save_cargo_update_exec<D: Display>(_: &D) {}

#[cfg(not(target_os="windows"))]
fn restore_cargo_update_exec<D: Display>(_: &D) {}
