use std::{
    path::{Path, PathBuf},
    sync::Arc,
};

use console::{Term, style};
use eyre::{Result, bail, eyre};
use itertools::Itertools;
use jiff::Timestamp;

use crate::cli::args::{BackendArg, ToolArg};
use crate::config::config_file::ConfigFile;
use crate::config::{Config, ConfigPathOptions, Settings, config_file, resolve_target_config_path};
use crate::file::display_path;
use crate::install_before::resolve_cli_minimum_release_age;
use crate::registry::REGISTRY;
use crate::toolset::{
    ConfigScope, InstallOptions, ResolveOptions, ToolRequest, ToolSource, ToolVersion,
    ToolsetBuilder,
};
use crate::ui::ctrlc;
use crate::{config, env, exit, file};

/// Installs a tool and adds the version to mise.toml.
///
/// This will install the tool version if it is not already installed.
/// By default, this will use a `mise.toml` file in the current directory.
/// If multiple config files exist (e.g., both `mise.toml` and `mise.local.toml`),
/// the lowest precedence file (`mise.toml`) will be used.
/// See https://mise.jdx.dev/configuration.html#target-file-for-write-operations
///
/// In the following order:
///   - If `--global` is set, it will use the global config file.
///   - If `--path` is set, it will use the config file at the given path.
///   - If `--env` is set, it will use `mise.<env>.toml`.
///   - If [`MISE_DEFAULT_CONFIG_FILENAME`](https://mise.jdx.dev/configuration.html#mise_default_config_filename) is set, it will use that instead.
///   - If `MISE_OVERRIDE_CONFIG_FILENAMES` is set, it will the first from that list.
///   - Otherwise just "mise.toml" or global config if cwd is home directory.
///
/// Use [`MISE_GLOBAL_CONFIG_FILE`](https://mise.jdx.dev/configuration.html#mise_global_config_file) to choose a different global config path.
///
/// Use the `--global` flag to use the global config file instead.
#[derive(Debug, clap::Args)]
#[clap(verbatim_doc_comment, visible_alias = "u", after_long_help = AFTER_LONG_HELP)]
pub struct Use {
    /// Tool(s) to add to config file
    ///
    /// e.g.: node@20, cargo:ripgrep@latest npm:prettier@3
    /// If no version is specified, it will default to @latest
    ///
    /// Tool options can be set with this syntax:
    ///
    ///     mise use ubi:BurntSushi/ripgrep[exe=rg]
    #[clap(value_name = "TOOL@VERSION", verbatim_doc_comment)]
    tool: Vec<ToolArg>,

    /// Create/modify an environment-specific config file like .mise.<env>.toml
    #[clap(long, short, overrides_with_all = & ["global", "path"])]
    env: Option<String>,

    /// Force reinstall even if already installed
    #[clap(long, short, requires = "tool")]
    force: bool,

    /// Use the global config file (`~/.config/mise/config.toml`) instead of the local one
    #[clap(short, long, overrides_with_all = & ["path", "env"])]
    global: bool,

    /// Number of jobs to run in parallel
    /// Values below 1 are treated as 1
    /// [default: 4]
    #[clap(long, short, env = "MISE_JOBS", verbatim_doc_comment)]
    jobs: Option<usize>,

    /// Perform a dry run, showing what would be installed and modified without making changes
    #[clap(long, short = 'n', verbatim_doc_comment)]
    dry_run: bool,

    /// Specify a path to a config file or directory
    ///
    /// If a directory is specified, it will look for a config file in that directory following
    /// the rules above.
    // No `--file` alias here: `-f` on this command is `--force`, so offering `--file`
    // invites `-f <path>`, which is a different action. See `mise unset --path` for the
    // commands where the short form is free.
    #[clap(short, long, overrides_with_all = & ["global", "env"], value_hint = clap::ValueHint::FilePath)]
    path: Option<PathBuf>,

    /// Like --dry-run but exits with code 1 if there are changes to make
    ///
    /// This is useful for scripts to check if tools need to be added or removed.
    #[clap(long, verbatim_doc_comment)]
    dry_run_code: bool,

    /// Save fuzzy version to config file
    ///
    /// e.g.: `mise use --fuzzy node@20` will save 20 as the version
    /// this is the default behavior unless `MISE_PIN=1`
    #[clap(long, verbatim_doc_comment, overrides_with = "pin")]
    fuzzy: bool,

    /// Only install versions released before this date or older than this duration
    ///
    /// Supports absolute dates like "2024-06-01" and relative durations like "90d" or "1y".
    #[clap(long, alias = "before", verbatim_doc_comment)]
    minimum_release_age: Option<String>,

