use std::collections::{BTreeMap, HashSet};
use std::fs::{self, File};
use std::io::{self, BufRead};
use std::iter::repeat;
use std::str;
use std::time::Duration;
use std::{cmp, env};

use anyhow::{bail, format_err};
use crates_io::{NewCrate, NewCrateDependency, Registry};
use curl::easy::{Easy, InfoType, SslOpt, SslVersion};
use log::{log, Level};
use percent_encoding::{percent_encode, NON_ALPHANUMERIC};

use crate::core::dependency::DepKind;
use crate::core::manifest::ManifestMetadata;
use crate::core::source::Source;
use crate::core::{Package, SourceId, Workspace};
use crate::ops;
use crate::sources::{RegistrySource, SourceConfigMap, CRATES_IO_REGISTRY};
use crate::util::config::{self, Config, SslVersionConfig, SslVersionConfigRange};
use crate::util::errors::{CargoResult, CargoResultExt};
use crate::util::important_paths::find_root_manifest_for_wd;
use crate::util::IntoUrl;
use crate::util::{paths, validate_package_name};
use crate::version;

pub struct RegistryConfig {
    pub index: Option<String>,
    pub token: Option<String>,
}

pub struct PublishOpts<'cfg> {
    pub config: &'cfg Config,
    pub token: Option<String>,
    pub index: Option<String>,
    pub verify: bool,
    pub allow_dirty: bool,
    pub jobs: Option<u32>,
    pub target: Option<String>,
    pub dry_run: bool,
    pub registry: Option<String>,
    pub features: Vec<String>,
    pub all_features: bool,
    pub no_default_features: bool,
}

pub fn publish(ws: &Workspace<'_>, opts: &PublishOpts<'_>) -> CargoResult<()> {
    let pkg = ws.current()?;

    if let Some(ref allowed_registries) = *pkg.publish() {
        let reg_name = opts
            .registry
            .clone()
            .unwrap_or_else(|| CRATES_IO_REGISTRY.to_string());
        if !allowed_registries.contains(&reg_name) {
            bail!(
                "`{}` cannot be published.\n\
                 The registry `{}` is not listed in the `publish` value in Cargo.toml.",
                pkg.name(),
                reg_name
            );
        }
    }

    let (mut registry, reg_id) = registry(
        opts.config,
        opts.token.clone(),
        opts.index.clone(),
        opts.registry.clone(),
        true,
        !opts.dry_run,
    )?;
    verify_dependencies(pkg, &registry, reg_id)?;

    // Prepare a tarball, with a non-surpressable warning if metadata
    // is missing since this is being put online.
    let tarball = ops::package(
        ws,
        &ops::PackageOpts {
            config: opts.config,
            verify: opts.verify,
            list: false,
            check_metadata: true,
            allow_dirty: opts.allow_dirty,
            target: opts.target.clone(),
            jobs: opts.jobs,
            features: opts.features.clone(),
            all_features: opts.all_features,
            no_default_features: opts.no_default_features,
        },
    )?
    .unwrap();

    // Upload said tarball to the specified destination
    opts.config
        .shell()
        .status("Uploading", pkg.package_id().to_string())?;
    transmit(
        opts.config,
        pkg,
        tarball.file(),
        &mut registry,
        reg_id,
        opts.dry_run,
    )?;

    Ok(())
}

