//! ingredients CLI

use std::io::Write;
use std::path::PathBuf;

use clap::{CommandFactory, Parser, ValueHint};
use clap_complete::aot::Shell;
use log::LevelFilter;
use owo_colors::OwoColorize;

use ingredients::{Crate, Diff, DiffOptions, RegistryClient, Report, ReportOptions, Severity, Version, VersionReq};

const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), " v", env!("CARGO_PKG_VERSION"));

fn pretty_format_severity(severity: Severity) -> String {
    match severity {
        x @ Severity::Fatal => x.to_string().bold().on_bright_cyan().to_string(),
        x @ Severity::Error => x.to_string().red().bold().to_string(),
        x @ Severity::Warning => x.to_string().yellow().bold().to_string(),
        x @ Severity::Debug => x.to_string().green().bold().to_string(),
        x @ Severity::Info | x => x.bold().to_string(),
    }
}

const fn plural_suffix(len: usize) -> &'static str {
    if len == 1 { "" } else { "s" }
}

fn pretty_print_report(report: &Report, minimum: Severity, verbose: bool) {
    let mut out = anstream::stdout();

    for item in report.items() {
        if item.severity() < minimum {
            continue;
        }

        let severity = pretty_format_severity(item.severity());

        if verbose {
            let _ = writeln!(out, "{}: {}", severity, item.message());
            if let Some(extra) = item.extra() {
                for line in extra.lines() {
                    let _ = writeln!(out, "  {line}");
                }
                let _ = writeln!(out);
            }
        } else {
            let _ = writeln!(out, "{}: {}", severity, item.message());
        }
    }

    let fatal = report
        .items()
        .iter()
        .filter(|item| item.severity() == Severity::Fatal)
        .collect::<Vec<_>>()
        .len();
    let error = report
        .items()
        .iter()
        .filter(|item| item.severity() == Severity::Error)
        .collect::<Vec<_>>()
        .len();
    let warning = report
        .items()
        .iter()
        .filter(|item| item.severity() == Severity::Warning)
        .collect::<Vec<_>>()
        .len();

    let _ = writeln!(
        out,
        "{}: {} fatal issue{}, {} error{}, {} warning{}",
        "summary".bold(),
        fatal,
        plural_suffix(fatal),
        error,
        plural_suffix(error),
        warning,
        plural_suffix(warning),
    );
}

fn pretty_print_diff(diff: &Diff, minimum: Severity, verbose: bool) {
    let mut out = anstream::stdout();

    for item in diff.items() {
        if item.severity() < minimum {
            continue;
        }

        let severity = pretty_format_severity(item.severity());

        if verbose {
            let _ = writeln!(out, "{}: {}", severity, item.message());
            if let Some(extra) = item.extra() {
                for line in extra.lines() {
                    let _ = writeln!(out, "  {line}");
                }
                let _ = writeln!(out);
            }
        } else {
            let _ = writeln!(out, "{}: {}", severity, item.message());
        }
    }

    let fatal = diff
        .items()
        .iter()
        .filter(|item| item.severity() == Severity::Fatal)
        .collect::<Vec<_>>()
        .len();
    let error = diff
        .items()
        .iter()
        .filter(|item| item.severity() == Severity::Error)
        .collect::<Vec<_>>()
        .len();
    let warning = diff
        .items()
        .iter()
        .filter(|item| item.severity() == Severity::Warning)
        .collect::<Vec<_>>()
        .len();

    let _ = writeln!(
        out,
        "{}: {} fatal issue{}, {} error{}, {} warning{}",
        "summary".bold(),
        fatal,
        plural_suffix(fatal),
        error,
        plural_suffix(error),
        warning,
        plural_suffix(warning),
    );
}

#[derive(Clone, Copy, Debug, Default, clap::ValueEnum)]
enum Format {
    #[default]
    Plain,
    Verbose,
    Json,
}

/// Check ingredients of published Rust crates
#[derive(Debug, clap::Parser)]
#[command(disable_help_subcommand(true))]
struct Args {
    #[command(subcommand)]
    command: Command,
}

/// Compare a crate against the contents of the upstream version control system
///
/// By default, the crate archive is downloaded from crates.io. To override this
/// behaviour, pass `--file <PATH>` to use a locally downloaded file instead.
///
/// For crates that don't set `package.repository` in their metadata, a URL can
/// be provided with the `--with-repository <URL>` argument.
///
/// For crates that have been published with old versions of cargo that did not
/// yet include complete `.cargo_vcs_info.json` files, the "path in VCS" (the
/// relative path where the crate can be found in the repository) can be
/// provided with the `--with-path-in-vcs <PATH>` argument, and the VCS ref can
/// be provided with the `--with-vcs-ref <REF>` argument.
#[derive(Debug, clap::Args)]
struct ReportArgs {
    /// Name of the crate
    name: String,
    /// Version of the crate
    version: String,

