use std::str::FromStr;

pub mod changes;
pub mod commit;
pub mod config;
pub mod hook;
pub mod owner;
pub mod plan;
pub mod publish;
pub mod push;
pub mod release;
pub mod replace;
pub mod tag;
pub mod version;

use crate::error::CargoResult;
use crate::ops::version::VersionExt as _;

pub fn verify_git_is_clean(
    path: &std::path::Path,
    dry_run: bool,
    level: log::Level,
) -> Result<bool, crate::error::CliError> {
    let mut success = true;
    if let Some(dirty) = crate::ops::git::is_dirty(path)? {
        let _ = crate::ops::shell::log(
            level,
            format!(
                "uncommitted changes detected, please resolve before release:\n  {}",
                dirty.join("\n  ")
            ),
        );
        if level == log::Level::Error {
            success = false;
            if !dry_run {
                return Err(101.into());
            }
        }
    }
    Ok(success)
}

pub fn verify_tags_missing(
    pkgs: &[plan::PackageRelease],
    dry_run: bool,
    level: log::Level,
) -> Result<bool, crate::error::CliError> {
    let mut success = true;

    let mut tag_exists = false;
    let mut seen_tags = std::collections::HashSet::new();
    for pkg in pkgs {
        if let Some(tag_name) = pkg.planned_tag.as_ref()
            && seen_tags.insert(tag_name)
        {
            let cwd = &pkg.package_root;
            if crate::ops::git::tag_exists(cwd, tag_name)? {
                let crate_name = pkg.meta.name.as_str();
                let _ = crate::ops::shell::log(
                    level,
                    format!("tag `{tag_name}` already exists (for `{crate_name}`)"),
                );
                tag_exists = true;
            }
        }
    }
    if tag_exists && level == log::Level::Error {
        success = false;
        if !dry_run {
            return Err(101.into());
        }
    }

    Ok(success)
}

pub fn verify_tags_exist(
    pkgs: &[plan::PackageRelease],
    dry_run: bool,
    level: log::Level,
) -> Result<bool, crate::error::CliError> {
    let mut success = true;

    let mut tag_missing = false;
    let mut seen_tags = std::collections::HashSet::new();
    for pkg in pkgs {
        if let Some(tag_name) = pkg.planned_tag.as_ref()
            && seen_tags.insert(tag_name)
        {
            let cwd = &pkg.package_root;
            if !crate::ops::git::tag_exists(cwd, tag_name)? {
                let crate_name = pkg.meta.name.as_str();
                let _ = crate::ops::shell::log(
                    level,
                    format!("tag `{tag_name}` doesn't exist (for `{crate_name}`)"),
                );
                tag_missing = true;
            }
        }
    }
    if tag_missing && level == log::Level::Error {
        success = false;
        if !dry_run {
            return Err(101.into());
        }
    }

    Ok(success)
}

pub fn verify_git_branch(
    path: &std::path::Path,
    ws_config: &crate::config::Config,
    dry_run: bool,
    level: log::Level,
) -> Result<bool, crate::error::CliError> {
    use itertools::Itertools;

    let mut success = true;

    let branch = crate::ops::git::current_branch(path)?;
    let mut good_branches = ignore::gitignore::GitignoreBuilder::new(".");
    for pattern in ws_config.allow_branch() {
        good_branches.add_line(None, pattern)?;
    }
    let good_branches = good_branches.build()?;
    let good_branch_match = good_branches.matched_path_or_any_parents(&branch, false);
    if !good_branch_match.is_ignore() {
        let allowed = ws_config
            .allow_branch()
            .map(|b| format!("`{b}`"))
            .join(", ");
        let _ = crate::ops::shell::log(
            level,
            format!(
                "cannot release from branch `{branch}` as it doesn't match {allowed}; either switch to an allowed branch or add this branch to `allow-branch`",
            ),
        );
        log::trace!("due to {good_branch_match:?}");
        if level == log::Level::Error {
            success = false;
            if !dry_run {
                return Err(101.into());
            }
        }
    }

    Ok(success)
}

