mod path;

use crate::{exit, plugins::PluginEnum};
use std::collections::HashSet;
use std::{collections::BTreeMap, sync::Arc};

use crate::backend::backend_type::BackendType;
use crate::build_time::built_info;
use crate::cli::self_update::SelfUpdate;
use crate::cli::version;
use crate::cli::version::VERSION;
use crate::config::{Config, IGNORED_CONFIG_FILES, Settings};
use crate::env::PATH_KEY;
use crate::file::{canonicalize_cached, canonicalize_or_self, display_path};
use crate::git::Git;
use crate::plugins::PluginType;
use crate::plugins::core::CORE_PLUGINS;
use crate::registry::REGISTRY;
use crate::toolset::install_state;
use crate::toolset::{ToolVersion, Toolset, ToolsetBuilder};
use crate::ui::{info, style};
use crate::{backend, dirs, duration, env, file, shims};
use console::{Alignment, pad_str, style};
use heck::ToSnakeCase;
use indexmap::IndexMap;
use indoc::formatdoc;
use itertools::Itertools;
use std::env::split_paths;
use std::path::{Path, PathBuf};
use strum::IntoEnumIterator;

/// Check mise installation for possible problems
#[derive(Debug, clap::Args)]
#[clap(visible_alias = "dr", verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)]
pub struct Doctor {
    #[clap(subcommand)]
    subcommand: Option<Commands>,
    #[clap(skip)]
    errors: Vec<String>,
    #[clap(skip)]
    warnings: Vec<String>,
    #[clap(long, short = 'J')]
    json: bool,
}

#[derive(Debug, clap::Subcommand)]
pub enum Commands {
    Path(path::Path),
}

impl Doctor {
    pub async fn run(self) -> eyre::Result<()> {
        if let Some(cmd) = self.subcommand {
            match cmd {
                Commands::Path(cmd) => cmd.run().await,
            }
        } else if self.json {
            self.doctor_json().await
        } else {
            self.doctor().await
        }
    }