    /// Output format
    #[clap(long, default_value = "plain")]
    format: Format,
    /// Minimum severity
    #[clap(long, default_value = "info")]
    severity: Severity,

    /// Path to local crate archive to use instead of downloading from crates.io
    #[clap(long, short = 'f', value_hint = ValueHint::FilePath)]
    file: Option<PathBuf>,

    /// Provide repository URL if package.repository is missing or wrong
    #[clap(long, short = 'u')]
    with_repository: Option<String>,
    /// Provide "path in VCS" if `.cargo_vcs_info.json` is missing or incomplete
    #[clap(long, short = 'p')]
    with_path_in_vcs: Option<String>,
    /// Provide VCS ref if `.cargo_vcs_info.json` is missing or incomplete
    #[clap(long, short = 'r')]
    with_vcs_ref: Option<String>,
    /// Override default file size limit (in bytes) for diff generation
    ///
    /// Pass "0" to set no file size limit (not recommended).
    /// The default value is 1 MiB (1024 * 1024).
    #[clap(long, short = 'L')]
    diff_size_limit: Option<usize>,
}

/// Compare two versions of a crate
///
/// By default, the crate archives are downloaded from crates.io. To override
/// this behaviour, pass `--old_file <PATH>` and `--new-file <PATH>` to use
/// locally downloaded files instead.
#[derive(Debug, clap::Args)]
struct DiffArgs {
    /// Name of the crate
    name: String,
    /// Old version of the crate
    ///
    /// Accepts strings that can be parsed as `semver::Version` or `semver::VersionReq`.
    /// Version requirements are used to query crates.io for the latest available version
    /// that matches the requirement.
    old_version: String,
    /// New version of the crate
    ///
    /// Accepts strings that can be parsed as `semver::Version` or `semver::VersionReq`.
    /// Version requirements are used to query crates.io for the latest available version
    /// that matches the requirement.
    ///
    /// If omitted, the registry is queried for the latest available version.
    new_version: Option<String>,

    /// Output format
    #[clap(long, default_value = "plain")]
    format: Format,
    /// Minimum severity
    #[clap(long, default_value = "info")]
    severity: Severity,

    /// Path to local crate archive for old crate version
    #[clap(long, value_hint = ValueHint::FilePath)]
    old_file: Option<PathBuf>,
    /// Path to local crate archive for new crate version
    #[clap(long, value_hint = ValueHint::FilePath)]
    new_file: Option<PathBuf>,

    /// Include pre-releases when querying registry for available versions
    #[clap(long)]
    include_prereleases: bool,
    /// Include yanked versions when querying registry for available versions
    #[clap(long)]
    include_yanked: bool,

    /// Override default file size limit (in bytes) for diff generation
    ///
    /// Pass "0" to set no file size limit (not recommended).
    /// The default value is 1 MiB (1024 * 1024).
    #[clap(long, short = 'L')]
    diff_size_limit: Option<usize>,
}

#[derive(Clone, Copy, Debug, clap::ValueEnum)]
enum ShellKind {
    Bash,
    Fish,
    Zsh,
    Nushell,
}

#[derive(Debug, clap::Args)]
struct CompletionsArgs {
    shell: ShellKind,
}

#[derive(Debug, clap::Args)]
struct ManualArgs {
    path: PathBuf,
}

#[derive(Debug, clap::Subcommand)]
enum Command {
    Report(ReportArgs),
    Diff(DiffArgs),
    #[clap(hide = true)]
    Completions(CompletionsArgs),
    #[clap(hide = true)]
    Manual(ManualArgs),
}

fn diff_options_from_args(diff_size_limit: Option<usize>, format: Format) -> DiffOptions {
    let mut diff_options = DiffOptions::default();
    if let Some(limit) = diff_size_limit {
        if limit == 0 {
            diff_options.diff_size_limit = None;
        } else {
            diff_options.diff_size_limit = Some(limit);
        }
    }
    if matches!(format, Format::Plain) {
        // "plain" format does not print diffs,
        // so skip generating them entirely
        diff_options.skip_diff_gen = true;
    }
    diff_options
}

#[expect(clippy::print_stdout)]
async fn report(args: ReportArgs) -> anyhow::Result<()> {
    let mut options = ReportOptions::default();
    options.repository = args.with_repository;
    options.path_in_vcs = args.with_path_in_vcs;
    options.vcs_ref = args.with_vcs_ref;

    let diff_options = diff_options_from_args(args.diff_size_limit, args.format);

    let registry = RegistryClient::crates_io(USER_AGENT)?;

    let krate = if let Some(path) = args.file {
        Crate::local(&args.name, &args.version, &path)?
    } else {
        registry.download(&args.name, &args.version).await?
    };

    let report = krate.report_with_options(options, &diff_options).await?;

    match args.format {
        Format::Plain => pretty_print_report(&report, args.severity, false),
        Format::Verbose => pretty_print_report(&report, args.severity, true),
        Format::Json => println!("{}", report.to_json()),
    }

    Ok(())
}

