use crate::{
    Config, FigmentProviders, foundry_toml_dir_entries, remappings_from_env_var,
    remappings_from_newline,
};
use figment::{
    Error, Figment, Metadata, Profile, Provider,
    value::{Dict, Map},
};
use foundry_compilers::artifacts::remappings::{RelativeRemapping, Remapping};
use rayon::prelude::*;
use std::{
    borrow::Cow,
    cmp::Reverse,
    collections::{
        BTreeMap, BTreeSet, HashMap, HashSet, btree_map::Entry, hash_map::Entry as HashEntry,
    },
    fs,
    path::{MAIN_SEPARATOR, Path, PathBuf},
};

const GENERATED_REMAPPINGS_KEY: &str = "__generated_remappings";

#[derive(Default)]
struct RemappingsOutput {
    /// All explicit, dependency, and auto-detected remappings exposed to the config.
    remappings: Vec<Remapping>,
    /// Contextual dependency refinements generated by this provider.
    ///
    /// These are tracked so the later CLI merge can distinguish them from equivalent explicit
    /// contextual remappings.
    generated_contextual_remappings: Vec<Remapping>,
}

/// Wrapper types over a `Vec<Remapping>` that only appends unique remappings.
#[derive(Clone, Debug, Default)]
pub struct Remappings {
    /// Remappings.
    remappings: Vec<Remapping>,
    /// Source, test and script configured project dirs.
    /// Remappings of these dirs from libs are ignored.
    project_paths: Vec<Remapping>,
}

impl Remappings {
    /// Create a new `Remappings` wrapper with an empty vector.
    pub const fn new() -> Self {
        Self { remappings: Vec::new(), project_paths: Vec::new() }
    }

    /// Create a new `Remappings` wrapper with a vector of remappings.
    pub const fn new_with_remappings(remappings: Vec<Remapping>) -> Self {
        Self { remappings, project_paths: Vec::new() }
    }

    /// Extract project paths that cannot be remapped by dependencies.
    pub fn with_figment(mut self, figment: &Figment) -> Self {
        let mut add_project_remapping = |path: &str| {
            if let Ok(path) = figment.find_value(path)
                && let Some(path) = path.into_string()
            {
                let remapping =
                    Remapping { context: None, name: format!("{path}/"), path: format!("{path}/") };
                self.project_paths.push(remapping);
            }
        };
        add_project_remapping("src");
        add_project_remapping("test");
        add_project_remapping("script");
        self
    }

    /// Filters the remappings vector by name and context.
    fn filter_key(r: &Remapping) -> String {
        match &r.context {
            Some(str) => str.clone() + &r.name.clone(),
            None => r.name.clone(),
        }
    }

    /// Consumes the wrapper and returns the inner remappings vector.
    pub fn into_inner(self) -> Vec<Remapping> {
        let mut seen = HashSet::new();
        self.remappings.iter().filter(|r| seen.insert(Self::filter_key(r))).cloned().collect()
    }

    /// Push an element to the remappings vector, but only if it's not already present.
    fn push(&mut self, remapping: Remapping) -> bool {
        // Special handling for .sol file remappings, only allow one remapping per source file.
        if remapping.name.ends_with(".sol") && !remapping.path.ends_with(".sol") {
            return false;
        }

        if self.remappings.iter().any(|existing| {
            if remapping.name.ends_with(".sol") {
                // For .sol files, only prevent duplicate source names in the same context
                return existing.name == remapping.name
                    && existing.context == remapping.context
                    && existing.path == remapping.path;
            }

            // Autodetected remappings are added from the root project down through its libraries,
            // so an existing root alias remains authoritative over an equal or more specific
            // dependency alias. For example, an existing `@utils/=src/` suppresses an incoming
            // `@utils/libraries/=lib/utils/`, preventing a dependency from overriding part of the
            // root namespace. The reverse direction is intentional: an existing
            // `@prb/math/=src/math/` can coexist with an incoming `@prb/=lib/prb/`; the root alias
            // resolves its subtree while the dependency alias acts as a fallback for the rest of
            // the namespace.
            let mut existing_name_path = existing.name.clone();
            if !existing_name_path.ends_with('/') {
                existing_name_path.push('/')
            }
            let is_conflicting = remapping.name.starts_with(&existing_name_path);
            is_conflicting && existing.context == remapping.context
        }) {
            return false;
        };

        // Ignore remappings of root project src, test or script dir.
        // See <https://github.com/foundry-rs/foundry/issues/3440>.
        if self
            .project_paths
            .iter()
            .any(|project_path| remapping.name.eq_ignore_ascii_case(&project_path.name))
        {
            return false;
        };

        self.remappings.push(remapping);
        true
    }