fn verify_dependencies(
    pkg: &Package,
    registry: &Registry,
    registry_src: SourceId,
) -> CargoResult<()> {
    for dep in pkg.dependencies().iter() {
        if dep.source_id().is_path() || dep.source_id().is_git() {
            if !dep.specified_req() {
                if !dep.is_transitive() {
                    // dev-dependencies will be stripped in TomlManifest::prepare_for_publish
                    continue;
                }
                let which = if dep.source_id().is_path() {
                    "path"
                } else {
                    "git"
                };
                let dep_version_source = dep.registry_id().map_or_else(
                    || "crates.io".to_string(),
                    |registry_id| registry_id.display_registry_name(),
                );
                bail!(
                    "all dependencies must have a version specified when publishing.\n\
                     dependency `{}` does not specify a version\n\
                     Note: The published dependency will use the version from {},\n\
                     the `{}` specification will be removed from the dependency declaration.",
                    dep.package_name(),
                    dep_version_source,
                    which,
                )
            }
        // TomlManifest::prepare_for_publish will rewrite the dependency
        // to be just the `version` field.
        } else if dep.source_id() != registry_src {
            if !dep.source_id().is_registry() {
                // Consider making SourceId::kind a public type that we can
                // exhaustively match on. Using match can help ensure that
                // every kind is properly handled.
                panic!("unexpected source kind for dependency {:?}", dep);
            }
            // Block requests to send to crates.io with alt-registry deps.
            // This extra hostname check is mostly to assist with testing,
            // but also prevents someone using `--index` to specify
            // something that points to crates.io.
            if registry_src.is_default_registry() || registry.host_is_crates_io() {
                bail!("crates cannot be published to crates.io with dependencies sourced from other\n\
                       registries. `{}` needs to be published to crates.io before publishing this crate.\n\
                       (crate `{}` is pulled from {})",
                      dep.package_name(),
                      dep.package_name(),
                      dep.source_id());
            }
        }
    }
    Ok(())
}

fn transmit(
    config: &Config,
    pkg: &Package,
    tarball: &File,
    registry: &mut Registry,
    registry_id: SourceId,
    dry_run: bool,
) -> CargoResult<()> {
    let deps = pkg
        .dependencies()
        .iter()
        .filter(|dep| {
            // Skip dev-dependency without version.
            dep.is_transitive() || dep.specified_req()
        })
        .map(|dep| {
            // If the dependency is from a different registry, then include the
            // registry in the dependency.
            let dep_registry_id = match dep.registry_id() {
                Some(id) => id,
                None => SourceId::crates_io(config)?,
            };
            // In the index and Web API, None means "from the same registry"
            // whereas in Cargo.toml, it means "from crates.io".
            let dep_registry = if dep_registry_id != registry_id {
                Some(dep_registry_id.url().to_string())
            } else {
                None
            };

            Ok(NewCrateDependency {
                optional: dep.is_optional(),
                default_features: dep.uses_default_features(),
                name: dep.package_name().to_string(),
                features: dep.features().iter().map(|s| s.to_string()).collect(),
                version_req: dep.version_req().to_string(),
                target: dep.platform().map(|s| s.to_string()),
                kind: match dep.kind() {
                    DepKind::Normal => "normal",
                    DepKind::Build => "build",
                    DepKind::Development => "dev",
                }
                .to_string(),
                registry: dep_registry,
                explicit_name_in_toml: dep.explicit_name_in_toml().map(|s| s.to_string()),
            })
        })
        .collect::<CargoResult<Vec<NewCrateDependency>>>()?;
    let manifest = pkg.manifest();
    let ManifestMetadata {
        ref authors,
        ref description,
        ref homepage,
        ref documentation,
        ref keywords,
        ref readme,
        ref repository,
        ref license,
        ref license_file,
        ref categories,
        ref badges,
        ref links,
    } = *manifest.metadata();
    let readme_content = match *readme {
        Some(ref readme) => Some(paths::read(&pkg.root().join(readme))?),
        None => None,
    };
    if let Some(ref file) = *license_file {
        if fs::metadata(&pkg.root().join(file)).is_err() {
            bail!("the license file `{}` does not exist", file)
        }
    }

    // Do not upload if performing a dry run
    if dry_run {
        config.shell().warn("aborting upload due to dry run")?;
        return Ok(());
    }

    let summary = pkg.summary();
    let string_features = summary
        .features()
        .iter()
        .map(|(feat, values)| {
            (
                feat.to_string(),
                values.iter().map(|fv| fv.to_string(summary)).collect(),
            )
        })
        .collect::<BTreeMap<String, Vec<String>>>();

    let publish = registry.publish(
        &NewCrate {
            name: pkg.name().to_string(),
            vers: pkg.version().to_string(),
            deps,
            features: string_features,
            authors: authors.clone(),
            description: description.clone(),
            homepage: homepage.clone(),
            documentation: documentation.clone(),
            keywords: keywords.clone(),
            categories: categories.clone(),
            readme: readme_content,
            readme_file: readme.clone(),
            repository: repository.clone(),
            license: license.clone(),
            license_file: license_file.clone(),
            badges: badges.clone(),
            links: links.clone(),
        },
        tarball,
    );

    match publish {
        Ok(warnings) => {
            if !warnings.invalid_categories.is_empty() {
                let msg = format!(
                    "the following are not valid category slugs and were \
                     ignored: {}. Please see https://crates.io/category_slugs \
                     for the list of all category slugs. \
                     ",
                    warnings.invalid_categories.join(", ")
                );
                config.shell().warn(&msg)?;
            }

            if !warnings.invalid_badges.is_empty() {
                let msg = format!(
                    "the following are not valid badges and were ignored: {}. \
                     Either the badge type specified is unknown or a required \
                     attribute is missing. Please see \
                     https://doc.rust-lang.org/cargo/reference/manifest.html#package-metadata \
                     for valid badge types and their required attributes.",
                    warnings.invalid_badges.join(", ")
                );
                config.shell().warn(&msg)?;
            }

            if !warnings.other.is_empty() {
                for msg in warnings.other {
                    config.shell().warn(&msg)?;
                }
            }

            Ok(())
        }
        Err(e) => Err(e),
    }
}

