//! Shared helpers for `mise oci` subcommands.

use std::path::Path;

use eyre::{Result, bail};

use crate::config::{Config, ConfigMap};
use crate::oci::{BuildOptions, BuildOutput, Builder, OciConfig};
use crate::system;
use crate::toolset::{ConfigScope, ToolsetBuilder};

/// Merge `[oci]` sections from the given config files, with more specific
/// (closer to the current directory) configs winning per-field.
///
/// Uses "first-Some-wins" semantics via `OciConfig::fill_defaults_from`, so
/// the result is correct as long as the iteration order is most-specific-first
/// — the convention at the time of writing. If that convention ever inverts,
/// this logic still picks the first value it sees and just changes which
/// config wins; it won't silently drop fields or return garbage.
pub fn merged_oci_config_from<'a>(
    config_files: impl IntoIterator<
        Item = &'a std::sync::Arc<dyn crate::config::config_file::ConfigFile>,
    >,
) -> OciConfig {
    let mut out = OciConfig::default();
    for cf in config_files {
        if let Some(mut oci) = cf.oci_config() {
            resolve_copy_paths(&mut oci, cf.get_path().parent().unwrap_or(Path::new(".")));
            out.fill_defaults_from(oci);
        }
    }
    out
}

/// Resolve config-provided copy sources relative to the file that declared
/// them. CLI copy sources remain relative to the process working directory.
fn resolve_copy_paths(oci: &mut OciConfig, base: &Path) {
    for copy in &mut oci.copy {
        if copy.host.is_relative() {
            copy.host = base.join(&copy.host);
        }
    }
}

/// Convenience wrapper that merges `[oci]` across *all* loaded configs.
/// Used when the caller has explicitly opted into global/system scope.
pub fn merged_oci_config(config: &Config) -> OciConfig {
    merged_oci_config_from(config.config_files.values())
}

/// Load config + toolset, merge the `[oci]` section, and build the image.
/// Used by `mise oci build`, `mise oci run`, and `mise oci push`.
///
/// By default both the toolset and the `[oci]` section are scoped to configs
/// at-or-below the project root — i.e. the user's global config, system
/// config, and parent-directory configs above the project (e.g. a `~/mise.toml`
/// setting personal Node defaults) are excluded. Rationale: `mise oci build`
/// is conceptually "package *this project's* tools into a deployable image" —
/// personal dev tools (neovim, ripgrep, …) sitting in
/// `~/.config/mise/config.toml` have no business in a project image, and
/// several of them (asdf/vfox plugins) would in fact be rejected by the v1
/// builder. See discussion #9690.
///
/// Set `include_global = true` to revert to the merge-everything behavior.
pub async fn perform_build(opts: BuildOptions, include_global: bool) -> Result<BuildOutput> {
    let config = Config::get().await?;
    if include_global {
        let ts = ToolsetBuilder::new().build(&config).await?;
        let oci = merged_oci_config(&config);
        reject_unsupported_system_defaults(&config.config_files)?;
        let files = system::files::files_from_config_files(&config.config_files);
        let packages = system::packages_from_config_files(&config.config_files);
        return Builder::new(config.clone(), ts, oci, opts)
            .with_dotfiles(files)
            .with_system_packages(packages)
            .build()
            .await;
    }
    let project_files = project_config_files(&config)?;
    let ts = build_project_toolset(&config, project_files.clone()).await?;
    let oci = merged_oci_config_from(project_files.values());
    reject_unsupported_system_defaults(&project_files)?;
    let files = system::files::files_from_config_files(&project_files);
    let packages = system::packages_from_config_files(&project_files);
    Builder::new(config.clone(), ts, oci, opts)
        .with_dotfiles(files)
        .with_system_packages(packages)
        .build()
        .await
}

/// Configs at-or-below the project root.
///
/// `cf.project_root().is_some()` is the right scope: it's None for the global
/// config, the system config, and parent-dir configs that live directly under
/// $HOME (e.g. `~/mise.toml` someone uses to set their default Node). It's
/// Some for any project config, including those walked up from CWD into a
/// monorepo root — which we *do* want included (sub-project + monorepo root
/// share the toolset of the deployable).
fn project_config_files(config: &Config) -> Result<ConfigMap> {
    let project_files: ConfigMap = config
        .config_files
        .iter()
        .filter(|(_, cf)| cf.project_root().is_some())
        .map(|(p, cf)| (p.clone(), cf.clone()))
        .collect();
    if project_files.is_empty() {
        bail!(
            "mise oci: no project mise config found in the current directory or any parent. \
             Add a `mise.toml` to the project, or pass `--include-global` to use tools and \
             [oci] settings from your global config (note: asdf/vfox plugins remain \
             unsupported)."
        );
    }
    Ok(project_files)
}

async fn build_project_toolset(
    config: &std::sync::Arc<Config>,
    project_files: ConfigMap,
) -> Result<crate::toolset::Toolset> {
    // ConfigScope::LocalOnly belt-and-suspenders: the project-files filter
    // already drops global/system, but LocalOnly *also* drops MISE_*_VERSION
    // ad-hoc overrides from the environment, which shouldn't bake into an
    // image either.
    ToolsetBuilder::new()
        .with_config_files(project_files)
        .with_scope(ConfigScope::LocalOnly)
        .build(config)
        .await
}

fn reject_unsupported_system_defaults(config_files: &ConfigMap) -> Result<()> {
    let mut defaults = 0usize;
    for cf in config_files.values() {
        let Some(system) = cf.bootstrap_config() else {
            continue;
        };
        defaults += system::macos_defaults_entry_count(&system.macos);
    }

    if defaults > 0 {
        bail!(
            "mise oci does not support [bootstrap.macos.*] defaults (found {defaults} default entries); \
             macOS defaults do not apply to OCI images."
        );
    }
    Ok(())
}

pub fn short_digest(d: &str) -> String {
    let hex = d.trim_start_matches("sha256:");
    if hex.len() >= 12 {
        format!("sha256:{}", &hex[..12])
    } else {
        d.to_string()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::oci::OciCopy;

    #[test]
    fn config_copy_paths_resolve_from_declaring_config_directory() {
        let mut oci = OciConfig {
            copy: vec![
                OciCopy {
                    host: "assets".into(),
                    image: "/srv/assets".into(),
                },
                OciCopy {
                    host: "/absolute/tool".into(),
                    image: "/usr/local/bin/tool".into(),
                },
            ],
            ..Default::default()
        };

        resolve_copy_paths(&mut oci, Path::new("/project"));

        assert_eq!(oci.copy[0].host, Path::new("/project/assets"));
        assert_eq!(oci.copy[1].host, Path::new("/absolute/tool"));
    }
}