    /// Save the resolved concrete version to the config file
    ///
    /// If the request exactly matches an available release, that release is preferred over
    /// installed fuzzy matches. Use `prefix:` to explicitly request recursive prefix matching.
    /// e.g.: `mise use --pin node@20` will save the resolved `20.x.y` version
    /// Set `MISE_PIN=1` to make this the default behavior
    ///
    /// Consider using mise.lock as a better alternative to pinning in mise.toml:
    /// https://mise.jdx.dev/configuration/settings.html#lockfile
    #[clap(long, verbatim_doc_comment, overrides_with = "fuzzy")]
    pin: bool,

    /// Connect backend install command stdin/stdout/stderr directly to the terminal
    /// Implies `--jobs=1`
    #[clap(long, overrides_with = "jobs")]
    raw: bool,

    /// Remove the tool(s) from config file
    #[clap(long, value_name = "TOOL", aliases = ["rm", "unset"])]
    remove: Vec<BackendArg>,
}

impl Use {
    fn is_dry_run(&self) -> bool {
        self.dry_run || self.dry_run_code
    }

    pub async fn run(mut self) -> Result<()> {
        if self.tool.is_empty() && self.remove.is_empty() {
            self.tool = vec![self.tool_selector()?];
        }
        env::TOOL_ARGS.write().unwrap().clone_from(&self.tool);
        let mut config = Config::get().await?;
        let scope = if self.global {
            ConfigScope::GlobalOnly
        } else {
            ConfigScope::All
        };
        let mut ts = ToolsetBuilder::new()
            .with_scope(scope)
            .build(&config)
            .await?;
        let mut cf = self.get_config_file().await?;
        let pin = self.pin || !self.fuzzy && (Settings::get().pin || Settings::get().asdf_compat);
        let mut resolve_options = ResolveOptions {
            latest_versions: false,
            use_locked_version: true,
            resolve_rolling_channels: false,
            prefer_exact_version: pin,
            before_date: self.get_before_date()?,
            before_date_from_default: false,
            filter_installed_versions_by_release_date: false,
            offline: false,
            refresh_remote_versions: false,
            inactive: false,
        };
        let versions: Vec<_> = self
            .tool
            .iter()
            .cloned()
            .map(|t| match t.tvr {
                Some(tvr) => {
                    if tvr.version() == "latest" && !Settings::get().locked {
                        // user specified `@latest` so we should resolve the latest version
                        // TODO: this should only happen on this tool, not all of them
                        resolve_options.latest_versions = true;
                        resolve_options.use_locked_version = false;
                    }
                    Ok(tvr)
                }
                None => ToolRequest::new(
                    t.ba,
                    "latest",
                    ToolSource::MiseToml(cf.get_path().to_path_buf()),
                ),
            })
            .collect::<Result<_>>()?;
        let mut versions = ts
            .install_all_versions(
                &mut config,
                versions.clone(),
                &InstallOptions {
                    reason: "use".to_string(),
                    force: self.force,
                    jobs: self.jobs,
                    raw: self.raw,
                    dry_run: self.is_dry_run(),
                    resolve_options,
                    ..Default::default()
                },
            )
            .await?;

        // Installation can take long enough for another `mise use` process to update this file.
        // Serialize only the read-modify-write phase, then re-read under the lock so we apply our
        // changes to the latest contents instead of overwriting them with the stale snapshot used
        // during resolution and installation.
        let mut config_lock = if self.is_dry_run() {
            None
        } else {
            let (lock, latest_cf) = config_file::lock_and_parse_or_init(cf.get_path()).await?;
            cf = latest_cf;
            Some(lock)
        };

        for (ba, tvl) in &versions.iter().chunk_by(|tv| tv.ba()) {
            let versions: Vec<_> = tvl
                .into_iter()
                .map(|tv| {
                    let mut request = tv.request.clone();
                    if pin
                        && let ToolRequest::Version {
                            version: _version,
                            source,
                            options,
                            backend,
                        } = request
                    {
                        request = ToolRequest::Version {
                            version: tv.version.clone(),
                            source,
                            options,
                            backend,
                        };
                    }
                    request
                })
                .collect();
            cf.replace_versions(ba, versions)?;
        }

        if self.global {
            self.warn_if_hidden(&config, cf.get_path()).await;
        }
        for tool_name in &self.remove {
            cf.remove_tool(tool_name)?;
        }

        if !self.is_dry_run() {
            cf.save()?;
            drop(config_lock.take());
            for tv in &mut versions {
                // update the source so the lockfile is updated correctly
                tv.request.set_source(cf.source());
            }

            let config = Config::reset().await?;
            let ts = config.get_toolset().await?;
            config::rebuild_shims_and_runtime_symlinks(
                &config,
                ts,
                &versions,
                crate::lockfile::LockfileUpdateMode::Normal,
            )
            .await?;
        }

        self.render_success_message(cf.as_ref(), &versions, &self.remove)?;
        Ok(())
    }