pub fn registry_configuration(
    config: &Config,
    registry: Option<String>,
) -> CargoResult<RegistryConfig> {
    let (index, token) = match registry {
        Some(registry) => {
            validate_package_name(&registry, "registry name", "")?;
            (
                Some(config.get_registry_index(&registry)?.to_string()),
                config
                    .get_string(&format!("registries.{}.token", registry))?
                    .map(|p| p.val),
            )
        }
        None => {
            // Checking for default index and token
            (
                config
                    .get_default_registry_index()?
                    .map(|url| url.to_string()),
                config.get_string("registry.token")?.map(|p| p.val),
            )
        }
    };

    Ok(RegistryConfig { index, token })
}

fn registry(
    config: &Config,
    token: Option<String>,
    index: Option<String>,
    registry: Option<String>,
    force_update: bool,
    validate_token: bool,
) -> CargoResult<(Registry, SourceId)> {
    // Parse all configuration options
    let RegistryConfig {
        token: token_config,
        index: index_config,
    } = registry_configuration(config, registry.clone())?;
    let token = token.or(token_config);
    let sid = get_source_id(config, index_config.or(index), registry)?;
    if !sid.is_remote_registry() {
        bail!(
            "{} does not support API commands.\n\
             Check for a source-replacement in .cargo/config.",
            sid
        );
    }
    let api_host = {
        let _lock = config.acquire_package_cache_lock()?;
        let mut src = RegistrySource::remote(sid, &HashSet::new(), config);
        // Only update the index if the config is not available or `force` is set.
        let cfg = src.config();
        let mut updated_cfg = || {
            src.update()
                .chain_err(|| format!("failed to update {}", sid))?;
            src.config()
        };

        let cfg = if force_update {
            updated_cfg()?
        } else {
            cfg.or_else(|_| updated_cfg())?
        };

        cfg.and_then(|cfg| cfg.api)
            .ok_or_else(|| format_err!("{} does not support API commands", sid))?
    };
    let handle = http_handle(config)?;
    if validate_token && token.is_none() {
        bail!("no upload token found, please run `cargo login`");
    };
    Ok((Registry::new_handle(api_host, token, handle), sid))
}

/// Creates a new HTTP handle with appropriate global configuration for cargo.
pub fn http_handle(config: &Config) -> CargoResult<Easy> {
    let (mut handle, timeout) = http_handle_and_timeout(config)?;
    timeout.configure(&mut handle)?;
    Ok(handle)
}

pub fn http_handle_and_timeout(config: &Config) -> CargoResult<(Easy, HttpTimeout)> {
    if config.frozen() {
        bail!(
            "attempting to make an HTTP request, but --frozen was \
             specified"
        )
    }
    if !config.network_allowed() {
        bail!("can't make HTTP request in the offline mode")
    }

    // The timeout option for libcurl by default times out the entire transfer,
    // but we probably don't want this. Instead we only set timeouts for the
    // connect phase as well as a "low speed" timeout so if we don't receive
    // many bytes in a large-ish period of time then we time out.
    let mut handle = Easy::new();
    let timeout = configure_http_handle(config, &mut handle)?;
    Ok((handle, timeout))
}

