use super::types::RepoLocation;
use super::types::RepoMapping;
use crate::config::validation::canonical_reference_instance_key;
use crate::config::validation::canonical_reference_key;
use crate::config::validation::normalize_encoded_ref_key_for_identity;
use crate::git::ref_key::encode_ref_key;
use crate::repo_identity::RepoIdentity;
use crate::repo_identity::RepoIdentityKey;
use crate::repo_identity::parse_url_and_subpath as identity_parse_url_and_subpath;
use crate::utils::locks::FileLock;
use crate::utils::paths::sanitize_dir_name;
use crate::utils::paths::{self};
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use atomicwrites::AllowOverwrite;
use atomicwrites::AtomicFile;
use std::io::ErrorKind;
use std::io::Write;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;

const REFERENCE_MAPPING_MARKER: &str = "#thoughts-ref=";

/// Indicates how a URL was resolved to a mapping.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UrlResolutionKind {
    /// The URL matched exactly as stored in repos.json
    Exact,
    /// The URL matched via canonical identity comparison (different scheme/format)
    CanonicalFallback,
}

/// Details about a resolved URL mapping.
#[derive(Debug, Clone)]
pub struct ResolvedUrl {
    /// The key in repos.json that matched
    pub matched_url: String,
    /// How the match was found
    pub resolution: UrlResolutionKind,
    /// The location details (cloned)
    pub location: RepoLocation,
}

pub struct RepoMappingManager {
    mapping_path: PathBuf,
}

/// Compute the lock path for a given mapping file path.
///
/// This is extracted as a standalone function so it can be used both in
/// `RepoMappingManager::lock_path()` and `migrate_legacy_repos_json_if_needed()`.
fn lock_path_for_mapping_path(mapping_path: &Path) -> PathBuf {
    let name = mapping_path
        .file_name()
        .unwrap_or_default()
        .to_string_lossy();
    mapping_path.with_file_name(format!("{name}.lock"))
}

/// Migrate legacy repos.json to the new location, if needed.
///
/// Returns `Ok(true)` if migration was performed, `Ok(false)` otherwise.
///
/// # Safety Properties
///
/// - Acquires the same lock used by all RMW operations.
/// - Re-checks `mapping_path.exists()` under lock to close the TOCTOU window.
/// - Writes via `AtomicFile` for atomic visibility.
fn migrate_legacy_repos_json_if_needed(mapping_path: &Path, legacy_path: &Path) -> Result<bool> {
    let _lock = FileLock::lock_exclusive(lock_path_for_mapping_path(mapping_path))?;

    // Re-check under lock to close the TOCTOU window.
    if mapping_path.exists() || !legacy_path.exists() {
        return Ok(false);
    }

    if let Some(parent) = mapping_path.parent() {
        paths::ensure_dir(parent)?;
    }

    let bytes = std::fs::read(legacy_path).with_context(|| {
        format!(
            "Failed to read legacy repos.json at {}",
            legacy_path.display()
        )
    })?;

    let af = AtomicFile::new(mapping_path, AllowOverwrite);
    af.write(|f| f.write_all(&bytes))
        .context("Failed to migrate repos.json from legacy location")?;

    tracing::info!(
        "Migrated repos.json from {} to {}",
        legacy_path.display(),
        mapping_path.display()
    );

    Ok(true)
}

impl RepoMappingManager {
    pub fn new() -> Result<Self> {
        let mapping_path = paths::get_repo_mapping_path()?;

        // Check for legacy location migration (fast-path guard), then migrate under lock.
        if !mapping_path.exists()
            && let Ok(legacy_path) = paths::get_legacy_repo_mapping_path()
            && legacy_path.exists()
        {
            // Performs the lock + recheck + atomic write.
            let _ = migrate_legacy_repos_json_if_needed(&mapping_path, &legacy_path)?;
        }

        Ok(Self { mapping_path })
    }

    /// Get the lock file path for repos.json RMW operations.
    fn lock_path(&self) -> PathBuf {
        lock_path_for_mapping_path(&self.mapping_path)
    }