    async fn doctor_json(mut self) -> crate::Result<()> {
        let mut data: BTreeMap<String, _> = BTreeMap::new();
        data.insert(
            "version".into(),
            serde_json::Value::String(VERSION.to_string()),
        );
        data.insert("activated".into(), env::is_activated().into());
        data.insert("shims_on_path".into(), shims_on_path().into());
        data.insert(
            "self_update_available".into(),
            SelfUpdate::is_available().into(),
        );
        // Warn about shims+activate conflict, but not when not_found_auto_install is enabled
        // since that intentionally preserves shims for auto-install functionality
        if env::is_activated() && shims_on_path() && !Settings::get().not_found_auto_install {
            self.errors.push("shims are on PATH and mise is also activated. You should only use one of these methods.".to_string());
        }
        data.insert(
            "build_info".into(),
            build_info()
                .into_iter()
                .map(|(k, v)| (k.to_snake_case(), v))
                .collect(),
        );

        let shell = shell();
        let mut shell_lines = shell.lines();
        let mut shell = serde_json::Map::new();
        if let Some(name) = shell_lines.next() {
            shell.insert("name".into(), name.into());
        }
        if let Some(version) = shell_lines.next() {
            shell.insert("version".into(), version.into());
        }
        data.insert("shell".into(), shell.into());
        data.insert(
            "dirs".into(),
            mise_dirs()
                .into_iter()
                .map(|(k, p)| (k, p.to_string_lossy().to_string()))
                .collect(),
        );
        let mut aqua = serde_json::Map::new();
        let aqua_registry_metadata =
            crate::aqua::standard_registry::AQUA_STANDARD_REGISTRY_METADATA;
        aqua.insert(
            "baked_in_registry_repository".into(),
            aqua_registry_metadata.repository.into(),
        );
        aqua.insert(
            "baked_in_registry_tag".into(),
            aqua_registry_metadata.tag.into(),
        );
        aqua.insert(
            "baked_in_registry_tools".into(),
            aqua_registry_count().into(),
        );
        data.insert("aqua".into(), aqua.into());
        data.insert("env_vars".into(), mise_env_vars().into_iter().collect());
        data.insert(
            "settings".into(),
            serde_json::from_str(&cmd!(&*env::MISE_BIN, "settings", "-J").read()?)?,
        );

        let config = Config::get().await?;
        let ts = config.get_toolset().await?;
        self.analyze_shims(&config, ts).await;
        self.analyze_plugins();
        self.analyze_backend_mismatches();
        self.check_path_ordering(ts, &config).await;
        data.insert(
            "paths".into(),
            self.paths(ts)
                .await?
                .into_iter()
                .map(|p| p.to_string_lossy().to_string())
                .collect(),
        );
        data.insert(
            "config_files".into(),
            config
                .config_files
                .keys()
                .map(|p| p.to_string_lossy().to_string())
                .collect(),
        );
        data.insert(
            "env_files".into(),
            config
                .env_results()
                .await?
                .env_files
                .iter()
                .map(|f| f.to_string_lossy().to_string())
                .collect(),
        );
        data.insert(
            "ignored_config_files".into(),
            IGNORED_CONFIG_FILES
                .iter()
                .map(|p| p.to_string_lossy().to_string())
                .collect(),
        );
        data.insert("plugins".into(), render_plugins_json());

        let tools = ts.list_versions_by_plugin().into_iter().map(|(f, tv)| {
            let versions: serde_json::Value = tv
                .iter()
                .filter(|tv| tv.request.is_os_supported())
                .map(|tv: &ToolVersion| {
                    let mut tool = serde_json::Map::new();
                    match f.is_version_installed(&config, tv, true) {
                        true => {
                            tool.insert("version".into(), tv.version.to_string().into());
                        }
                        false => {
                            tool.insert("version".into(), tv.version.to_string().into());
                            tool.insert("missing".into(), true.into());
                            self.errors.push(format!(
                                "tool {tv} is not installed, install with `mise install`"
                            ));
                        }
                    }
                    serde_json::Value::from(tool)
                })
                .collect();
            (f.ba().to_string(), versions)
        });
        data.insert("toolset".into(), tools.collect());

        if !self.errors.is_empty() {
            data.insert("errors".into(), self.errors.clone().into_iter().collect());
        }
        if !self.warnings.is_empty() {
            data.insert(
                "warnings".into(),
                self.warnings.clone().into_iter().collect(),
            );
        }

        let out = serde_json::to_string_pretty(&data)?;
        println!("{out}");

        if !self.errors.is_empty() {
            exit(1);
        }
        Ok(())
    }

    async fn doctor(mut self) -> eyre::Result<()> {
        info::inline_section("version", &*VERSION)?;
        #[cfg(unix)]
        info::inline_section("activated", yn(env::is_activated()))?;
        info::inline_section("shims_on_path", yn(shims_on_path()))?;
        info::inline_section("self_update_available", yn(SelfUpdate::is_available()))?;
        if !SelfUpdate::is_available()
            && let Some(instructions) = crate::cli::self_update::upgrade_instructions_text()
        {
            info::section("self_update_instructions", instructions)?;
        }
        // Warn about shims+activate conflict, but not when not_found_auto_install is enabled
        // since that intentionally preserves shims for auto-install functionality
        if env::is_activated() && shims_on_path() && !Settings::get().not_found_auto_install {
            self.errors.push("shims are on PATH and mise is also activated. You should only use one of these methods.".to_string());
        }

        let build_info = build_info()
            .into_iter()
            .map(|(k, v)| format!("{k}: {v}"))
            .join("\n");
        info::section("build_info", build_info)?;
        info::section("shell", shell())?;
        info::section("aqua", aqua_registry_count_str())?;

        let mise_dirs = mise_dirs()
            .into_iter()
            .map(|(k, p)| format!("{k}: {}", display_path(p)))
            .join("\n");
        info::section("dirs", mise_dirs)?;

        match Config::get().await {
            Ok(config) => self.analyze_config(&config).await?,
            Err(err) => self.errors.push(format!("failed to load config: {err}")),
        }

        self.analyze_plugins();
        self.analyze_backend_mismatches();

        let env_vars = mise_env_vars()
            .into_iter()
            .map(|(k, v)| format!("{k}={v}"))
            .join("\n");
        if env_vars.is_empty() {
            info::section("env_vars", "(none)")?;
        } else {
            info::section("env_vars", env_vars)?;
        }
        self.analyze_settings()?;

        if let Some(latest) = version::check_for_new_version(duration::HOURLY).await {
            version::show_latest().await;
            self.warnings.push(format!(
                "new mise version {latest} available, currently on {}",
                *version::V
            ));
        }

        miseprintln!();

        if !self.warnings.is_empty() {
            let warnings_plural = if self.warnings.len() == 1 { "" } else { "s" };
            let warning_summary =
                format!("{} warning{warnings_plural} found:", self.warnings.len());
            miseprintln!("{}\n", style(warning_summary).yellow().bold());
            for (i, check) in self.warnings.iter().enumerate() {
                let num = style::nyellow(format!("{}.", i + 1));
                miseprintln!("{num} {}\n", info::indent_by(check, "   ").trim_start());
            }
        }

        if self.errors.is_empty() {
            miseprintln!("No problems found");
        } else {
            let errors_plural = if self.errors.len() == 1 { "" } else { "s" };
            let error_summary = format!("{} problem{errors_plural} found:", self.errors.len());
            miseprintln!("{}\n", style(error_summary).red().bold());
            for (i, check) in self.errors.iter().enumerate() {
                let num = style::nred(format!("{}.", i + 1));
                miseprintln!("{num} {}\n", info::indent_by(check, "   ").trim_start());
            }
            exit(1);
        }

        Ok(())
    }