pub fn verify_if_behind(
    path: &std::path::Path,
    ws_config: &crate::config::Config,
    dry_run: bool,
    level: log::Level,
) -> Result<bool, crate::error::CliError> {
    let mut success = true;

    // If we are not pushing, we are not behind our push target.
    if !ws_config.push() {
        return Ok(success);
    }

    let git_remote = ws_config.push_remote();
    let branch = crate::ops::git::current_branch(path)?;
    crate::ops::git::fetch(path, git_remote, &branch)?;
    if crate::ops::git::is_behind_remote(path, git_remote, &branch)? {
        let title = format!("{branch} is behind {git_remote}/{branch}");
        if let Some(level) = crate::ops::shell::level(level) {
            let report = &[
                annotate_snippets::Group::with_title(level.primary_title(title)),
                annotate_snippets::Group::with_title(
                    annotate_snippets::Level::HELP.primary_title(format!("to update your release branch, run `git pull --rebase {git_remote} {branch}`")),
                ),
            ];
            let _ = crate::ops::shell::print_report(report);
        } else {
            let _ = crate::ops::shell::log(level, title);
        }
        if level == log::Level::Error {
            success = false;
            if !dry_run {
                return Err(101.into());
            }
        }
    }

    Ok(success)
}

pub fn verify_monotonically_increasing(
    pkgs: &[plan::PackageRelease],
    dry_run: bool,
    level: log::Level,
) -> Result<bool, crate::error::CliError> {
    let mut success = true;

    let mut downgrades_present = false;
    for pkg in pkgs {
        if let Some(version) = pkg.planned_version.as_ref()
            && version.full_version < pkg.initial_version.full_version
        {
            let crate_name = pkg.meta.name.as_str();
            let _ = crate::ops::shell::log(
                level,
                format!(
                    "cannot downgrade {} from {} to {}",
                    crate_name, version.full_version, pkg.initial_version.full_version
                ),
            );
            downgrades_present = true;
        }
    }
    if downgrades_present && level == log::Level::Error {
        success = false;
        if !dry_run {
            return Err(101.into());
        }
    }

    Ok(success)
}

pub fn verify_rate_limit(
    pkgs: &[plan::PackageRelease],
    index: &mut crate::ops::index::CratesIoIndex,
    rate_limit: &crate::config::RateLimit,
    dry_run: bool,
    level: log::Level,
) -> Result<bool, crate::error::CliError> {
    let mut success = true;

    // "It's not particularly secret, we just don't publish it other than in the code because
    // it's subject to change. The responses from the rate limited requests on when to try
    // again contain the most accurate information."
    let mut new = 0;
    let mut existing = 0;
    for pkg in pkgs {
        // Note: these rate limits are only known for default registry
        if pkg.config.registry().is_none() && pkg.config.publish() {
            let crate_name = pkg.meta.name.as_str();
            if index.has_krate(None, crate_name, pkg.config.certs_source())? {
                existing += 1;
            } else {
                new += 1;
            }
        }
    }

    if rate_limit.new_packages() < new {
        // "The rate limit for creating new crates is 1 crate every 10 minutes, with a burst of 5 crates."
        success = false;
        let _ = crate::ops::shell::log(
            level,
            format!(
                "attempting to publish {} new crates which is above the rate limit: {}",
                new,
                rate_limit.new_packages()
            ),
        );
    }

    if rate_limit.existing_packages() < existing {
        // "The rate limit for new versions of existing crates is 1 per minute, with a burst of 30 crates, so when releasing new versions of these crates, you shouldn't hit the limit."
        success = false;
        let _ = crate::ops::shell::log(
            level,
            format!(
                "attempting to publish {} existing crates which is above the rate limit: {}",
                existing,
                rate_limit.existing_packages()
            ),
        );
    }

    if !success && level == log::Level::Error && !dry_run {
        return Err(101.into());
    }

    Ok(success)
}

pub fn verify_metadata(
    pkgs: &[plan::PackageRelease],
    dry_run: bool,
    level: log::Level,
) -> Result<bool, crate::error::CliError> {
    let mut success = true;

    for pkg in pkgs {
        if !pkg.config.publish() {
            continue;
        }
        let mut missing = Vec::new();

        // General cargo rules
        if pkg
            .meta
            .description
            .as_deref()
            .unwrap_or_default()
            .is_empty()
        {
            missing.push("description");
        }
        if pkg.meta.license.as_deref().unwrap_or_default().is_empty()
            && pkg.meta.license_file.is_none()
        {
            missing.push("license || license-file");
        }
        if pkg
            .meta
            .documentation
            .as_deref()
            .unwrap_or_default()
            .is_empty()
            && pkg.meta.homepage.as_deref().unwrap_or_default().is_empty()
            && pkg
                .meta
                .repository
                .as_deref()
                .unwrap_or_default()
                .is_empty()
        {
            missing.push("documentation || homepage || repository");
        }

        if !missing.is_empty() {
            let _ = crate::ops::shell::log(
                level,
                format!(
                    "{} is missing the following fields:\n  {}",
                    pkg.meta.name,
                    missing.join("\n  ")
                ),
            );
            success = false;
        }
    }

    if !success && level == log::Level::Error && !dry_run {
        return Err(101.into());
    }

    Ok(success)
}