    pub fn load(&self) -> Result<RepoMapping> {
        let contents = match std::fs::read_to_string(&self.mapping_path) {
            Ok(c) => c,
            Err(e) if e.kind() == ErrorKind::NotFound => {
                // Pure read: do not create the file here.
                return Ok(RepoMapping::default());
            }
            Err(e) => {
                return Err(e).context("Failed to read repository mapping file");
            }
        };

        serde_json::from_str(&contents).context("Failed to parse repository mapping")
    }

    pub fn save(&self, mapping: &RepoMapping) -> Result<()> {
        // Ensure directory exists
        if let Some(parent) = self.mapping_path.parent() {
            paths::ensure_dir(parent)?;
        }

        // Atomic write for safety
        let json = serde_json::to_string_pretty(mapping)?;
        let af = AtomicFile::new(&self.mapping_path, AllowOverwrite);
        af.write(|f| f.write_all(json.as_bytes()))?;

        Ok(())
    }

    /// Load the mapping while holding an exclusive lock.
    ///
    /// Returns the mapping and the lock guard. The lock is released when the
    /// guard is dropped, so callers should hold it until after `save()`.
    ///
    /// Use this for read-modify-write operations to prevent concurrent updates
    /// from losing changes.
    pub fn load_locked(&self) -> Result<(RepoMapping, FileLock)> {
        let lock = FileLock::lock_exclusive(self.lock_path())?;
        let mapping = self.load()?;
        Ok((mapping, lock))
    }

    /// Resolve a git URL with detailed resolution information.
    ///
    /// Returns the matched URL key, resolution kind, location, and optional subpath.
    pub fn resolve_url_with_details(
        &self,
        url: &str,
    ) -> Result<Option<(ResolvedUrl, Option<String>)>> {
        let mapping = self.load()?; // read-only; atomic writes make this safe
        let (base_url, subpath) = parse_url_and_subpath(url);

        // Try exact match first
        if let Some(loc) = mapping.mappings.get(&base_url) {
            return Ok(Some((
                ResolvedUrl {
                    matched_url: base_url,
                    resolution: UrlResolutionKind::Exact,
                    location: loc.clone(),
                },
                subpath,
            )));
        }

        // Canonical fallback: parse target URL and find a matching key
        let target_key = match RepoIdentity::parse(&base_url) {
            Ok(id) => id.canonical_key(),
            Err(_) => return Ok(None),
        };

        let mut matches: Vec<(String, RepoLocation)> = mapping
            .mappings
            .iter()
            .filter_map(|(k, v)| {
                let (k_base, _) = parse_url_and_subpath(k);
                let key = RepoIdentity::parse(&k_base).ok()?.canonical_key();
                (key == target_key).then(|| (k.clone(), v.clone()))
            })
            .collect();

        // Sort for deterministic selection
        matches.sort_by(|a, b| a.0.cmp(&b.0));

        if let Some((matched_url, location)) = matches.into_iter().next() {
            return Ok(Some((
                ResolvedUrl {
                    matched_url,
                    resolution: UrlResolutionKind::CanonicalFallback,
                    location,
                },
                subpath,
            )));
        }

        Ok(None)
    }

    /// Resolve a git URL to its local path.
    ///
    /// Uses exact match first, then falls back to canonical identity matching
    /// to handle URL scheme variants (SSH vs HTTPS).
    pub fn resolve_url(&self, url: &str) -> Result<Option<PathBuf>> {
        if let Some((resolved, subpath)) = self.resolve_url_with_details(url)? {
            let mut p = resolved.location.path.clone();
            if let Some(ref sub) = subpath {
                validate_subpath(sub)?;
                p = p.join(sub);
            }
            return Ok(Some(p));
        }
        Ok(None)
    }

    pub fn resolve_reference_url(
        &self,
        url: &str,
        ref_name: Option<&str>,
    ) -> Result<Option<PathBuf>> {
        let mapping = self.load()?;
        let (_, subpath) = parse_url_and_subpath(url);

        let matches = Self::matching_reference_storage_keys(&mapping, url, ref_name)?;

        if let Some(stored_key) = matches.first()
            && let Some(location) = mapping.mappings.get(stored_key)
        {
            let mut p = location.path.clone();
            if let Some(ref sub) = subpath {
                validate_subpath(sub)?;
                p = p.join(sub);
            }
            return Ok(Some(p));
        }

        Ok(None)
    }