    fn analyze_settings(&mut self) -> eyre::Result<()> {
        match cmd!("mise", "settings").read() {
            Ok(settings) => {
                info::section("settings", settings)?;
            }
            Err(err) => self.errors.push(format!("failed to load settings: {err}")),
        }
        Ok(())
    }
    async fn analyze_config(&mut self, config: &Arc<Config>) -> eyre::Result<()> {
        info::section("config_files", render_config_files(config))?;
        info::section("env_files", render_env_files(config).await?)?;
        if IGNORED_CONFIG_FILES.is_empty() {
            println!();
            info::inline_section("ignored_config_files", "(none)")?;
        } else {
            info::section(
                "ignored_config_files",
                IGNORED_CONFIG_FILES.iter().map(display_path).join("\n"),
            )?;
        }
        info::section("backends", render_backends())?;
        info::section("plugins", render_plugins())?;

        for backend in backend::list() {
            if let Some(plugin) = backend.plugin()
                && !plugin.is_installed()
            {
                self.errors
                    .push(format!("plugin {} is not installed", &plugin.name()));
                continue;
            }
        }

        if !env::is_activated() && !shims_on_path() {
            let shims = style::ncyan(display_path(*dirs::SHIMS));
            if cfg!(windows) {
                self.errors.push(formatdoc!(
                    r#"mise shims are not on PATH
                    Add this directory to PATH: {shims}"#
                ));
            } else {
                let cmd = style::nyellow("mise help activate");
                let url = style::nunderline("https://mise.en.dev");
                self.errors.push(formatdoc!(
                    r#"mise is not activated, run {cmd} or
                        read documentation at {url} for activation instructions.
                        Alternatively, add the shims directory {shims} to PATH.
                        Using the shims directory is preferred for non-interactive setups."#
                ));
            }
        }

        match ToolsetBuilder::new().build(config).await {
            Ok(ts) => {
                self.analyze_shims(config, &ts).await;
                self.analyze_toolset(&ts).await?;
                self.analyze_paths(&ts).await?;
                self.check_path_ordering(&ts, config).await;
            }
            Err(err) => self.errors.push(format!("failed to load toolset: {err}")),
        }

        Ok(())
    }

    async fn analyze_toolset(&mut self, ts: &Toolset) -> eyre::Result<()> {
        let config = Config::get().await?;
        let tools = ts
            .list_current_versions()
            .into_iter()
            .map(|(f, tv)| match f.is_version_installed(&config, &tv, true) {
                true => (tv.to_string(), style::nstyle("")),
                false => {
                    self.errors.push(format!(
                        "tool {tv} is not installed, install with `mise install`"
                    ));
                    (tv.to_string(), style::ndim("(missing)"))
                }
            })
            .collect_vec();
        let max_tool_len = tools
            .iter()
            .map(|(t, _)| t.len())
            .max()
            .unwrap_or(0)
            .min(20);
        let tools = tools
            .into_iter()
            .map(|(t, s)| format!("{}  {s}", pad_str(&t, max_tool_len, Alignment::Left, None)))
            .sorted()
            .collect::<Vec<_>>()
            .join("\n");

        info::section("toolset", tools)?;
        Ok(())
    }