pub fn needs_custom_http_transport(config: &Config) -> CargoResult<bool> {
    Ok(http_proxy_exists(config)?
        || *config.http_config()? != Default::default()
        || env::var_os("HTTP_TIMEOUT").is_some())
}

/// Configure a libcurl http handle with the defaults options for Cargo
pub fn configure_http_handle(config: &Config, handle: &mut Easy) -> CargoResult<HttpTimeout> {
    let http = config.http_config()?;
    if let Some(proxy) = http_proxy(config)? {
        handle.proxy(&proxy)?;
    }
    if let Some(cainfo) = &http.cainfo {
        let cainfo = cainfo.resolve_path(config);
        handle.cainfo(&cainfo)?;
    }
    if let Some(check) = http.check_revoke {
        handle.ssl_options(SslOpt::new().no_revoke(!check))?;
    }

    if let Some(user_agent) = &http.user_agent {
        handle.useragent(user_agent)?;
    } else {
        handle.useragent(&version().to_string())?;
    }

    fn to_ssl_version(s: &str) -> CargoResult<SslVersion> {
        let version = match s {
            "default" => SslVersion::Default,
            "tlsv1" => SslVersion::Tlsv1,
            "tlsv1.0" => SslVersion::Tlsv10,
            "tlsv1.1" => SslVersion::Tlsv11,
            "tlsv1.2" => SslVersion::Tlsv12,
            "tlsv1.3" => SslVersion::Tlsv13,
            _ => bail!(
                "Invalid ssl version `{}`,\
                 choose from 'default', 'tlsv1', 'tlsv1.0', 'tlsv1.1', 'tlsv1.2', 'tlsv1.3'.",
                s
            ),
        };
        Ok(version)
    }
    if let Some(ssl_version) = &http.ssl_version {
        match ssl_version {
            SslVersionConfig::Single(s) => {
                let version = to_ssl_version(s.as_str())?;
                handle.ssl_version(version)?;
            }
            SslVersionConfig::Range(SslVersionConfigRange { min, max }) => {
                let min_version = min
                    .as_ref()
                    .map_or(Ok(SslVersion::Default), |s| to_ssl_version(s))?;
                let max_version = max
                    .as_ref()
                    .map_or(Ok(SslVersion::Default), |s| to_ssl_version(s))?;
                handle.ssl_min_max_version(min_version, max_version)?;
            }
        }
    }

    if let Some(true) = http.debug {
        handle.verbose(true)?;
        handle.debug_function(|kind, data| {
            let (prefix, level) = match kind {
                InfoType::Text => ("*", Level::Debug),
                InfoType::HeaderIn => ("<", Level::Debug),
                InfoType::HeaderOut => (">", Level::Debug),
                InfoType::DataIn => ("{", Level::Trace),
                InfoType::DataOut => ("}", Level::Trace),
                InfoType::SslDataIn | InfoType::SslDataOut => return,
                _ => return,
            };
            match str::from_utf8(data) {
                Ok(s) => {
                    for line in s.lines() {
                        log!(level, "http-debug: {} {}", prefix, line);
                    }
                }
                Err(_) => {
                    log!(
                        level,
                        "http-debug: {} ({} bytes of data)",
                        prefix,
                        data.len()
                    );
                }
            }
        })?;
    }

    HttpTimeout::new(config)
}

#[must_use]
pub struct HttpTimeout {
    pub dur: Duration,
    pub low_speed_limit: u32,
}

impl HttpTimeout {
    pub fn new(config: &Config) -> CargoResult<HttpTimeout> {
        let config = config.http_config()?;
        let low_speed_limit = config.low_speed_limit.unwrap_or(10);
        let seconds = config
            .timeout
            .or_else(|| env::var("HTTP_TIMEOUT").ok().and_then(|s| s.parse().ok()))
            .unwrap_or(30);
        Ok(HttpTimeout {
            dur: Duration::new(seconds, 0),
            low_speed_limit,
        })
    }

    pub fn configure(&self, handle: &mut Easy) -> CargoResult<()> {
        // The timeout option for libcurl by default times out the entire
        // transfer, but we probably don't want this. Instead we only set
        // timeouts for the connect phase as well as a "low speed" timeout so
        // if we don't receive many bytes in a large-ish period of time then we
        // time out.
        handle.connect_timeout(self.dur)?;
        handle.low_speed_time(self.dur)?;
        handle.low_speed_limit(self.low_speed_limit)?;
        Ok(())
    }
}