    /// Add a URL-to-path mapping with identity-based upsert.
    ///
    /// If a mapping with the same canonical identity already exists,
    /// it will be replaced (preserving any existing last_sync time).
    /// This prevents duplicate entries for SSH vs HTTPS variants.
    pub fn add_mapping(&mut self, url: String, path: PathBuf, auto_managed: bool) -> Result<()> {
        let _lock = FileLock::lock_exclusive(self.lock_path())?;
        let mut mapping = self.load()?; // safe under lock for RMW

        // Basic validation
        if !path.exists() {
            bail!("Path does not exist: {}", path.display());
        }

        if !path.is_dir() {
            bail!("Path is not a directory: {}", path.display());
        }

        let (base_url, _) = parse_url_and_subpath(&url);
        let new_key = RepoIdentity::parse(&base_url)?.canonical_key();

        // Find all existing entries with the same canonical identity
        let matching_urls: Vec<String> = mapping
            .mappings
            .keys()
            .filter_map(|k| {
                let (k_base, _) = parse_url_and_subpath(k);
                let key = RepoIdentity::parse(&k_base).ok()?.canonical_key();
                (key == new_key).then(|| k.clone())
            })
            .collect();

        // Preserve last_sync from any existing entry
        let preserved_last_sync = matching_urls
            .iter()
            .filter_map(|k| mapping.mappings.get(k).and_then(|loc| loc.last_sync))
            .max();

        // Remove all matching entries
        for k in matching_urls {
            mapping.mappings.remove(&k);
        }

        // Insert the new mapping
        mapping.mappings.insert(
            base_url,
            RepoLocation {
                path,
                auto_managed,
                last_sync: preserved_last_sync,
            },
        );

        self.save(&mapping)?;
        Ok(())
    }

    pub fn add_reference_mapping(
        &mut self,
        url: String,
        ref_name: Option<&str>,
        path: PathBuf,
        auto_managed: bool,
    ) -> Result<()> {
        let _lock = FileLock::lock_exclusive(self.lock_path())?;
        let mut mapping = self.load()?;

        if !path.exists() {
            bail!("Path does not exist: {}", path.display());
        }

        if !path.is_dir() {
            bail!("Path is not a directory: {}", path.display());
        }

        let storage_key = reference_mapping_storage_key(&url, ref_name)?;
        let matching_urls = Self::matching_reference_storage_keys(&mapping, &url, ref_name)?;

        let preserved_last_sync = matching_urls
            .iter()
            .filter_map(|k| mapping.mappings.get(k).and_then(|loc| loc.last_sync))
            .max();

        for k in matching_urls {
            mapping.mappings.remove(&k);
        }

        mapping.mappings.insert(
            storage_key,
            RepoLocation {
                path,
                auto_managed,
                last_sync: preserved_last_sync,
            },
        );

        self.save(&mapping)?;
        Ok(())
    }

    /// Remove a URL mapping
    #[allow(dead_code)]
    // TODO(2): Add "thoughts mount unmap" command for cleanup
    pub fn remove_mapping(&mut self, url: &str) -> Result<()> {
        let _lock = FileLock::lock_exclusive(self.lock_path())?;
        let mut mapping = self.load()?;
        mapping.mappings.remove(url);
        self.save(&mapping)?;
        Ok(())
    }

    /// Check if a URL is auto-managed
    pub fn is_auto_managed(&self, url: &str) -> Result<bool> {
        let mapping = self.load()?;
        Ok(mapping
            .mappings
            .get(url)
            .map(|loc| loc.auto_managed)
            .unwrap_or(false))
    }

    pub fn is_reference_auto_managed(&self, url: &str, ref_name: Option<&str>) -> Result<bool> {
        let mapping = self.load()?;
        let keys = Self::matching_reference_storage_keys(&mapping, url, ref_name)?;
        Ok(keys
            .first()
            .and_then(|key| mapping.mappings.get(key))
            .map(|loc| loc.auto_managed)
            .unwrap_or(false))
    }