    async fn analyze_shims(&mut self, config: &Arc<Config>, toolset: &Toolset) {
        let mise_bin = file::which_no_shims("mise").unwrap_or(env::MISE_BIN.clone());

        if let Ok((missing, extra)) = shims::get_shim_diffs(config, mise_bin, toolset).await {
            let cmd = style::nyellow("mise reshim");

            if !missing.is_empty() {
                self.errors.push(formatdoc!(
                    "shims are missing, run {cmd} to create them
                     Missing shims: {missing}",
                    missing = missing.into_iter().join(", ")
                ));
            }

            if !extra.is_empty() {
                self.errors.push(formatdoc!(
                    "unused shims are present, run {cmd} to remove them
                     Unused shims: {extra}",
                    extra = extra.into_iter().join(", ")
                ));
            }
        }
        time!("doctor::analyze_shims");
    }

    fn analyze_plugins(&mut self) {
        for plugin in backend::list() {
            let is_core = CORE_PLUGINS.contains_key(plugin.id());
            let plugin_type = plugin.get_plugin_type();

            if is_core && matches!(plugin_type, Some(PluginType::Asdf | PluginType::Vfox)) {
                self.warnings
                    .push(format!("plugin {} overrides a core plugin", &plugin.id()));
            }
        }
    }

    fn analyze_backend_mismatches(&mut self) {
        for (short, ist) in install_state::list_tools().iter() {
            // Skip plugin-based tools (they use the plugin as backend)
            if install_state::get_plugin_type(short).is_some() {
                continue;
            }

            // Get stored backend, skip if none
            let Some(stored_full) = &ist.full else {
                continue;
            };

            // Get registry entry, skip if not in registry
            let Some(rt) = REGISTRY.get(short.as_str()) else {
                continue;
            };

            // Get recommended backend for current platform
            let backends = rt.backends();
            let Some(registry_full) = backends.first() else {
                continue;
            };

            // Strip options for comparison (e.g., "github:repo[exe=bin]" -> "github:repo")
            let stored_stripped = stored_full.split('[').next().unwrap_or(stored_full);
            let registry_stripped = registry_full.split('[').next().unwrap_or(registry_full);

            // Compare backends
            if stored_stripped != registry_stripped {
                let msg = if ist.explicit_backend {
                    formatdoc!(
                        r#"tool '{short}' installed with explicit backend '{stored_full}'
                           differs from registry recommendation '{registry_full}'.
                           To switch: mise uninstall --all {short} && mise install {short}"#
                    )
                } else {
                    formatdoc!(
                        r#"tool '{short}' installed with backend '{stored_full}'
                           but registry now recommends '{registry_full}'.
                           To migrate: mise uninstall --all {short} && mise install {short}"#
                    )
                };
                self.warnings.push(msg);
            }
        }
    }

    async fn paths(&mut self, ts: &Toolset) -> eyre::Result<Vec<PathBuf>> {
        let config = Config::get().await?;
        let env = ts.full_env(&config).await?;
        let path = env
            .get(&*PATH_KEY)
            .ok_or_else(|| eyre::eyre!("Path not found"))?;
        Ok(split_paths(path).collect())
    }

    async fn analyze_paths(&mut self, ts: &Toolset) -> eyre::Result<()> {
        let paths = self
            .paths(ts)
            .await?
            .into_iter()
            .map(display_path)
            .join("\n");

        info::section("path", paths)?;
        Ok(())
    }