    /// Extend the remappings vector, leaving out the remappings that are already present.
    pub fn extend(&mut self, remappings: Vec<Remapping>) {
        for remapping in remappings {
            self.push(remapping);
        }
    }

    /// Extract generated contextual refinements from a Figment.
    pub fn generated_from_figment(figment: &Figment) -> Vec<Remapping> {
        figment.extract_inner(GENERATED_REMAPPINGS_KEY).unwrap_or_default()
    }

    /// Merge config remappings while preserving CLI precedence over generated refinements.
    ///
    /// Narrow CLI aliases are overlaid into generated contexts; explicit contexts are unchanged.
    pub fn extend_with_config_remappings(
        &mut self,
        config_remappings: Vec<Remapping>,
        generated_contextual_remappings: &[Remapping],
    ) {
        let authoritative = self
            .remappings
            .iter()
            .filter(|remapping| remapping.context.is_none())
            .cloned()
            .collect::<Vec<_>>();
        let mut suppressed = HashSet::new();
        let mut overlays = Vec::new();
        for (index, remapping) in config_remappings.iter().enumerate().filter(|(_, remapping)| {
            remapping.context.is_some() && generated_contextual_remappings.contains(remapping)
        }) {
            if let Some(contextual) = contextual_overlays(&authoritative, remapping) {
                overlays.extend(contextual);
            } else {
                suppressed.insert(index);
            }
        }
        self.extend(overlays);
        for (index, remapping) in config_remappings.into_iter().enumerate() {
            if !suppressed.contains(&index) {
                self.push(remapping);
            }
        }
    }
}

struct CachedNestedConfig {
    src: PathBuf,
    libs: Vec<PathBuf>,
    remappings: Vec<Remapping>,
    file_remappings: Vec<Remapping>,
}

/// A figment provider that checks if the remappings were previously set and if they're unset looks
/// up the fs via
///   - `DAPP_REMAPPINGS` || `FOUNDRY_REMAPPINGS` env var
///   - `<root>/remappings.txt` file
///   - `Remapping::find_many`.
pub struct RemappingsProvider<'a> {
    /// Whether to auto detect remappings from the `lib_paths`
    pub auto_detect_remappings: bool,
    /// The lib/dependency directories to scan for remappings
    pub lib_paths: Cow<'a, Vec<PathBuf>>,
    /// the root path used to turn an absolute `Remapping`, as we're getting it from
    /// `Remapping::find_many` into a relative one.
    pub root: &'a Path,
    /// This contains either:
    ///   - previously set remappings
    ///   - a `MissingField` error, which means previous provider didn't set the "remappings" field
    ///   - other error, like formatting
    pub remappings: Result<Vec<Remapping>, Error>,
}