    /// Get default clone path for a URL using hierarchical layout.
    ///
    /// Returns `~/.thoughts/clones/{host}/{org_path}/{repo}` with sanitized directory names.
    pub fn get_default_clone_path(url: &str) -> Result<PathBuf> {
        let home = dirs::home_dir()
            .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;

        let (base_url, _sub) = parse_url_and_subpath(url);
        let id = RepoIdentity::parse(&base_url)?;
        let key = id.canonical_key(); // use canonical for stable paths across case/scheme

        let mut p = home
            .join(".thoughts")
            .join("clones")
            .join(sanitize_dir_name(&key.host));
        for seg in key.org_path.split('/') {
            if !seg.is_empty() {
                p = p.join(sanitize_dir_name(seg));
            }
        }
        p = p.join(sanitize_dir_name(&key.repo));
        Ok(p)
    }

    pub fn get_default_reference_clone_path(url: &str, ref_name: Option<&str>) -> Result<PathBuf> {
        let mut path = Self::get_default_clone_path(url)?;
        if let Some(ref_name) = ref_name {
            let ref_key = encode_ref_key(ref_name)?;
            let repo_dir = path
                .file_name()
                .ok_or_else(|| anyhow::anyhow!("Default clone path had no repository segment"))?
                .to_string_lossy()
                .to_string();
            path.set_file_name(format!("{}@{}", sanitize_dir_name(&repo_dir), ref_key));
        }
        Ok(path)
    }

    /// Update last sync time for a URL.
    ///
    /// Uses canonical fallback to update the correct entry even if the URL
    /// scheme differs from what's stored.
    pub fn update_sync_time(&mut self, url: &str) -> Result<()> {
        let _lock = FileLock::lock_exclusive(self.lock_path())?;
        let mut mapping = self.load()?;
        let now = chrono::Utc::now();

        // Prefer exact base_url key
        let (base_url, _) = parse_url_and_subpath(url);
        if let Some(loc) = mapping.mappings.get_mut(&base_url) {
            loc.last_sync = Some(now);
            self.save(&mapping)?;
            return Ok(());
        }

        // Fall back to canonical match
        // Note: We need to find the matching key without holding a mutable borrow
        let target_key = RepoIdentity::parse(&base_url)?.canonical_key();

        // TODO(2): If repos.json contains multiple entries with the same canonical identity (legacy
        // duplicates), the selection below is nondeterministic due to HashMap iteration order.
        // Consider sorting (as in `resolve_url_with_details`) or updating all matches.
        let matched_key: Option<String> = mapping
            .mappings
            .keys()
            .filter_map(|k| {
                let (k_base, _) = parse_url_and_subpath(k);
                let key = RepoIdentity::parse(&k_base).ok()?.canonical_key();
                (key == target_key).then(|| k.clone())
            })
            .next();

        if let Some(key) = matched_key
            && let Some(loc) = mapping.mappings.get_mut(&key)
        {
            loc.last_sync = Some(now);
            self.save(&mapping)?;
        }

        Ok(())
    }

    pub fn update_reference_sync_time(&mut self, url: &str, ref_name: Option<&str>) -> Result<()> {
        let _lock = FileLock::lock_exclusive(self.lock_path())?;
        let mut mapping = self.load()?;
        let now = chrono::Utc::now();

        let keys = Self::matching_reference_storage_keys(&mapping, url, ref_name)?;
        if let Some(key) = keys.first()
            && let Some(loc) = mapping.mappings.get_mut(key)
        {
            loc.last_sync = Some(now);
            self.save(&mapping)?;
        }

        Ok(())
    }

    /// Get the canonical identity key for a URL, if parseable.
    pub fn get_canonical_key(url: &str) -> Option<RepoIdentityKey> {
        let (base, _) = parse_url_and_subpath(url);
        RepoIdentity::parse(&base).ok().map(|id| id.canonical_key())
    }