pub fn warn_changed(
    ws_meta: &cargo_metadata::Metadata,
    pkgs: &[plan::PackageRelease],
) -> Result<(), crate::error::CliError> {
    let mut changed_pkgs = std::collections::HashSet::new();
    for pkg in pkgs {
        let version = pkg.planned_version.as_ref().unwrap_or(&pkg.initial_version);
        let crate_name = pkg.meta.name.as_str();
        if let Some(prior_tag_name) = &pkg.prior_tag {
            if let Some(changed) = version::changed_since(ws_meta, pkg, prior_tag_name) {
                if !changed.is_empty() {
                    log::debug!(
                        "Files changed in {crate_name} since {prior_tag_name}: {changed:#?}"
                    );
                    changed_pkgs.insert(&pkg.meta.id);
                    if changed.len() == 1 && changed[0].ends_with("Cargo.lock") {
                        // Lock file changes don't invalidate dependencies
                    } else {
                        changed_pkgs.extend(pkg.dependents.iter().map(|d| &d.pkg.id));
                    }
                } else if changed_pkgs.contains(&pkg.meta.id) {
                    log::debug!("Dependency changed for {crate_name} since {prior_tag_name}",);
                    changed_pkgs.insert(&pkg.meta.id);
                    changed_pkgs.extend(pkg.dependents.iter().map(|d| &d.pkg.id));
                } else {
                    let _ = crate::ops::shell::warn(format!(
                        "updating {} to {} despite no changes made since tag {}",
                        crate_name, version.full_version_string, prior_tag_name
                    ));
                }
            } else {
                log::debug!(
                    "cannot detect changes for {crate_name} because tag {prior_tag_name} is missing. Try setting `--prev-tag-name <TAG>`."
                );
            }
        } else {
            log::debug!(
                "cannot detect changes for {crate_name} because no tag was found. Try setting `--prev-tag-name <TAG>`.",
            );
        }
    }

    Ok(())
}

pub fn find_shared_versions(
    pkgs: &[plan::PackageRelease],
) -> Result<Option<plan::Version>, crate::error::CliError> {
    let mut is_shared = true;
    let mut shared_versions: std::collections::HashMap<&str, &plan::Version> = Default::default();
    for pkg in pkgs {
        let group_name = if let Some(group_name) = pkg.config.shared_version() {
            group_name
        } else {
            continue;
        };
        let version = pkg.planned_version.as_ref().unwrap_or(&pkg.initial_version);
        match shared_versions.entry(group_name) {
            std::collections::hash_map::Entry::Occupied(existing) => {
                if version.bare_version != existing.get().bare_version {
                    is_shared = false;
                    let _ = crate::ops::shell::error(format!(
                        "{} has version {}, should be {}",
                        pkg.meta.name,
                        version.bare_version_string,
                        existing.get().bare_version_string
                    ));
                }
            }
            std::collections::hash_map::Entry::Vacant(vacant) => {
                vacant.insert(version);
            }
        }
    }
    if !is_shared {
        let _ = crate::ops::shell::error("crate versions deviated, aborting");
        return Err(101.into());
    }

    if shared_versions.len() == 1 {
        Ok(shared_versions.values().next().map(|s| (*s).clone()))
    } else {
        Ok(None)
    }
}

pub fn consolidate_commits(
    selected_pkgs: &[plan::PackageRelease],
    excluded_pkgs: &[plan::PackageRelease],
) -> Result<bool, crate::error::CliError> {
    let mut consolidate_commits = None;
    for pkg in selected_pkgs.iter().chain(excluded_pkgs.iter()) {
        let current = Some(pkg.config.consolidate_commits());
        if consolidate_commits.is_none() {
            consolidate_commits = current;
        } else if consolidate_commits != current {
            let _ = crate::ops::shell::error("inconsistent `consolidate-commits` setting");
            return Err(101.into());
        }
    }
    Ok(consolidate_commits.expect("at least one package"))
}