impl RemappingsProvider<'_> {
    /// Find and parse remappings for the projects
    ///
    /// **Order**
    ///
    /// Remappings are built in this order (last item takes precedence)
    /// - Autogenerated remappings
    /// - toml remappings
    /// - `remappings.txt`
    /// - Environment variables
    /// - CLI parameters
    fn get_remappings(&self, remappings: Vec<Remapping>) -> Result<RemappingsOutput, Error> {
        trace!("get all remappings from {:?}", self.root);
        /// prioritizes remappings that are closer: shorter `path`
        ///   - ("a", "1/2") over ("a", "1/2/3")
        ///
        /// grouped by remapping context
        fn insert_closest(
            mappings: &mut BTreeMap<Option<String>, BTreeMap<String, PathBuf>>,
            context: Option<String>,
            key: String,
            path: PathBuf,
        ) {
            let context_mappings = mappings.entry(context).or_default();
            match context_mappings.entry(key) {
                Entry::Occupied(mut e)
                    if e.get().components().count() > path.components().count() =>
                {
                    e.insert(path);
                }
                Entry::Vacant(e) => {
                    e.insert(path);
                }
                _ => {}
            }
        }

        // Let's first just extend the remappings with the ones that were passed in,
        // without any filtering.
        let mut user_remappings = Vec::new();

        // check env vars
        if let Some(env_remappings) = remappings_from_env_var("DAPP_REMAPPINGS")
            .or_else(|| remappings_from_env_var("FOUNDRY_REMAPPINGS"))
        {
            user_remappings
                .extend(env_remappings.map_err::<Error, _>(|err| err.to_string().into())?);
        }

        // check remappings.txt file
        let remappings_file = self.root.join("remappings.txt");
        if remappings_file.is_file() {
            let content = fs::read_to_string(remappings_file).map_err(|err| err.to_string())?;
            let remappings_from_file: Result<Vec<_>, _> =
                remappings_from_newline(&content).collect();
            user_remappings
                .extend(remappings_from_file.map_err::<Error, _>(|err| err.to_string().into())?);
        }

        user_remappings.extend(remappings);
        let global_user_remappings =
            user_remappings.iter().filter(|r| r.context.is_none()).cloned().collect::<Vec<_>>();
        // Let's now use the wrapper to conditionally extend the remappings with the autodetected
        // ones. We want to avoid duplicates, and the wrapper will handle this for us.
        let mut all_remappings = Remappings::new_with_remappings(user_remappings);

        // scan all library dirs and autodetect remappings
        // TODO: if a lib specifies contexts for remappings manually, we need to figure out how to
        // resolve that
        if self.auto_detect_remappings {
            let (nested_foundry_remappings, auto_detected_remappings) = rayon::join(
                || self.find_nested_foundry_remappings(),
                || self.auto_detect_remappings(),
            );
            let nested_foundry_remappings = nested_foundry_remappings?;
            let auto_detected_remappings = auto_detected_remappings.collect::<Vec<_>>();

            let configured_package_entries = nested_foundry_remappings
                .iter()
                .filter(|(_, _, is_package_entry)| *is_package_entry)
                .map(|(lib, remapping, _)| (lib.clone(), remapping.clone()))
                .collect::<Vec<_>>();
            let mut lib_remappings = BTreeMap::new();
            let mut contextual_remappings = Vec::new();
            for (lib, r, is_package_entry) in nested_foundry_remappings {
                // A dependency can intentionally refine an auto-detected package root to its
                // source directory. Scope that refinement to the dependency so root imports keep
                // the broader package mapping.
                if !is_package_entry
                    && r.context.is_none()
                    && auto_detected_remappings.iter().any(|auto| {
                        auto.context == r.context
                            && auto.name == r.name
                            && Path::new(&r.path) != Path::new(&auto.path)
                            && Path::new(&r.path).starts_with(&auto.path)
                    })
                {
                    let mut contextual = r.clone();
                    contextual.context = Some(format!("{}/", lib.display()));
                    if let Some(overlays) =
                        contextual_overlays(&global_user_remappings, &contextual)
                    {
                        contextual_remappings.extend(overlays);
                        contextual_remappings.push(contextual);
                    }
                }
                insert_closest(&mut lib_remappings, r.context, r.name, r.path.into());
            }
            for r in auto_detected_remappings {
                // this is an additional safety check for weird auto-detected remappings
                if ["lib/", "src/", "contracts/"].contains(&r.name.as_str())
                    || configured_package_entries.iter().any(|(lib, configured)| {
                        configured.context == r.context
                            && configured.name == r.name
                            && dependency_owns_path(lib, Path::new(&r.path))
                    })
                {
                    trace!(target: "forge", "- skipping the remapping");
                    continue;
                }
                insert_closest(&mut lib_remappings, r.context, r.name, r.path.into());
            }

            let explicit_remappings = all_remappings
                .remappings
                .iter()
                .map(|r| relative_remapping_preserving_context_boundary(r.clone(), self.root))
                .collect::<Vec<_>>();
            let mut generated_remappings = Vec::new();
            for contextual in contextual_remappings {
                let relative =
                    relative_remapping_preserving_context_boundary(contextual.clone(), self.root);
                if !explicit_remappings.contains(&relative)
                    && all_remappings.push(contextual.clone())
                {
                    generated_remappings.push(contextual);
                }
            }
            all_remappings.extend(
                lib_remappings
                    .into_iter()
                    .flat_map(|(context, remappings)| {
                        remappings.into_iter().map(move |(name, path)| Remapping {
                            context: context.clone(),
                            name,
                            path: path.to_string_lossy().into(),
                        })
                    })
                    .collect(),
            );

            return Ok(RemappingsOutput {
                remappings: all_remappings.into_inner(),
                generated_contextual_remappings: generated_remappings,
            });
        }

        Ok(RemappingsOutput { remappings: all_remappings.into_inner(), ..Default::default() })
    }

    /// Returns all remappings declared in foundry.toml files of libraries
    fn find_nested_foundry_remappings(&self) -> Result<Vec<(PathBuf, Remapping, bool)>, Error> {
        let root = dunce::canonicalize(self.root).unwrap_or_else(|_| self.root.to_path_buf());
        let mut pending = self
            .lib_paths
            .iter()
            .map(|path| if path.is_absolute() { path.clone() } else { self.root.join(path) })
            .flat_map(foundry_toml_dir_entries)
            .collect::<BTreeSet<_>>();
        let mut seen = HashSet::from([root.clone()]);
        let mut configs = HashMap::<PathBuf, Option<CachedNestedConfig>>::new();
        let mut remappings = Vec::new();

        while let Some(entry) = pending.pop_first() {
            if entry.canonical == root {
                continue;
            }

            // Load dependency config inputs without recursively installing another remappings
            // provider. Canonical identity is used only to read each config once; emitted paths
            // retain the lexical dependency identity.
            let config = match configs.entry(entry.canonical.clone()) {
                HashEntry::Occupied(config) => config.into_mut(),
                HashEntry::Vacant(config) => {
                    trace!(lib = ?entry.canonical, "find all remappings of nested foundry.toml");
                    config.insert(load_nested_config(&entry.canonical)?)
                }
            };
            let Some(config) = config else {
                continue;
            };

            remappings.extend(config.remappings.iter().cloned().map(|remapping| {
                (
                    entry.path.clone(),
                    rebase_nested_remapping(remapping, &entry.canonical, &entry.path),
                    false,
                )
            }));
            remappings.extend(config.file_remappings.iter().cloned().map(|remapping| {
                (entry.path.clone(), RelativeRemapping::new(remapping, &entry.path).into(), false)
            }));

            // Preserve existing physical nested-config discovery. Symlink discovery is limited to
            // direct configured-library entries; nested symlink graphs remain out of scope.
            if !fs::symlink_metadata(&entry.path)
                .is_ok_and(|metadata| metadata.file_type().is_symlink())
                && seen.insert(entry.canonical.clone())
            {
                for lib in &config.libs {
                    let lib = if lib.is_absolute() { lib.clone() } else { entry.path.join(lib) };
                    pending.extend(foundry_toml_dir_entries(lib).into_iter().filter(|entry| {
                        !fs::symlink_metadata(&entry.path)
                            .is_ok_and(|metadata| metadata.file_type().is_symlink())
                    }));
                }
            }

            // Custom source directories are not auto-detected. Standard source directories only
            // need synthesis while missing; when present, package-root autodetection preserves
            // imports that include the source directory, such as `forge-std/src/...`.
            let standard_src = [Path::new("src"), Path::new("contracts"), Path::new("lib")];
            if (!standard_src.contains(&config.src.as_path())
                || !entry.canonical.join(&config.src).is_dir())
                && let Some(name) = entry.path.file_name().and_then(|name| name.to_str())
            {
                let mut remapping = Remapping {
                    context: None,
                    name: format!("{name}/"),
                    path: entry.path.join(&config.src).display().to_string(),
                };
                if !remapping.path.ends_with('/') {
                    remapping.path.push('/');
                }
                remappings.push((entry.path, remapping, true));
            }
        }

        Ok(remappings)
    }

    /// Auto detect remappings from the lib paths
    fn auto_detect_remappings(&self) -> impl Iterator<Item = Remapping> + '_ {
        self.lib_paths
            .par_iter()
            .flat_map_iter(|lib| {
                let lib = self.root.join(lib);
                trace!(?lib, "find all remappings");
                Remapping::find_many(&lib)
            })
            .collect::<Vec<_>>()
            .into_iter()
    }
}