/// Finds an explicit HTTP proxy if one is available.
///
/// Favor cargo's `http.proxy`, then git's `http.proxy`. Proxies specified
/// via environment variables are picked up by libcurl.
fn http_proxy(config: &Config) -> CargoResult<Option<String>> {
    let http = config.http_config()?;
    if let Some(s) = &http.proxy {
        return Ok(Some(s.clone()));
    }
    if let Ok(cfg) = git2::Config::open_default() {
        if let Ok(s) = cfg.get_str("http.proxy") {
            return Ok(Some(s.to_string()));
        }
    }
    Ok(None)
}

/// Determine if an http proxy exists.
///
/// Checks the following for existence, in order:
///
/// * cargo's `http.proxy`
/// * git's `http.proxy`
/// * `http_proxy` env var
/// * `HTTP_PROXY` env var
/// * `https_proxy` env var
/// * `HTTPS_PROXY` env var
fn http_proxy_exists(config: &Config) -> CargoResult<bool> {
    if http_proxy(config)?.is_some() {
        Ok(true)
    } else {
        Ok(["http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY"]
            .iter()
            .any(|v| env::var(v).is_ok()))
    }
}

pub fn registry_login(
    config: &Config,
    token: Option<String>,
    reg: Option<String>,
) -> CargoResult<()> {
    let (registry, _) = registry(config, token.clone(), None, reg.clone(), false, false)?;

    let token = match token {
        Some(token) => token,
        None => {
            println!(
                "please visit {}/me and paste the API Token below",
                registry.host()
            );
            let mut line = String::new();
            let input = io::stdin();
            input
                .lock()
                .read_line(&mut line)
                .chain_err(|| "failed to read stdin")
                .map_err(anyhow::Error::from)?;
            // Automatically remove `cargo login` from an inputted token to allow direct pastes from `registry.host()`/me.
            line.replace("cargo login", "").trim().to_string()
        }
    };

    let RegistryConfig {
        token: old_token, ..
    } = registry_configuration(config, reg.clone())?;

    if let Some(old_token) = old_token {
        if old_token == token {
            config.shell().status("Login", "already logged in")?;
            return Ok(());
        }
    }

    config::save_credentials(config, token, reg.clone())?;
    config.shell().status(
        "Login",
        format!(
            "token for `{}` saved",
            reg.as_ref().map_or("crates.io", String::as_str)
        ),
    )?;
    Ok(())
}

pub struct OwnersOptions {
    pub krate: Option<String>,
    pub token: Option<String>,
    pub index: Option<String>,
    pub to_add: Option<Vec<String>>,
    pub to_remove: Option<Vec<String>>,
    pub list: bool,
    pub registry: Option<String>,
}

pub fn modify_owners(config: &Config, opts: &OwnersOptions) -> CargoResult<()> {
    let name = match opts.krate {
        Some(ref name) => name.clone(),
        None => {
            let manifest_path = find_root_manifest_for_wd(config.cwd())?;
            let ws = Workspace::new(&manifest_path, config)?;
            ws.current()?.package_id().name().to_string()
        }
    };

    let (mut registry, _) = registry(
        config,
        opts.token.clone(),
        opts.index.clone(),
        opts.registry.clone(),
        true,
        true,
    )?;

    if let Some(ref v) = opts.to_add {
        let v = v.iter().map(|s| &s[..]).collect::<Vec<_>>();
        let msg = registry
            .add_owners(&name, &v)
            .map_err(|e| format_err!("failed to invite owners to crate {}: {}", name, e))?;

        config.shell().status("Owner", msg)?;
    }

    if let Some(ref v) = opts.to_remove {
        let v = v.iter().map(|s| &s[..]).collect::<Vec<_>>();
        config
            .shell()
            .status("Owner", format!("removing {:?} from crate {}", v, name))?;
        registry
            .remove_owners(&name, &v)
            .chain_err(|| format!("failed to remove owners from crate {}", name))?;
    }

    if opts.list {
        let owners = registry
            .list_owners(&name)
            .chain_err(|| format!("failed to list owners of crate {}", name))?;
        for owner in owners.iter() {
            print!("{}", owner.login);
            match (owner.name.as_ref(), owner.email.as_ref()) {
                (Some(name), Some(email)) => println!(" ({} <{}>)", name, email),
                (Some(s), None) | (None, Some(s)) => println!(" ({})", s),
                (None, None) => println!(),
            }
        }
    }

    Ok(())
}