    fn matching_reference_storage_keys(
        mapping: &RepoMapping,
        url: &str,
        ref_name: Option<&str>,
    ) -> Result<Vec<String>> {
        let wanted = canonical_reference_instance_key(url, ref_name)?;
        let mut keys: Vec<String> = mapping
            .mappings
            .keys()
            .filter_map(|stored_key| {
                let (stored_url, stored_ref_key) = parse_reference_mapping_storage_key(stored_key);
                let (host, org_path, repo) = canonical_reference_key(&stored_url).ok()?;
                let normalized_stored_ref_key = stored_ref_key
                    .as_deref()
                    .map(|ref_key| normalize_encoded_ref_key_for_identity(ref_key).into_owned());
                let actual = (host, org_path, repo, normalized_stored_ref_key);
                (actual == wanted).then(|| stored_key.clone())
            })
            .collect();
        keys.sort();
        Ok(keys)
    }
}

fn reference_mapping_storage_key(url: &str, ref_name: Option<&str>) -> Result<String> {
    let (base_url, _) = parse_url_and_subpath(url);
    match ref_name {
        Some(ref_name) => Ok(format!(
            "{}{REFERENCE_MAPPING_MARKER}{}",
            base_url,
            encode_ref_key(ref_name)?
        )),
        None => Ok(base_url),
    }
}

pub fn parse_reference_mapping_storage_key(stored_key: &str) -> (String, Option<String>) {
    match stored_key.split_once(REFERENCE_MAPPING_MARKER) {
        Some((base_url, ref_key)) => (base_url.to_string(), Some(ref_key.to_string())),
        None => (stored_key.to_string(), None),
    }
}

/// Parse a URL into (base_url, optional_subpath).
///
/// Delegates to the repo_identity module for robust port-aware parsing.
pub fn parse_url_and_subpath(url: &str) -> (String, Option<String>) {
    identity_parse_url_and_subpath(url)
}

/// Validate a subpath to prevent directory traversal attacks.
///
/// Rejects absolute paths and paths containing ".." components that could
/// escape the repository root directory.
fn validate_subpath(subpath: &str) -> Result<()> {
    let path = Path::new(subpath);
    if path.is_absolute() {
        bail!(
            "Invalid subpath (must be relative and not contain '..'): {}",
            subpath
        );
    }
    for component in path.components() {
        match component {
            Component::ParentDir => {
                bail!(
                    "Invalid subpath (must be relative and not contain '..'): {}",
                    subpath
                );
            }
            Component::Prefix(_) => {
                bail!(
                    "Invalid subpath (must be relative and not contain '..'): {}",
                    subpath
                );
            }
            _ => {}
        }
    }
    Ok(())
}

pub fn extract_repo_name_from_url(url: &str) -> Result<String> {
    let url = url.trim_end_matches(".git");

    // Handle different URL formats
    if let Some(pos) = url.rfind('/') {
        Ok(url[pos + 1..].to_string())
    } else if let Some(pos) = url.rfind(':') {
        // SSH format like git@github.com:user/repo
        if let Some(slash_pos) = url[pos + 1..].rfind('/') {
            Ok(url[pos + 1 + slash_pos + 1..].to_string())
        } else {
            Ok(url[pos + 1..].to_string())
        }
    } else {
        bail!("Cannot extract repository name from URL: {}", url)
    }
}