pub fn confirm(
    step: &str,
    pkgs: &[plan::PackageRelease],
    no_confirm: bool,
    dry_run: bool,
) -> Result<(), crate::error::CliError> {
    if !dry_run && !no_confirm {
        let prompt = if pkgs.len() == 1 {
            let pkg = &pkgs[0];
            let crate_name = pkg.meta.name.as_str();
            let version = pkg.planned_version.as_ref().unwrap_or(&pkg.initial_version);
            format!("{} {} {}?", step, crate_name, version.full_version_string)
        } else {
            use std::io::Write;

            let mut buffer: Vec<u8> = vec![];
            writeln!(&mut buffer, "{step}").unwrap();
            for pkg in pkgs {
                let crate_name = pkg.meta.name.as_str();
                let version = pkg.planned_version.as_ref().unwrap_or(&pkg.initial_version);
                writeln!(
                    &mut buffer,
                    "  {} {}",
                    crate_name, version.full_version_string
                )
                .unwrap();
            }
            write!(&mut buffer, "?").unwrap();
            String::from_utf8(buffer).expect("Only valid UTF-8 has been written")
        };

        let confirmed = crate::ops::shell::confirm(&prompt);
        if !confirmed {
            return Err(0.into());
        }
    }

    Ok(())
}

pub fn finish(failed: bool, dry_run: bool) -> Result<(), crate::error::CliError> {
    if dry_run {
        if failed {
            let _ =
                crate::ops::shell::error("dry-run failed, resolve the above errors and try again.");
            Err(101.into())
        } else {
            let _ =
                crate::ops::shell::warn("aborting release due to dry run; re-run with `--execute`");
            Ok(())
        }
    } else {
        Ok(())
    }
}

#[derive(Clone, Debug)]
pub enum TargetVersion {
    Relative(BumpLevel),
    Absolute(semver::Version),
}

impl TargetVersion {
    pub fn bump(
        &self,
        current: &semver::Version,
        metadata: Option<&str>,
    ) -> CargoResult<Option<plan::Version>> {
        match self {
            Self::Relative(bump_level) => {
                let mut potential_version = current.to_owned();
                bump_level.bump_version(&mut potential_version, metadata)?;
                if potential_version != *current {
                    let full_version = potential_version;
                    let version = plan::Version::from(full_version);
                    Ok(Some(version))
                } else {
                    Ok(None)
                }
            }
            Self::Absolute(version) => {
                let mut full_version = version.to_owned();
                if full_version.build.is_empty() {
                    if let Some(metadata) = metadata {
                        full_version.build = semver::BuildMetadata::new(metadata)?;
                    } else {
                        full_version.build = current.build.clone();
                    }
                }
                let version = plan::Version::from(full_version);
                if version.bare_version != plan::Version::from(current.clone()).bare_version {
                    Ok(Some(version))
                } else {
                    Ok(None)
                }
            }
        }
    }
}

impl Default for TargetVersion {
    fn default() -> Self {
        Self::Relative(BumpLevel::Release)
    }
}

impl std::fmt::Display for TargetVersion {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
        match self {
            Self::Relative(bump_level) => {
                write!(f, "{bump_level}")
            }
            Self::Absolute(version) => {
                write!(f, "{version}")
            }
        }
    }
}

impl FromStr for TargetVersion {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        if let Ok(bump_level) = BumpLevel::from_str(s) {
            Ok(Self::Relative(bump_level))
        } else {
            Ok(Self::Absolute(
                semver::Version::parse(s).map_err(|e| e.to_string())?,
            ))
        }
    }
}

impl clap::builder::ValueParserFactory for TargetVersion {
    type Parser = TargetVersionParser;

    fn value_parser() -> Self::Parser {
        TargetVersionParser
    }
}

#[derive(Copy, Clone)]
pub struct TargetVersionParser;

impl clap::builder::TypedValueParser for TargetVersionParser {
    type Value = TargetVersion;

    fn parse_ref(
        &self,
        cmd: &clap::Command,
        arg: Option<&clap::Arg>,
        value: &std::ffi::OsStr,
    ) -> Result<Self::Value, clap::Error> {
        let inner_parser = TargetVersion::from_str;
        inner_parser.parse_ref(cmd, arg, value)
    }

    fn possible_values(
        &self,
    ) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
        let inner_parser = clap::builder::EnumValueParser::<BumpLevel>::new();
        inner_parser.possible_values().map(|ps| {
            let ps = ps.collect::<Vec<_>>();
            let ps: Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_> =
                Box::new(ps.into_iter());
            ps
        })
    }
}