pub fn yank(
    config: &Config,
    krate: Option<String>,
    version: Option<String>,
    token: Option<String>,
    index: Option<String>,
    undo: bool,
    reg: Option<String>,
) -> CargoResult<()> {
    let name = match krate {
        Some(name) => name,
        None => {
            let manifest_path = find_root_manifest_for_wd(config.cwd())?;
            let ws = Workspace::new(&manifest_path, config)?;
            ws.current()?.package_id().name().to_string()
        }
    };
    let version = match version {
        Some(v) => v,
        None => bail!("a version must be specified to yank"),
    };

    let (mut registry, _) = registry(config, token, index, reg, true, true)?;

    if undo {
        config
            .shell()
            .status("Unyank", format!("{}:{}", name, version))?;
        registry
            .unyank(&name, &version)
            .chain_err(|| "failed to undo a yank")?;
    } else {
        config
            .shell()
            .status("Yank", format!("{}:{}", name, version))?;
        registry
            .yank(&name, &version)
            .chain_err(|| "failed to yank")?;
    }

    Ok(())
}

fn get_source_id(
    config: &Config,
    index: Option<String>,
    reg: Option<String>,
) -> CargoResult<SourceId> {
    match (reg, index) {
        (Some(r), _) => SourceId::alt_registry(config, &r),
        (_, Some(i)) => SourceId::for_registry(&i.into_url()?),
        _ => {
            let map = SourceConfigMap::new(config)?;
            let src = map.load(SourceId::crates_io(config)?, &HashSet::new())?;
            Ok(src.replaced_source_id())
        }
    }
}

pub fn search(
    query: &str,
    config: &Config,
    index: Option<String>,
    limit: u32,
    reg: Option<String>,
) -> CargoResult<()> {
    fn truncate_with_ellipsis(s: &str, max_width: usize) -> String {
        // We should truncate at grapheme-boundary and compute character-widths,
        // yet the dependencies on unicode-segmentation and unicode-width are
        // not worth it.
        let mut chars = s.chars();
        let mut prefix = (&mut chars).take(max_width - 1).collect::<String>();
        if chars.next().is_some() {
            prefix.push('…');
        }
        prefix
    }

    let (mut registry, source_id) = registry(config, None, index, reg, false, false)?;
    let (crates, total_crates) = registry
        .search(query, limit)
        .chain_err(|| "failed to retrieve search results from the registry")?;

    let names = crates
        .iter()
        .map(|krate| format!("{} = \"{}\"", krate.name, krate.max_version))
        .collect::<Vec<String>>();

    let description_margin = names.iter().map(|s| s.len() + 4).max().unwrap_or_default();

    let description_length = cmp::max(80, 128 - description_margin);

    let descriptions = crates.iter().map(|krate| {
        krate
            .description
            .as_ref()
            .map(|desc| truncate_with_ellipsis(&desc.replace("\n", " "), description_length))
    });

    for (name, description) in names.into_iter().zip(descriptions) {
        let line = match description {
            Some(desc) => {
                let space = repeat(' ')
                    .take(description_margin - name.len())
                    .collect::<String>();
                name + &space + "# " + &desc
            }
            None => name,
        };
        println!("{}", line);
    }

    let search_max_limit = 100;
    if total_crates > limit && limit < search_max_limit {
        println!(
            "... and {} crates more (use --limit N to see more)",
            total_crates - limit
        );
    } else if total_crates > limit && limit >= search_max_limit {
        let extra = if source_id.is_default_registry() {
            format!(
                " (go to https://crates.io/search?q={} to see more)",
                percent_encode(query.as_bytes(), NON_ALPHANUMERIC)
            )
        } else {
            String::new()
        };
        println!("... and {} crates more{}", total_crates - limit, extra);
    }

    Ok(())
}