    /// Check that mise tool paths appear before system paths in the current PATH.
    /// This detects cases where shell configuration (e.g. brew, system profile) has
    /// inserted entries before mise's paths, causing system tools to shadow mise-managed ones.
    async fn check_path_ordering(&mut self, ts: &Toolset, config: &Arc<Config>) {
        if !env::is_activated() {
            return;
        }

        // Get all mise-managed paths (tool installs, env._.path, UV venv, MISE_ADD_PATH, etc.)
        let mise_paths = match ts.final_env(config).await {
            Ok((_env, env_results)) => match ts.list_final_paths(config, env_results).await {
                Ok(paths) => paths,
                Err(_) => return,
            },
            Err(_) => return,
        };
        if mise_paths.is_empty() {
            return;
        }

        let current_path = &*env::PATH_NON_PRISTINE;
        if current_path.is_empty() {
            return;
        }

        let resolve = |p: &PathBuf| canonicalize_or_self(p);

        // Resolve all mise-managed paths for comparison
        let mise_paths_resolved: HashSet<PathBuf> = mise_paths.iter().map(resolve).collect();

        // Also exclude the mise binary's own directory
        let mise_bin_parent = env::MISE_BIN.parent().and_then(canonicalize_cached);

        // Find the index of the first mise-managed path in the current PATH
        // Note: mise_bin_parent is intentionally excluded here — it's a directory like
        // /opt/homebrew/bin that happens to contain the mise binary, not a mise tool path.
        // Including it would mask the exact PATH ordering issue we're trying to detect.
        let first_mise_idx = current_path
            .iter()
            .position(|p| mise_paths_resolved.contains(&resolve(p)));

        let Some(first_mise_idx) = first_mise_idx else {
            // No mise paths found in current PATH at all — this is already
            // covered by the activation check
            return;
        };

        if first_mise_idx == 0 {
            return;
        }

        // Everything before first_mise_idx is by definition not a mise-managed path.
        // Filter out the mise binary's own directory (e.g. /opt/homebrew/bin) since
        // that's not a problematic entry — it's just where mise itself is installed.
        let paths_display = current_path[..first_mise_idx]
            .iter()
            .filter(|p| mise_bin_parent.as_ref().is_none_or(|bp| bp != &resolve(p)))
            .map(display_path)
            .join("\n  ");

        if paths_display.is_empty() {
            return;
        }
        self.warnings.push(formatdoc!(
            r#"mise tool paths are not first in PATH. These paths take precedence:
              {paths_display}
            This may cause system-installed tools to be used instead of mise-managed versions.
            Ensure `mise activate` runs after other PATH modifications in your shell rc file."#
        ));
    }
}

fn shims_on_path() -> bool {
    let shims = &*dirs::SHIMS;
    env::PATH
        .iter()
        .any(|p| crate::file::paths_eq(&crate::file::replace_path(p), shims))
}

fn yn(b: bool) -> String {
    if b {
        style("yes").green().to_string()
    } else {
        style("no").red().to_string()
    }
}

fn mise_dirs() -> Vec<(String, &'static Path)> {
    [
        ("cache", &*dirs::CACHE),
        ("config", &*dirs::CONFIG),
        ("data", &*dirs::DATA),
        ("shims", &*dirs::SHIMS),
        ("state", &*dirs::STATE),
    ]
    .iter()
    .map(|(k, v)| (k.to_string(), **v))
    .collect()
}

fn mise_env_vars() -> Vec<(String, String)> {
    const REDACT_KEYS: &[&str] = &[
        "MISE_GITHUB_TOKEN",
        "MISE_GITLAB_TOKEN",
        "MISE_GITHUB_ENTERPRISE_TOKEN",
        "MISE_GITLAB_ENTERPRISE_TOKEN",
    ];
    env::vars_safe()
        .filter(|(k, _)| k.starts_with("MISE_"))
        .map(|(k, v)| {
            let v = if REDACT_KEYS.contains(&k.as_str()) {
                style::ndim("REDACTED").to_string()
            } else {
                v
            };
            (k, v)
        })
        .collect()
}

fn render_config_files(config: &Config) -> String {
    config
        .config_files
        .keys()
        .rev()
        .map(display_path)
        .join("\n")
}

async fn render_env_files(config: &Arc<Config>) -> eyre::Result<String> {
    let env_files = &config.env_results().await?.env_files;
    if env_files.is_empty() {
        Ok("(none)".to_string())
    } else {
        Ok(env_files.iter().map(display_path).join("\n"))
    }
}