#[derive(Debug, Clone, Copy, clap::ValueEnum)]
#[value(rename_all = "kebab-case")]
pub enum BumpLevel {
    /// Increase the major version (x.0.0)
    Major,
    /// Increase the minor version (x.y.0)
    Minor,
    /// Increase the patch version (x.y.z)
    Patch,
    /// Remove the pre-version (x.y.z)
    Release,
    /// Increase the rc pre-version (x.y.z-rc.M)
    Rc,
    /// Increase the beta pre-version (x.y.z-beta.M)
    Beta,
    /// Increase the alpha pre-version (x.y.z-alpha.M)
    Alpha,
}

impl std::fmt::Display for BumpLevel {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        use clap::ValueEnum;

        self.to_possible_value()
            .expect("no values are skipped")
            .get_name()
            .fmt(f)
    }
}

impl FromStr for BumpLevel {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        use clap::ValueEnum;

        for variant in Self::value_variants() {
            if variant.to_possible_value().unwrap().matches(s, false) {
                return Ok(*variant);
            }
        }
        Err(format!("Invalid variant: {s}"))
    }
}

impl BumpLevel {
    pub fn bump_version(
        self,
        version: &mut semver::Version,
        metadata: Option<&str>,
    ) -> CargoResult<()> {
        match self {
            Self::Major => {
                version.increment_major();
            }
            Self::Minor => {
                version.increment_minor();
            }
            Self::Patch => {
                if !version.is_prerelease() {
                    version.increment_patch();
                } else {
                    version.pre = semver::Prerelease::EMPTY;
                }
            }
            Self::Release => {
                if version.is_prerelease() {
                    version.pre = semver::Prerelease::EMPTY;
                }
            }
            Self::Rc => {
                version.increment_rc()?;
            }
            Self::Beta => {
                version.increment_beta()?;
            }
            Self::Alpha => {
                version.increment_alpha()?;
            }
        };

        if let Some(metadata) = metadata {
            version.metadata(metadata)?;
        }

        Ok(())
    }
}

pub fn verify_dependencies(
    selected_pkgs: &[plan::PackageRelease],
    excluded_pkgs: &[plan::PackageRelease],
    index: &mut crate::ops::index::CratesIoIndex,
    dry_run: bool,
    level: log::Level,
) -> Result<bool, crate::error::CliError> {
    let mut success = true;

    for pkg in selected_pkgs {
        if !pkg.config.publish() || pkg.config.registry().is_some() {
            continue;
        }
        let version = pkg.planned_version.as_ref().unwrap_or(&pkg.initial_version);
        let mut checked = std::collections::HashSet::new();
        for dependency in &pkg.meta.dependencies {
            if dependency.registry.is_some()
                || (dependency.req == semver::VersionReq::STAR
                    && !dependency
                        .source
                        .as_ref()
                        .is_some_and(cargo_metadata::Source::is_crates_io))
                || !checked.insert((dependency.name.as_str(), &dependency.req))
            {
                continue;
            }

            let publishing = selected_pkgs.iter().any(|candidate| {
                let candidate_version = candidate
                    .planned_version
                    .as_ref()
                    .unwrap_or(&candidate.initial_version);
                candidate.config.publish()
                    && candidate.config.registry() == pkg.config.registry()
                    && candidate.meta.name.as_str() == dependency.name.as_str()
                    && dependency.req.matches(&candidate_version.full_version)
            });
            if publishing
                || crate::ops::cargo::is_published_req(
                    index,
                    pkg.config.registry(),
                    &dependency.name,
                    &dependency.req,
                    pkg.config.certs_source(),
                )
            {
                continue;
            }

            let workspace_dependency = selected_pkgs
                .iter()
                .chain(excluded_pkgs)
                .find(|candidate| candidate.meta.name.as_str() == dependency.name.as_str());
            let message = if let Some(workspace_dependency) = workspace_dependency {
                let dependency_version = workspace_dependency
                    .planned_version
                    .as_ref()
                    .unwrap_or(&workspace_dependency.initial_version);
                format!(
                    "{} {} depends on unpublished workspace package {} {}",
                    pkg.meta.name,
                    version.full_version_string,
                    workspace_dependency.meta.name,
                    dependency_version.full_version_string
                )
            } else {
                format!(
                    "{} {} depends on unpublished package {} {}",
                    pkg.meta.name, version.full_version_string, dependency.name, dependency.req
                )
            };
            let _ = crate::ops::shell::log(level, message);
            success = false;
        }
    }

    if !success && level == log::Level::Error && !dry_run {
        return Err(101.into());
    }

    Ok(success)
}