fn load_nested_config(root: &Path) -> Result<Option<CachedNestedConfig>, Error> {
    let figment = Config::with_root(root).to_figment(FigmentProviders::Cast);
    let Ok(config) = Config::from_figment_fallback(figment) else { return Ok(None) };
    let src = config.src.clone();
    let libs = config.libs.clone();
    let remappings = config.sanitized().remappings.into_iter().map(Remapping::from).collect();
    let remappings_file = root.join("remappings.txt");
    let file_remappings = if remappings_file.is_file() {
        let content = fs::read_to_string(remappings_file).map_err(|err| err.to_string())?;
        remappings_from_newline(&content)
            .collect::<Result<Vec<_>, _>>()
            .map_err::<Error, _>(|err| err.to_string().into())?
    } else {
        Vec::new()
    };
    Ok(Some(CachedNestedConfig { src, libs, remappings, file_remappings }))
}

fn remapping_name_is_prefix(prefix: &str, name: &str) -> bool {
    let prefix = prefix.trim_end_matches('/');
    let name = name.trim_end_matches('/');
    prefix == name || name.strip_prefix(prefix).is_some_and(|suffix| suffix.starts_with('/'))
}

fn contextual_overlays(
    authoritative: &[Remapping],
    refinement: &Remapping,
) -> Option<Vec<Remapping>> {
    if authoritative.iter().any(|mapping| remapping_name_is_prefix(&mapping.name, &refinement.name))
    {
        return None;
    }
    let mut overlays = authoritative
        .iter()
        .filter(|mapping| remapping_name_is_prefix(&refinement.name, &mapping.name))
        .cloned()
        .collect::<Vec<_>>();
    overlays.sort_by_key(|mapping| Reverse(mapping.name.len()));
    for overlay in &mut overlays {
        overlay.context.clone_from(&refinement.context);
    }
    Some(overlays)
}