/// Extract org_path and repo from a URL.
///
/// Delegates to RepoIdentity for robust parsing that handles:
/// - SSH with ports: `ssh://git@host:2222/org/repo.git`
/// - GitLab subgroups: `https://gitlab.com/a/b/c/repo.git`
/// - Azure DevOps: `https://dev.azure.com/org/proj/_git/repo`
pub fn extract_org_repo_from_url(url: &str) -> anyhow::Result<(String, String)> {
    let (base, _) = parse_url_and_subpath(url);
    let id = RepoIdentity::parse(&base)?;
    Ok((id.org_path, id.repo))
}

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

    #[test]
    fn test_parse_url_and_subpath() {
        let (url, sub) = parse_url_and_subpath("git@github.com:user/repo.git");
        assert_eq!(url, "git@github.com:user/repo.git");
        assert_eq!(sub, None);

        let (url, sub) = parse_url_and_subpath("git@github.com:user/repo.git:docs/api");
        assert_eq!(url, "git@github.com:user/repo.git");
        assert_eq!(sub, Some("docs/api".to_string()));

        let (url, sub) = parse_url_and_subpath("https://github.com/user/repo");
        assert_eq!(url, "https://github.com/user/repo");
        assert_eq!(sub, None);
    }

    #[test]
    fn test_extract_repo_name() {
        assert_eq!(
            extract_repo_name_from_url("git@github.com:user/repo.git").unwrap(),
            "repo"
        );
        assert_eq!(
            extract_repo_name_from_url("https://github.com/user/repo").unwrap(),
            "repo"
        );
        assert_eq!(
            extract_repo_name_from_url("git@github.com:user/repo").unwrap(),
            "repo"
        );
    }

    #[test]
    fn test_extract_org_repo() {
        assert_eq!(
            extract_org_repo_from_url("git@github.com:user/repo.git").unwrap(),
            ("user".to_string(), "repo".to_string())
        );
        assert_eq!(
            extract_org_repo_from_url("https://github.com/user/repo").unwrap(),
            ("user".to_string(), "repo".to_string())
        );
        assert_eq!(
            extract_org_repo_from_url("git@github.com:user/repo").unwrap(),
            ("user".to_string(), "repo".to_string())
        );
        assert_eq!(
            extract_org_repo_from_url("https://github.com/modelcontextprotocol/rust-sdk.git")
                .unwrap(),
            ("modelcontextprotocol".to_string(), "rust-sdk".to_string())
        );
    }

    #[test]
    fn test_default_clone_path_hierarchical() {
        // Test hierarchical path: ~/.thoughts/clones/{host}/{org}/{repo}
        let p =
            RepoMappingManager::get_default_clone_path("git@github.com:org/repo.git:docs").unwrap();
        assert!(p.ends_with(std::path::Path::new(".thoughts/clones/github.com/org/repo")));
    }

    #[test]
    fn test_default_clone_path_gitlab_subgroups() {
        let p = RepoMappingManager::get_default_clone_path(
            "https://gitlab.com/group/subgroup/team/repo.git",
        )
        .unwrap();
        assert!(p.ends_with(std::path::Path::new(
            ".thoughts/clones/gitlab.com/group/subgroup/team/repo"
        )));
    }

    #[test]
    fn test_default_clone_path_ssh_port() {
        let p = RepoMappingManager::get_default_clone_path(
            "ssh://git@myhost.example.com:2222/org/repo.git",
        )
        .unwrap();
        assert!(p.ends_with(std::path::Path::new(
            ".thoughts/clones/myhost.example.com/org/repo"
        )));
    }

    #[test]
    fn test_default_reference_clone_path_appends_ref_key() {
        let p = RepoMappingManager::get_default_reference_clone_path(
            "https://github.com/org/repo.git",
            Some("refs/tags/v1.2.3"),
        )
        .unwrap();
        assert!(p.ends_with(std::path::Path::new(
            ".thoughts/clones/github.com/org/repo@r-refs~2ftags~2fv1.2.3"
        )));
    }

    #[test]
    fn test_canonical_key_consistency() {
        let ssh_key = RepoMappingManager::get_canonical_key("git@github.com:Org/Repo.git").unwrap();
        let https_key =
            RepoMappingManager::get_canonical_key("https://github.com/org/repo").unwrap();
        assert_eq!(
            ssh_key, https_key,
            "SSH and HTTPS should have same canonical key"
        );
    }

    #[test]
    fn test_add_reference_mapping_keeps_different_refs_separate() {
        let temp_dir = TempDir::new().unwrap();
        let mapping_path = temp_dir.path().join("repos.json");
        let mut manager = RepoMappingManager { mapping_path };

        let main_path = temp_dir.path().join("repo-main");
        let tag_path = temp_dir.path().join("repo-tag");
        std::fs::create_dir_all(&main_path).unwrap();
        std::fs::create_dir_all(&tag_path).unwrap();

        manager
            .add_reference_mapping(
                "https://github.com/org/repo.git".to_string(),
                Some("refs/heads/main"),
                main_path.clone(),
                true,
            )
            .unwrap();
        manager
            .add_reference_mapping(
                "git@github.com:Org/Repo.git".to_string(),
                Some("refs/tags/v1.0.0"),
                tag_path.clone(),
                true,
            )
            .unwrap();

        assert_eq!(
            manager
                .resolve_reference_url("https://github.com/org/repo", Some("refs/heads/main"))
                .unwrap(),
            Some(main_path)
        );
        assert_eq!(
            manager
                .resolve_reference_url("https://github.com/org/repo", Some("refs/tags/v1.0.0"))
                .unwrap(),
            Some(tag_path)
        );

        let mapping = manager.load().unwrap();
        assert_eq!(mapping.mappings.len(), 2);
    }

    #[test]
    fn test_resolve_reference_url_matches_legacy_refs_remotes_and_heads_equivalently() {
        let temp_dir = TempDir::new().unwrap();
        let mapping_path = temp_dir.path().join("repos.json");
        let mut manager = RepoMappingManager {
            mapping_path: mapping_path.clone(),
        };

        let repo_path = temp_dir.path().join("repo-legacy");
        std::fs::create_dir_all(&repo_path).unwrap();

        manager
            .add_reference_mapping(
                "https://github.com/org/repo.git".to_string(),
                Some("refs/remotes/origin/main"),
                repo_path.clone(),
                true,
            )
            .unwrap();

        let mgr_ro = RepoMappingManager { mapping_path };
        assert_eq!(
            mgr_ro
                .resolve_reference_url("https://github.com/org/repo", Some("refs/heads/main"))
                .unwrap(),
            Some(repo_path)
        );
    }

    #[test]
    fn test_update_reference_sync_time_updates_ref_specific_entry() {
        let temp_dir = TempDir::new().unwrap();
        let mapping_path = temp_dir.path().join("repos.json");
        let mut manager = RepoMappingManager { mapping_path };

        let repo_path = temp_dir.path().join("repo-main");
        std::fs::create_dir_all(&repo_path).unwrap();

        manager
            .add_reference_mapping(
                "https://github.com/org/repo.git".to_string(),
                Some("refs/heads/main"),
                repo_path,
                true,
            )
            .unwrap();

        manager
            .update_reference_sync_time("git@github.com:Org/Repo.git", Some("refs/heads/main"))
            .unwrap();

        let mapping = manager.load().unwrap();
        let found = mapping
            .mappings
            .iter()
            .find(|(key, _)| key.contains("#thoughts-ref="))
            .unwrap();
        assert!(found.1.last_sync.is_some());
    }

    #[test]
    fn test_is_reference_auto_managed_matches_ref_specific_entry() {
        let temp_dir = TempDir::new().unwrap();
        let mapping_path = temp_dir.path().join("repos.json");
        let mut manager = RepoMappingManager {
            mapping_path: mapping_path.clone(),
        };

        let repo_path = temp_dir.path().join("repo-main");
        std::fs::create_dir_all(&repo_path).unwrap();

        manager
            .add_reference_mapping(
                "https://github.com/org/repo.git".to_string(),
                Some("refs/heads/main"),
                repo_path,
                true,
            )
            .unwrap();

        let mgr_ro = RepoMappingManager { mapping_path };
        assert!(
            mgr_ro
                .is_reference_auto_managed("git@github.com:Org/Repo.git", Some("refs/heads/main"))
                .unwrap()
        );
    }

    // TODO(2): Add integration test for resolve_url_with_details canonical fallback path.
    // This test verifies keys match, but doesn't test that resolve_url_with_details
    // actually uses canonical matching to find mappings stored under different URL schemes.
    // Test should: 1) add mapping with SSH URL, 2) resolve with HTTPS URL,
    // 3) verify CanonicalFallback resolution kind is returned.

    #[test]
    fn test_validate_subpath_accepts_valid_paths() {
        // Simple relative paths should be accepted
        assert!(validate_subpath("docs").is_ok());
        assert!(validate_subpath("docs/api").is_ok());
        assert!(validate_subpath("src/lib/utils").is_ok());
        assert!(validate_subpath("a/b/c/d/e").is_ok());
    }

    #[test]
    fn test_validate_subpath_rejects_parent_dir_traversal() {
        // Parent directory traversal should be rejected
        assert!(validate_subpath("..").is_err());
        assert!(validate_subpath("../etc").is_err());
        assert!(validate_subpath("docs/../..").is_err());
        assert!(validate_subpath("docs/../../etc").is_err());
        assert!(validate_subpath("a/b/c/../../../etc").is_err());
    }

    #[test]
    fn test_validate_subpath_rejects_absolute_paths() {
        // Absolute paths should be rejected
        assert!(validate_subpath("/etc").is_err());
        assert!(validate_subpath("/etc/passwd").is_err());
        assert!(validate_subpath("/home/user/.ssh").is_err());
    }

    // -------------------------------------------------------------------------
    // Migration and load() TOCTOU-fix tests
    // -------------------------------------------------------------------------

    #[test]
    fn test_migrate_legacy_when_destination_missing() {
        let dir = TempDir::new().unwrap();
        let mapping_path = dir.path().join("repos.json");
        let legacy_path = dir.path().join("legacy_repos.json");

        let legacy_bytes = br#"{ "mappings": { "git@github.com:a/b.git": { "path": "/tmp/x", "auto_managed": false, "last_sync": null } } }"#;
        std::fs::write(&legacy_path, legacy_bytes).unwrap();

        let migrated = migrate_legacy_repos_json_if_needed(&mapping_path, &legacy_path).unwrap();
        assert!(migrated);
        assert!(mapping_path.exists());

        let got = std::fs::read(&mapping_path).unwrap();
        assert_eq!(got, legacy_bytes);
    }

    #[test]
    fn test_migrate_does_not_overwrite_existing_destination() {
        let dir = TempDir::new().unwrap();
        let mapping_path = dir.path().join("repos.json");
        let legacy_path = dir.path().join("legacy_repos.json");

        std::fs::write(&legacy_path, b"legacy").unwrap();
        std::fs::write(&mapping_path, b"already-there").unwrap();

        let migrated = migrate_legacy_repos_json_if_needed(&mapping_path, &legacy_path).unwrap();
        assert!(!migrated);

        let got = std::fs::read(&mapping_path).unwrap();
        assert_eq!(got, b"already-there");
    }

    #[test]
    fn test_migrate_noop_when_legacy_missing() {
        let dir = TempDir::new().unwrap();
        let mapping_path = dir.path().join("repos.json");
        let legacy_path = dir.path().join("legacy_repos.json"); // intentionally absent

        let migrated = migrate_legacy_repos_json_if_needed(&mapping_path, &legacy_path).unwrap();
        assert!(!migrated);
        assert!(!mapping_path.exists());
    }

    #[test]
    fn test_load_missing_file_returns_default_without_creating_file() {
        let dir = TempDir::new().unwrap();
        let mapping_path = dir.path().join("repos.json");
        let mgr = RepoMappingManager {
            mapping_path: mapping_path.clone(),
        };

        let mapping = mgr.load().unwrap();
        assert_eq!(mapping, RepoMapping::default());
        assert!(!mapping_path.exists(), "load() must not create repos.json");
    }

    #[test]
    fn test_load_reads_existing_file() {
        let dir = TempDir::new().unwrap();
        let mapping_path = dir.path().join("repos.json");
        let mgr = RepoMappingManager {
            mapping_path: mapping_path.clone(),
        };

        let mut m = RepoMapping::default();
        m.mappings.insert(
            "git@github.com:org/repo.git".to_string(),
            RepoLocation {
                path: PathBuf::from("/tmp/repo"),
                auto_managed: false,
                last_sync: None,
            },
        );
        mgr.save(&m).unwrap();

        let loaded = mgr.load().unwrap();
        assert!(loaded.mappings.contains_key("git@github.com:org/repo.git"));
    }
}