    async fn get_config_file(&self) -> Result<Arc<dyn ConfigFile>> {
        let cwd = env::current_dir()?;
        let opts = ConfigPathOptions {
            global: self.global,
            path: self.path.clone(),
            env: self.env.clone(),
            cwd: Some(cwd),
            prefer_toml: false, // mise use supports .tool-versions and other formats
            prevent_home_local: true, // When in HOME, use global config
        };
        let path = resolve_target_config_path(opts)?;

        config_file::parse_or_init(&path).await
    }

    async fn warn_if_hidden(&self, config: &Arc<Config>, global: &Path) {
        let ts = ToolsetBuilder::new()
            .build(config)
            .await
            .unwrap_or_default();
        let warn = |targ: &ToolArg, p| {
            let plugin = &targ.ba;
            let p = display_path(p);
            let global = display_path(global);
            warn!("{plugin} is defined in {p} which overrides the global config ({global})");
        };
        for targ in &self.tool {
            if let Some(tv) = ts.versions.get(targ.ba.as_ref())
                && let ToolSource::MiseToml(p) | ToolSource::ToolVersions(p) = &tv.source
                && !file::same_file(p, global)
                && !config::is_system_config(p)
            {
                warn(targ, p);
            }
        }
    }

    fn render_success_message(
        &self,
        cf: &dyn ConfigFile,
        versions: &[ToolVersion],
        remove: &[BackendArg],
    ) -> Result<()> {
        let path = display_path(cf.get_path());
        let quiet = Settings::get().quiet;

        if self.is_dry_run() {
            let mut messages = vec![];

            if !versions.is_empty() {
                let tools = versions.iter().map(|t| t.style()).join(", ");
                messages.push(format!("add: {tools}"));
            }

            if !remove.is_empty() {
                let tools_to_remove = remove.iter().map(|r| r.to_string()).join(", ");
                messages.push(format!("remove: {tools_to_remove}"));
            }

            if !messages.is_empty() {
                if !quiet {
                    miseprintln!(
                        "{} would update {} ({})",
                        style("mise").green(),
                        style(&path).cyan().for_stderr(),
                        messages.join(", ")
                    );
                }
                if self.dry_run_code {
                    return Err(exit::request(1));
                }
            }
        } else if !quiet {
            if !versions.is_empty() {
                let tools = versions.iter().map(|t| t.style()).join(", ");
                miseprintln!(
                    "{} {} tools: {tools}",
                    style("mise").green(),
                    style(&path).cyan().for_stderr(),
                );
            }
            if !remove.is_empty() {
                let tools_to_remove = remove.iter().map(|r| r.to_string()).join(", ");
                miseprintln!(
                    "{} {} removed: {tools_to_remove}",
                    style("mise").green(),
                    style(&path).cyan().for_stderr(),
                );
            }
        }
        Ok(())
    }

    fn tool_selector(&self) -> Result<ToolArg> {
        if !console::user_attended_stderr() {
            bail!("No tool specified and not running interactively");
        }
        let theme = crate::ui::theme::get_theme();
        let mut s = demand::Select::new("Tools")
            .description("Select a tool to install")
            .filtering(true)
            .filterable(true)
            .theme(&theme);
        for rt in REGISTRY.values().unique_by(|r| r.short) {
            if let Some(backend) = rt.backends().first() {
                // TODO: populate registry with descriptions from aqua and other sources
                // TODO: use the backend from the lockfile if available
                let description = rt.description.unwrap_or(backend);
                s = s.option(demand::DemandOption::new(rt).description(description));
            }
        }
        ctrlc::show_cursor_after_ctrl_c();
        match s.run() {
            Ok(rt) => rt.short.parse(),
            Err(err) => {
                Term::stderr().show_cursor()?;
                Err(eyre!(err))
            }
        }
    }

    /// Get the minimum_release_age cutoff from the CLI --minimum-release-age flag only.
    /// Per-tool and global setting fallbacks are handled in ToolRequest::resolve.
    fn get_before_date(&self) -> Result<Option<Timestamp>> {
        resolve_cli_minimum_release_age(self.minimum_release_age.as_deref())
    }
}

static AFTER_LONG_HELP: &str = color_print::cstr!(
    r#"<bold><underline>Examples:</underline></bold>

    # run with no arguments to use the interactive selector
    $ <bold>mise use</bold>

    # set the current version of node to 20.x in mise.toml of current directory
    # will write the fuzzy version (e.g.: 20)
    $ <bold>mise use node@20</bold>

    # set the current version of node to 20.x in ~/.config/mise/config.toml
    # will write the precise version (e.g.: 20.0.0)
    $ <bold>mise use -g --pin node@20</bold>

    # sets .mise.local.toml (which is intended not to be committed to a project)
    $ <bold>mise use --env local node@20</bold>

    # sets .mise.staging.toml (which is used if MISE_ENV=staging)
    $ <bold>mise use --env staging node@20</bold>
"#
);