async fn get_latest_version(
    registry: &RegistryClient,
    name: &str,
    range: Option<&VersionReq>,
    include_prereleases: bool,
    include_yanked: bool,
) -> Result<Option<Version>, ingredients::Error> {
    let versions = registry
        .query_versions(name, range, include_prereleases, include_yanked)
        .await?;
    Ok(versions.into_iter().max())
}

async fn resolve_version_arg(
    registry: &RegistryClient,
    name: &str,
    version: &str,
    include_prereleases: bool,
    include_yanked: bool,
) -> anyhow::Result<Version> {
    if let Ok(v) = Version::parse(version) {
        return Ok(v);
    }

    if let Ok(r) = VersionReq::parse(version) {
        log::info!("Querying registry for versions matching '{version}' ...");
        let result = get_latest_version(registry, name, Some(&r), include_prereleases, include_yanked).await?;
        if let Some(v) = result {
            log::info!("Matching version found: '{v}'");
            Ok(v)
        } else {
            anyhow::bail!("Querying registry for available versions matching '{version}' returned zero results.");
        }
    } else {
        anyhow::bail!("Invalid version / version requirement argument: '{version}'");
    }
}

#[expect(clippy::print_stdout)]
async fn diff(args: DiffArgs) -> anyhow::Result<()> {
    let diff_options = diff_options_from_args(args.diff_size_limit, args.format);

    let registry = RegistryClient::crates_io(USER_AGENT)?;

    let old_version = resolve_version_arg(
        &registry,
        &args.name,
        &args.old_version,
        args.include_prereleases,
        args.include_yanked,
    )
    .await?;

    let new_version = match args.new_version.as_ref() {
        Some(v) => resolve_version_arg(&registry, &args.name, v, args.include_prereleases, args.include_yanked).await?,
        None => match get_latest_version(
            &registry,
            &args.name,
            None,
            args.include_prereleases,
            args.include_yanked,
        )
        .await?
        {
            Some(v) => v,
            None => anyhow::bail!("Querying registry for available versions returned zero results."),
        },
    };

    let old_crate = if let Some(old_file) = args.old_file {
        Crate::local(&args.name, &old_version.to_string(), &old_file)?
    } else {
        registry.download(&args.name, &old_version.to_string()).await?
    };
    let new_crate = if let Some(new_file) = args.new_file {
        Crate::local(&args.name, &new_version.to_string(), &new_file)?
    } else {
        registry.download(&args.name, &new_version.to_string()).await?
    };

    let diff = old_crate.diff_with_options(&new_crate, &diff_options)?;

    match args.format {
        Format::Plain => pretty_print_diff(&diff, args.severity, false),
        Format::Verbose => pretty_print_diff(&diff, args.severity, true),
        Format::Json => println!("{}", diff.to_json()),
    }

    Ok(())
}

fn completions(args: &CompletionsArgs) {
    match args.shell {
        ShellKind::Bash => {
            clap_complete::generate(
                Shell::Bash,
                &mut Args::command(),
                env!("CARGO_PKG_NAME"),
                &mut std::io::stdout(),
            );
        },
        ShellKind::Fish => {
            clap_complete::generate(
                Shell::Fish,
                &mut Args::command(),
                env!("CARGO_PKG_NAME"),
                &mut std::io::stdout(),
            );
        },
        ShellKind::Zsh => {
            clap_complete::generate(
                Shell::Zsh,
                &mut Args::command(),
                env!("CARGO_PKG_NAME"),
                &mut std::io::stdout(),
            );
        },
        ShellKind::Nushell => {
            clap_complete::generate(
                clap_complete_nushell::Nushell,
                &mut Args::command(),
                env!("CARGO_PKG_NAME"),
                &mut std::io::stdout(),
            );
        },
    }
}

fn manpage(args: &ManualArgs) -> std::io::Result<()> {
    let cmd = Args::command();
    clap_mangen::generate_to(cmd, &args.path)
}

fn init_logger() {
    let mut logger = env_logger::Builder::new();
    // default to "debug" and higher in "debug" mode,
    // default to "info" and higher in "release" mode
    logger.filter_level(if cfg!(debug_assertions) {
        LevelFilter::Debug
    } else {
        LevelFilter::Info
    });
    // read overrides from environment variable
    logger.parse_env("INGREDIENTS_LOG");
    logger.init();
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    init_logger();

    let args = Args::parse();

    match args.command {
        Command::Report(args) => report(args).await?,
        Command::Diff(args) => diff(args).await?,
        Command::Completions(args) => completions(&args),
        Command::Manual(args) => manpage(&args)?,
    }

    Ok(())
}