fn dependency_owns_path(dependency: &Path, path: &Path) -> bool {
    path.starts_with(dependency)
        || dunce::canonicalize(dependency)
            .ok()
            .zip(dunce::canonicalize(path).ok())
            .is_some_and(|(dependency, path)| path.starts_with(dependency))
}

fn rebase_nested_remapping(
    mut remapping: Remapping,
    canonical: &Path,
    lexical: &Path,
) -> Remapping {
    let rebase = |value: &str| {
        let path = Path::new(value);
        path.strip_prefix(canonical)
            .map(|relative| lexical.join(relative).display().to_string())
            .unwrap_or_else(|_| value.to_string())
    };
    remapping.path = rebase(&remapping.path);
    if let Some(context) = &mut remapping.context {
        let has_boundary = context.ends_with(['/', '\\']);
        *context = rebase(context);
        if has_boundary && !context.ends_with(['/', '\\']) {
            context.push(MAIN_SEPARATOR);
        }
    }
    remapping
}

pub fn relative_remapping_preserving_context_boundary(
    remapping: Remapping,
    root: &Path,
) -> RelativeRemapping {
    let has_boundary =
        remapping.context.as_deref().is_some_and(|context| context.ends_with(['/', '\\']));
    let mut remapping = RelativeRemapping::new(remapping, root);
    // Slash conversion on Windows only preserves a trailing native separator.
    if has_boundary
        && let Some(context) = &mut remapping.context
        && !context.ends_with(MAIN_SEPARATOR)
    {
        if context.ends_with(['/', '\\']) {
            context.pop();
        }
        context.push(MAIN_SEPARATOR);
    }
    remapping
}