fn render_backends() -> String {
    BackendType::iter()
        .filter(|b| b != &BackendType::Unknown)
        .map(|b| b.to_string())
        .join("\n")
}

fn render_plugins() -> String {
    let plugins = installed_plugins();
    let max_plugin_name_len = plugins
        .iter()
        .map(|p| p.name().len())
        .max()
        .unwrap_or(0)
        .min(40);
    plugins
        .into_iter()
        .map(|p| {
            let padded_name = pad_str(p.name(), max_plugin_name_len, Alignment::Left, None);
            let extra = plugin_extra(&p);
            format!("{padded_name}  {}", style::ndim(extra))
        })
        .collect::<Vec<_>>()
        .join("\n")
}

fn render_plugins_json() -> serde_json::Value {
    installed_plugins()
        .into_iter()
        .map(|plugin| {
            let mut value = serde_json::Map::new();
            value.insert(
                "type".into(),
                serde_json::Value::String(plugin_type_name(plugin.get_plugin_type()).to_string()),
            );
            if let Some(git) = plugin_git(&plugin)
                && let Some(url) = git.get_remote_url()
            {
                value.insert("url".into(), serde_json::Value::String(url));
                if let Ok(ref_) = git.current_abbrev_ref() {
                    value.insert("ref".into(), serde_json::Value::String(ref_));
                }
                if let Ok(sha) = git.current_sha_short() {
                    value.insert("sha".into(), serde_json::Value::String(sha));
                }
            }
            (plugin.name().to_string(), serde_json::Value::Object(value))
        })
        .collect::<serde_json::Map<_, _>>()
        .into()
}

fn installed_plugins() -> Vec<PluginEnum> {
    install_state::list_plugins()
        .iter()
        .map(|(name, plugin_type)| plugin_type.plugin(name.clone()))
        .filter(|plugin| plugin.is_installed())
        .collect()
}

fn plugin_extra(plugin: &PluginEnum) -> String {
    let Some(git) = plugin_git(plugin) else {
        return "".to_string();
    };
    match git.get_remote_url() {
        Some(url) => {
            let sha = git
                .current_sha_short()
                .unwrap_or_else(|_| "(unknown)".to_string());
            format!("{url}#{sha}")
        }
        None => "".to_string(),
    }
}

fn plugin_git(plugin: &PluginEnum) -> Option<Git> {
    let path = dirs::PLUGINS.join(plugin.name());
    if path.join(".git").exists() {
        Some(Git::new(path))
    } else {
        None
    }
}

fn plugin_type_name(plugin_type: PluginType) -> &'static str {
    match plugin_type {
        PluginType::Asdf => "asdf",
        PluginType::Vfox => "vfox",
        PluginType::VfoxBackend => "vfox_backend",
    }
}

fn build_info() -> IndexMap<String, &'static str> {
    let mut s = IndexMap::new();
    s.insert("Target".into(), built_info::TARGET);
    s.insert("Features".into(), built_info::FEATURES_STR);
    s.insert("Built".into(), built_info::BUILT_TIME_UTC);
    s.insert("Rust Version".into(), built_info::RUSTC_VERSION);
    s.insert("Profile".into(), built_info::PROFILE);
    s
}

fn shell() -> String {
    match env::MISE_SHELL.map(|s| s.to_string()) {
        Some(shell) => {
            let shell_cmd = if env::SHELL.ends_with(shell.as_str()) {
                &*env::SHELL
            } else {
                &shell
            };
            let version = cmd!(shell_cmd, "--version")
                .read()
                .unwrap_or_else(|e| format!("failed to get shell version: {e}"));
            format!("{shell_cmd}\n{version}")
        }
        None => "(unknown)".to_string(),
    }
}

fn aqua_registry_count() -> usize {
    crate::aqua::standard_registry::AQUA_STANDARD_REGISTRY_FILES.len()
}

fn aqua_registry_count_str() -> String {
    let metadata = crate::aqua::standard_registry::AQUA_STANDARD_REGISTRY_METADATA;
    format!(
        "baked in registry: {}@{}\nbaked in registry tools: {}",
        metadata.repository,
        metadata.tag,
        aqua_registry_count()
    )
}

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

    $ <bold>mise doctor</bold>
    [WARN] plugin node is not installed
"#
);