impl Provider for RemappingsProvider<'_> {
    fn metadata(&self) -> Metadata {
        Metadata::named("Remapping Provider")
    }

    fn data(&self) -> Result<Map<Profile, Dict>, Error> {
        let output = match &self.remappings {
            Ok(remappings) => self.get_remappings(remappings.clone()),
            Err(err) => {
                if let figment::error::Kind::MissingField(_) = err.kind {
                    self.get_remappings(vec![])
                } else {
                    return Err(err.clone());
                }
            }
        }?;

        // turn the absolute remapping into a relative one by stripping the `root`
        let remappings = output
            .remappings
            .into_iter()
            .map(|r| relative_remapping_preserving_context_boundary(r, self.root).to_string())
            .collect::<Vec<_>>();
        let generated_remappings = output
            .generated_contextual_remappings
            .into_iter()
            .map(|r| relative_remapping_preserving_context_boundary(r, self.root).to_string())
            .collect::<Vec<_>>();

        Ok(Map::from([(
            Config::selected_profile(),
            Dict::from([
                ("remappings".to_string(), figment::value::Value::from(remappings)),
                (
                    GENERATED_REMAPPINGS_KEY.to_string(),
                    figment::value::Value::from(generated_remappings),
                ),
            ]),
        )]))
    }

    fn profile(&self) -> Option<Profile> {
        Some(Config::selected_profile())
    }
}

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

    #[cfg(unix)]
    use std::os::unix::fs::symlink;
    #[cfg(unix)]
    use tempfile::tempdir;

    #[cfg(unix)]
    #[test]
    fn nested_remappings_ignore_symlinks_back_to_the_project_root() {
        let temp = tempdir().unwrap();
        let root = temp.path();
        fs::create_dir(root.join("lib")).unwrap();
        fs::write(root.join(Config::FILE_NAME), "").unwrap();
        symlink(root, root.join("lib/self")).unwrap();
        let libs = vec![PathBuf::from("lib")];
        let provider = RemappingsProvider {
            auto_detect_remappings: true,
            lib_paths: Cow::Borrowed(&libs),
            root,
            remappings: Ok(vec![]),
        };

        assert!(provider.find_nested_foundry_remappings().unwrap().is_empty());
    }

    #[cfg(unix)]
    #[test]
    fn nested_remappings_visit_each_config_once_across_a_dependency_cycle() {
        let temp = tempdir().unwrap();
        let root = temp.path().join("project");
        let dependency = temp.path().join("dependency");
        fs::create_dir_all(root.join("lib")).unwrap();
        fs::create_dir_all(dependency.join("lib")).unwrap();
        fs::create_dir(dependency.join("custom-source")).unwrap();
        fs::write(root.join(Config::FILE_NAME), "").unwrap();
        fs::write(
            dependency.join(Config::FILE_NAME),
            "[profile.default]\nsrc = \"custom-source\"\n",
        )
        .unwrap();
        symlink(&dependency, root.join("lib/dependency-alias")).unwrap();
        symlink(&root, dependency.join("lib/back")).unwrap();
        let libs = vec![PathBuf::from("lib")];
        let provider = RemappingsProvider {
            auto_detect_remappings: true,
            lib_paths: Cow::Borrowed(&libs),
            root: &root,
            remappings: Ok(vec![]),
        };

        assert_eq!(
            provider.find_nested_foundry_remappings().unwrap(),
            vec![(
                root.join("lib/dependency-alias"),
                Remapping {
                    context: None,
                    name: "dependency-alias/".to_string(),
                    path: format!("{}/", root.join("lib/dependency-alias/custom-source").display()),
                },
                true,
            )]
        );
    }

    #[cfg(unix)]
    #[test]
    fn nested_remappings_traverse_physical_dependency_after_symlink_alias() {
        let temp = tempdir().unwrap();
        let root = temp.path().join("project");
        let dependency = root.join("lib/z-dependency");
        let nested = dependency.join("lib/nested");
        fs::create_dir_all(&nested).unwrap();
        fs::create_dir(nested.join("custom-source")).unwrap();
        fs::write(root.join(Config::FILE_NAME), "").unwrap();
        fs::write(dependency.join(Config::FILE_NAME), "[profile.default]\nlibs = [\"lib\"]\n")
            .unwrap();
        fs::write(nested.join(Config::FILE_NAME), "[profile.default]\nsrc = \"custom-source\"\n")
            .unwrap();
        symlink(&dependency, root.join("lib/a-dependency-alias")).unwrap();
        let libs = vec![PathBuf::from("lib")];
        let provider = RemappingsProvider {
            auto_detect_remappings: true,
            lib_paths: Cow::Borrowed(&libs),
            root: &root,
            remappings: Ok(vec![]),
        };

        assert!(provider.find_nested_foundry_remappings().unwrap().contains(&(
            nested.clone(),
            Remapping {
                context: None,
                name: "nested/".to_string(),
                path: format!("{}/", nested.join("custom-source").display()),
            },
            true,
        )));
    }

    #[test]
    fn relative_remapping_preserves_context_directory_boundary() {
        let remapping = Remapping {
            context: Some("lib/outer/".to_string()),
            name: "inner/".to_string(),
            path: format!("lib{MAIN_SEPARATOR}outer{MAIN_SEPARATOR}lib{MAIN_SEPARATOR}inner"),
        };

        let remapping = relative_remapping_preserving_context_boundary(remapping, Path::new("."));
        assert!(remapping.context.as_ref().unwrap().ends_with(MAIN_SEPARATOR));
        assert_eq!(remapping.to_string(), "lib/outer/:inner/=lib/outer/lib/inner/");
    }

    #[test]
    fn config_merge_only_suppresses_generated_refinements() {
        let cli = Remapping {
            context: None,
            name: "pkg/sub/".to_string(),
            path: "src/local/".to_string(),
        };
        let contextual = Remapping {
            context: Some("lib/dep/".to_string()),
            name: "pkg/".to_string(),
            path: "lib/dep/vendor/pkg/".to_string(),
        };
        let mut explicit = Remappings::new_with_remappings(vec![cli.clone()]);
        explicit.extend_with_config_remappings(vec![contextual.clone()], &[]);
        assert_eq!(explicit.into_inner(), vec![cli.clone(), contextual.clone()]);

        let global = Remapping {
            context: None,
            name: "pkg/".to_string(),
            path: "lib/dep/lib/pkg/".to_string(),
        };
        let refinement = Remapping {
            context: Some("lib/dep/".to_string()),
            name: "pkg/".to_string(),
            path: "lib/dep/lib/pkg/contracts/".to_string(),
        };
        let cli_deep = Remapping {
            context: None,
            name: "pkg/sub/deep/".to_string(),
            path: "src/deep/".to_string(),
        };
        let mut generated = Remappings::new_with_remappings(vec![cli.clone(), cli_deep.clone()]);
        generated.extend_with_config_remappings(
            vec![refinement.clone(), global.clone()],
            std::slice::from_ref(&refinement),
        );
        let mut overlay = cli.clone();
        overlay.context.clone_from(&refinement.context);
        let mut deep_overlay = cli_deep.clone();
        deep_overlay.context.clone_from(&refinement.context);
        assert_eq!(
            generated.into_inner(),
            vec![cli.clone(), cli_deep, deep_overlay, overlay, refinement, global.clone()]
        );

        let broad_cli =
            Remapping { context: None, name: "pkg/".to_string(), path: "src/local/".to_string() };
        let mut generated = Remappings::new_with_remappings(vec![broad_cli.clone(), cli.clone()]);
        generated.extend_with_config_remappings(
            vec![contextual.clone(), global],
            std::slice::from_ref(&contextual),
        );
        assert_eq!(generated.into_inner(), vec![broad_cli, cli]);
    }

    #[test]
    fn nested_remapping_groups_are_sorted() {
        let root = tempfile::tempdir().unwrap();
        for dependency in ["zeta", "alpha"] {
            let dependency = root.path().join("lib").join(dependency);
            fs::create_dir_all(&dependency).unwrap();
            fs::write(dependency.join(Config::FILE_NAME), "[profile.default]\n").unwrap();
            fs::write(dependency.join("remappings.txt"), "pkg/=src/\n").unwrap();
        }
        let libs = vec![PathBuf::from("lib")];
        let provider = RemappingsProvider {
            auto_detect_remappings: true,
            lib_paths: Cow::Borrowed(&libs),
            root: root.path(),
            remappings: Ok(Vec::new()),
        };

        let dependencies = provider
            .find_nested_foundry_remappings()
            .unwrap()
            .into_iter()
            .map(|(dependency, _, _)| {
                dependency.file_name().unwrap().to_string_lossy().into_owned()
            })
            .collect::<Vec<_>>();
        assert_eq!(dependencies, ["alpha", "alpha", "zeta", "zeta"]);
    }

    #[test]
    fn test_sol_file_remappings() {
        let mut remappings = Remappings::new();

        // First valid remapping
        remappings.push(Remapping {
            context: None,
            name: "MyContract.sol".to_string(),
            path: "implementations/Contract1.sol".to_string(),
        });

        // Same source to different target (should be rejected)
        remappings.push(Remapping {
            context: None,
            name: "MyContract.sol".to_string(),
            path: "implementations/Contract2.sol".to_string(),
        });

        // Different source to same target (should be allowed)
        remappings.push(Remapping {
            context: None,
            name: "OtherContract.sol".to_string(),
            path: "implementations/Contract1.sol".to_string(),
        });

        // Exact duplicate (should be silently ignored)
        remappings.push(Remapping {
            context: None,
            name: "MyContract.sol".to_string(),
            path: "implementations/Contract1.sol".to_string(),
        });

        // Invalid .sol remapping (target not .sol)
        remappings.push(Remapping {
            context: None,
            name: "Invalid.sol".to_string(),
            path: "implementations/Contract1.txt".to_string(),
        });

        let result = remappings.into_inner();
        assert_eq!(result.len(), 2, "Should only have 2 valid remappings");

        // Verify the correct remappings exist
        assert!(
            result
                .iter()
                .any(|r| r.name == "MyContract.sol" && r.path == "implementations/Contract1.sol"),
            "Should keep first mapping of MyContract.sol"
        );
        assert!(
            !result
                .iter()
                .any(|r| r.name == "MyContract.sol" && r.path == "implementations/Contract2.sol"),
            "Should keep first mapping of MyContract.sol"
        );
        assert!(result.iter().any(|r| r.name == "OtherContract.sol" && r.path == "implementations/Contract1.sol"),
            "Should allow different source to same target");

        // Verify the rejected remapping doesn't exist
        assert!(
            !result
                .iter()
                .any(|r| r.name == "MyContract.sol" && r.path == "implementations/Contract2.sol"),
            "Should reject same source to different target"
        );
    }

    #[test]
    fn test_mixed_remappings() {
        let mut remappings = Remappings::new();

        remappings.push(Remapping {
            context: None,
            name: "@openzeppelin-contracts/".to_string(),
            path: "lib/openzeppelin-contracts/".to_string(),
        });
        remappings.push(Remapping {
            context: None,
            name: "@openzeppelin/contracts/".to_string(),
            path: "lib/openzeppelin/contracts/".to_string(),
        });

        remappings.push(Remapping {
            context: None,
            name: "MyContract.sol".to_string(),
            path: "os/Contract.sol".to_string(),
        });

        let result = remappings.into_inner();
        assert_eq!(result.len(), 3, "Should have 3 remappings");
        assert_eq!(result.first().unwrap().name, "@openzeppelin-contracts/");
        assert_eq!(result.first().unwrap().path, "lib/openzeppelin-contracts/");
        assert_eq!(result.get(1).unwrap().name, "@openzeppelin/contracts/");
        assert_eq!(result.get(1).unwrap().path, "lib/openzeppelin/contracts/");
        assert_eq!(result.get(2).unwrap().name, "MyContract.sol");
        assert_eq!(result.get(2).unwrap().path, "os/Contract.sol");
    }

    #[test]
    fn test_remappings_with_context() {
        let mut remappings = Remappings::new();

        // Same name but different contexts
        remappings.push(Remapping {
            context: Some("test/".to_string()),
            name: "MyContract.sol".to_string(),
            path: "test/Contract.sol".to_string(),
        });
        remappings.push(Remapping {
            context: Some("prod/".to_string()),
            name: "MyContract.sol".to_string(),
            path: "prod/Contract.sol".to_string(),
        });

        let result = remappings.into_inner();
        assert_eq!(result.len(), 2, "Should allow same name with different contexts");
        assert!(
            result
                .iter()
                .any(|r| r.context == Some("test/".to_string()) && r.path == "test/Contract.sol")
        );
        assert!(
            result
                .iter()
                .any(|r| r.context == Some("prod/".to_string()) && r.path == "prod/Contract.sol")
        );
    }

    #[test]
    fn test_root_remapping_prefix_precedence_is_directional() {
        let remapping = |name: &str, path: &str| Remapping {
            context: None,
            name: name.to_string(),
            path: path.to_string(),
        };

        let mut narrow_root =
            Remappings::new_with_remappings(vec![remapping("pkg/sub/", "src/local/")]);
        narrow_root.extend(vec![remapping("pkg/", "lib/pkg/src/")]);
        assert_eq!(
            narrow_root.into_inner(),
            vec![remapping("pkg/sub/", "src/local/"), remapping("pkg/", "lib/pkg/src/")]
        );

        let mut broad_root = Remappings::new_with_remappings(vec![remapping("pkg/", "src/local/")]);
        broad_root.extend(vec![
            remapping("pkg/sub/", "lib/pkg/src/sub/"),
            remapping("pkg-other/", "lib/pkg-other/src/"),
        ]);
        assert_eq!(
            broad_root.into_inner(),
            vec![remapping("pkg/", "src/local/"), remapping("pkg-other/", "lib/pkg-other/src/")]
        );

        let mut duplicate = Remappings::new_with_remappings(vec![remapping("pkg/", "src/local/")]);
        duplicate.extend(vec![remapping("pkg/", "lib/pkg/src/")]);
        assert_eq!(duplicate.remappings, vec![remapping("pkg/", "src/local/")]);

        let contextual_remapping = |context: &str, name: &str, path: &str| Remapping {
            context: Some(context.to_string()),
            name: name.to_string(),
            path: path.to_string(),
        };
        let mut same_context = Remappings::new_with_remappings(vec![contextual_remapping(
            "src/",
            "pkg/",
            "src/local/",
        )]);
        same_context.extend(vec![contextual_remapping("src/", "pkg/sub/", "lib/pkg/src/sub/")]);
        assert_eq!(
            same_context.remappings,
            vec![contextual_remapping("src/", "pkg/", "src/local/")]
        );

        let mut different_context = Remappings::new_with_remappings(vec![contextual_remapping(
            "src/",
            "pkg/",
            "src/local/",
        )]);
        different_context.extend(vec![contextual_remapping(
            "test/",
            "pkg/sub/",
            "lib/pkg/src/sub/",
        )]);
        assert_eq!(
            different_context.remappings,
            vec![
                contextual_remapping("src/", "pkg/", "src/local/"),
                contextual_remapping("test/", "pkg/sub/", "lib/pkg/src/sub/"),
            ]
        );

        let mut narrow_root_without_slash =
            Remappings::new_with_remappings(vec![remapping("pkg/sub", "src/local/")]);
        narrow_root_without_slash.extend(vec![remapping("pkg", "lib/pkg/src/")]);
        assert_eq!(
            narrow_root_without_slash.remappings,
            vec![remapping("pkg/sub", "src/local/"), remapping("pkg", "lib/pkg/src/")]
        );

        let mut broad_root_without_slash =
            Remappings::new_with_remappings(vec![remapping("pkg", "src/local/")]);
        broad_root_without_slash.extend(vec![
            remapping("pkg/sub", "lib/pkg/src/sub/"),
            remapping("pkg-other", "lib/pkg-other/src/"),
        ]);
        assert_eq!(
            broad_root_without_slash.remappings,
            vec![remapping("pkg", "src/local/"), remapping("pkg-other", "lib/pkg-other/src/")]
        );
    }
}
