use std::fs;
use std::path::{Path, PathBuf};

use regex::Regex;

use crate::error::ReleaseError;

/// Trait encapsulating detection, bumping, workspace discovery, and lock file
/// association for a single ecosystem (Cargo, npm, Python, etc.).
pub trait VersionFileHandler: Send + Sync {
    /// Human-readable name, e.g. "Cargo", "npm".
    fn name(&self) -> &str;

    /// Primary manifest filenames, e.g. `["Cargo.toml"]`.
    fn manifest_names(&self) -> &[&str];

    /// Associated lock file names, e.g. `["Cargo.lock"]`.
    fn lock_file_names(&self) -> &[&str];

    /// Does this ecosystem exist in `dir`? Default: any manifest file exists.
    fn detect(&self, dir: &Path) -> bool {
        self.manifest_names()
            .iter()
            .any(|name| dir.join(name).exists())
    }

    /// Bump version in the manifest at `path`. Returns additional files that
    /// were auto-discovered and bumped (e.g. workspace members).
    fn bump(&self, path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError>;
}

// ---------------------------------------------------------------------------
// Handler implementations
// ---------------------------------------------------------------------------

struct CargoHandler;

impl VersionFileHandler for CargoHandler {
    fn name(&self) -> &str {
        "Cargo"
    }
    fn manifest_names(&self) -> &[&str] {
        &["Cargo.toml"]
    }
    fn lock_file_names(&self) -> &[&str] {
        &["Cargo.lock"]
    }
    fn bump(&self, path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
        bump_cargo_toml(path, new_version)
    }
}

struct NpmHandler;

impl VersionFileHandler for NpmHandler {
    fn name(&self) -> &str {
        "npm"
    }
    fn manifest_names(&self) -> &[&str] {
        &["package.json"]
    }
    fn lock_file_names(&self) -> &[&str] {
        &["package-lock.json", "yarn.lock", "pnpm-lock.yaml"]
    }
    fn bump(&self, path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
        bump_package_json(path, new_version)
    }
}

struct PyprojectHandler;

impl VersionFileHandler for PyprojectHandler {
    fn name(&self) -> &str {
        "Python"
    }
    fn manifest_names(&self) -> &[&str] {
        &["pyproject.toml"]
    }
    fn lock_file_names(&self) -> &[&str] {
        &["uv.lock", "poetry.lock"]
    }
    fn bump(&self, path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
        bump_pyproject_toml(path, new_version)
    }
}

struct MavenHandler;

impl VersionFileHandler for MavenHandler {
    fn name(&self) -> &str {
        "Maven"
    }
    fn manifest_names(&self) -> &[&str] {
        &["pom.xml"]
    }
    fn lock_file_names(&self) -> &[&str] {
        &[]
    }
    fn bump(&self, path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
        bump_pom_xml(path, new_version).map(|()| vec![])
    }
}

struct GradleHandler;

impl VersionFileHandler for GradleHandler {
    fn name(&self) -> &str {
        "Gradle"
    }
    fn manifest_names(&self) -> &[&str] {
        &["build.gradle", "build.gradle.kts"]
    }
    fn lock_file_names(&self) -> &[&str] {
        &[]
    }
    fn bump(&self, path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
        bump_gradle(path, new_version).map(|()| vec![])
    }
}

struct GoHandler;

impl VersionFileHandler for GoHandler {
    fn name(&self) -> &str {
        "Go"
    }
    fn manifest_names(&self) -> &[&str] {
        &[]
    }
    fn lock_file_names(&self) -> &[&str] {
        &[]
    }
    /// Custom detection: scan for `*.go` files containing a `Version` variable.
    fn detect(&self, dir: &Path) -> bool {
        let Ok(entries) = fs::read_dir(dir) else {
            return false;
        };
        for entry in entries.flatten() {
            let path = entry.path();
            if path.extension().is_some_and(|e| e == "go")
                && let Ok(contents) = fs::read_to_string(&path)
                && go_version_re().is_match(&contents)
            {
                return true;
            }
        }
        false
    }
    fn bump(&self, path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
        bump_go_version(path, new_version).map(|()| vec![])
    }
}

// ---------------------------------------------------------------------------
// Registry & public API
// ---------------------------------------------------------------------------

/// Return all known version-file handlers.
pub fn all_handlers() -> Vec<Box<dyn VersionFileHandler>> {
    vec![
        Box::new(CargoHandler),
        Box::new(NpmHandler),
        Box::new(PyprojectHandler),
        Box::new(MavenHandler),
        Box::new(GradleHandler),
        Box::new(GoHandler),
    ]
}

/// Auto-detect version files in a directory. Returns relative paths (relative
/// to `dir`) for every manifest whose ecosystem is detected.
///
/// For the Go handler the detected `.go` file containing the Version variable
/// is returned (not a manifest name).
pub fn detect_version_files(dir: &Path) -> Vec<String> {
    let mut files = Vec::new();
    for handler in all_handlers() {
        if !handler.detect(dir) {
            continue;
        }
        if handler.manifest_names().is_empty() {
            // Go handler: find the actual .go file with a Version var
            if let Ok(entries) = fs::read_dir(dir) {
                let re = go_version_re();
                for entry in entries.flatten() {
                    let path = entry.path();
                    if path.extension().is_some_and(|e| e == "go")
                        && let Ok(contents) = fs::read_to_string(&path)
                        && re.is_match(&contents)
                    {
                        files.push(path.file_name().unwrap().to_string_lossy().into_owned());
                    }
                }
            }
        } else {
            for name in handler.manifest_names() {
                if dir.join(name).exists() {
                    files.push((*name).to_string());
                }
            }
        }
    }
    files
}

/// Look up the handler for a given filename.
fn handler_for_file(filename: &str) -> Option<Box<dyn VersionFileHandler>> {
    for handler in all_handlers() {
        if handler.manifest_names().contains(&filename) {
            return Some(handler);
        }
    }
    // Go files: any .go extension
    if filename.ends_with(".go") {
        return Some(Box::new(GoHandler));
    }
    None
}

/// Bump the `version` field in the given manifest file.
///
/// Returns a list of additional files that were auto-discovered and bumped
/// (e.g. workspace member manifests). The caller should stage these files.
///
/// The file format is auto-detected from the filename:
/// - `Cargo.toml`          → TOML (`package.version` or `workspace.package.version`)
/// - `package.json`        → JSON (`.version`)
/// - `pyproject.toml`      → TOML (`project.version` or `tool.poetry.version`)
/// - `build.gradle`        → Gradle Groovy DSL (`version = '...'` or `version = "..."`)
/// - `build.gradle.kts`    → Gradle Kotlin DSL (`version = "..."`)
/// - `pom.xml`             → Maven (`<version>...</version>`, skipping `<parent>` block)
/// - `*.go`                → Go (`var/const Version = "..."`)
///
/// For workspace roots (Cargo, npm, uv), member manifests are auto-discovered
/// and bumped without needing to list them in `version_files`.
pub fn bump_version_file(path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
    let filename = path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or_default();

    match handler_for_file(filename) {
        Some(handler) => handler.bump(path, new_version),
        None => Err(ReleaseError::VersionBump(format!(
            "unsupported version file: {filename}"
        ))),
    }
}

/// Given a list of bumped manifest paths, discover associated lock files that exist on disk.
/// Searches the manifest's directory and ancestors (for monorepo roots).
/// Returns deduplicated paths.
pub fn discover_lock_files(bumped_files: &[String]) -> Vec<PathBuf> {
    let handlers = all_handlers();
    let mut seen = std::collections::BTreeSet::new();
    for file in bumped_files {
        let path = Path::new(file);
        let filename = path
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or_default();

        // Collect lock file names from all handlers that match this manifest
        let mut lock_names: Vec<&str> = Vec::new();
        for handler in &handlers {
            if handler.manifest_names().contains(&filename) {
                lock_names.extend(handler.lock_file_names());
            }
        }

        // Search the manifest's directory and ancestors
        let mut dir = path.parent();
        while let Some(d) = dir {
            for lock_name in &lock_names {
                let lock_path = d.join(lock_name);
                if lock_path.exists() {
                    seen.insert(lock_path);
                }
            }
            dir = d.parent();
            // Stop at repo root (don't traverse beyond .git)
            if d.join(".git").exists() {
                break;
            }
        }
    }
    seen.into_iter().collect()
}

/// Returns `true` if the given filename is a supported version file.
pub fn is_supported_version_file(filename: &str) -> bool {
    handler_for_file(filename).is_some()
}

/// Compile the Go Version variable regex (used in detection).
fn go_version_re() -> Regex {
    Regex::new(r#"(?:var|const)\s+Version\s*(?:string\s*)?=\s*""#).unwrap()
}

// ---------------------------------------------------------------------------
// Private bump implementations (unchanged)
// ---------------------------------------------------------------------------

fn bump_cargo_toml(path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
    let contents = read_file(path)?;
    let mut doc: toml_edit::DocumentMut = contents.parse().map_err(|e| {
        ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
    })?;

    let is_workspace = doc
        .get("workspace")
        .and_then(|w| w.get("package"))
        .and_then(|p| p.get("version"))
        .is_some();

    let mut bumped_names: Vec<String> = Vec::new();
    if let Some(name) = doc
        .get("package")
        .and_then(|p| p.get("name"))
        .and_then(|n| n.as_str())
    {
        bumped_names.push(name.to_string());
    }

    if doc.get("package").and_then(|p| p.get("version")).is_some() {
        doc["package"]["version"] = toml_edit::value(new_version);
    } else if is_workspace {
        doc["workspace"]["package"]["version"] = toml_edit::value(new_version);

        // Also update [workspace.dependencies] entries that are internal path deps
        if let Some(deps) = doc
            .get_mut("workspace")
            .and_then(|w| w.get_mut("dependencies"))
            .and_then(|d| d.as_table_like_mut())
        {
            for (_, dep) in deps.iter_mut() {
                if let Some(tbl) = dep.as_table_like_mut()
                    && tbl.get("path").is_some()
                    && tbl.get("version").is_some()
                {
                    tbl.insert("version", toml_edit::value(new_version));
                }
            }
        }
    } else {
        return Err(ReleaseError::VersionBump(format!(
            "no version field found in {}",
            path.display()
        )));
    }

    write_file(path, &doc.to_string())?;

    // Auto-discover and bump workspace member Cargo.toml files
    let mut extra = Vec::new();
    if is_workspace {
        let members = extract_toml_string_array(&doc, &["workspace", "members"]);
        let root_dir = path.parent().unwrap_or(Path::new("."));
        for member_path in resolve_member_globs(root_dir, &members, "Cargo.toml") {
            if member_path.as_path() == path {
                continue;
            }
            match bump_cargo_member(&member_path, new_version) {
                Ok((modified, name)) => {
                    if modified {
                        extra.push(member_path);
                    }
                    if let Some(n) = name {
                        bumped_names.push(n);
                    }
                }
                Err(e) => eprintln!("warning: {e}"),
            }
        }
    }

    // Rewrite workspace member versions in Cargo.lock so the lockfile lands
    // in the release commit alongside the bumped manifests. Without this, the
    // post_release `cargo publish` hook regenerates Cargo.lock on the fly and
    // aborts with "working directory contains uncommitted changes".
    refresh_cargo_lock(path, new_version, &bumped_names)?;

    Ok(extra)
}

/// Bump `package.version` in a workspace member Cargo.toml (skip if using `version.workspace = true`).
/// Returns `(modified, name)` — `modified` is `true` when the file was rewritten;
/// `name` is the member's `package.name` (present regardless of whether the
/// version was inherited, so the caller can update Cargo.lock).
fn bump_cargo_member(
    path: &Path,
    new_version: &str,
) -> Result<(bool, Option<String>), ReleaseError> {
    let contents = read_file(path)?;
    let mut doc: toml_edit::DocumentMut = contents.parse().map_err(|e| {
        ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
    })?;

    let name = doc
        .get("package")
        .and_then(|p| p.get("name"))
        .and_then(|n| n.as_str())
        .map(|s| s.to_string());

    // Skip members that inherit version from workspace
    let version_item = doc.get("package").and_then(|p| p.get("version"));
    match version_item {
        Some(item) if item.is_value() => {
            doc["package"]["version"] = toml_edit::value(new_version);
            write_file(path, &doc.to_string())?;
            Ok((true, name))
        }
        _ => Ok((false, name)), // No version or uses workspace inheritance
    }
}

/// Rewrite workspace-member `[[package]]` entries in Cargo.lock to the new
/// version. Entries with a `source` field (published deps) are ignored.
/// Silently no-ops when Cargo.lock is absent.
fn refresh_cargo_lock(
    manifest_path: &Path,
    new_version: &str,
    member_names: &[String],
) -> Result<(), ReleaseError> {
    if member_names.is_empty() {
        return Ok(());
    }

    // Walk up from the manifest directory to find Cargo.lock.
    let mut dir = manifest_path.parent();
    let lock_path = loop {
        let Some(d) = dir else {
            return Ok(());
        };
        let candidate = d.join("Cargo.lock");
        if candidate.exists() {
            break candidate;
        }
        if d.join(".git").exists() {
            return Ok(());
        }
        dir = d.parent();
    };

    let contents = read_file(&lock_path)?;
    let mut doc: toml_edit::DocumentMut = contents.parse().map_err(|e| {
        ReleaseError::VersionBump(format!("failed to parse {}: {e}", lock_path.display()))
    })?;

    let Some(packages) = doc
        .get_mut("package")
        .and_then(|p| p.as_array_of_tables_mut())
    else {
        return Ok(());
    };

    let mut changed = false;
    for pkg in packages.iter_mut() {
        let Some(name) = pkg.get("name").and_then(|n| n.as_str()) else {
            continue;
        };
        if pkg.contains_key("source") {
            continue; // Published dep, not a workspace member
        }
        if member_names.iter().any(|m| m == name) {
            pkg.insert("version", toml_edit::value(new_version));
            changed = true;
        }
    }

    if changed {
        write_file(&lock_path, &doc.to_string())?;
    }
    Ok(())
}

fn bump_package_json(path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
    let contents = read_file(path)?;
    let mut value: serde_json::Value = serde_json::from_str(&contents).map_err(|e| {
        ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
    })?;

    let obj = value
        .as_object_mut()
        .ok_or_else(|| ReleaseError::VersionBump("package.json is not an object".into()))?;

    // Extract workspace patterns before mutating
    let workspace_patterns: Vec<String> = obj
        .get("workspaces")
        .and_then(|w| w.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str().map(String::from))
                .collect()
        })
        .unwrap_or_default();

    obj.insert(
        "version".into(),
        serde_json::Value::String(new_version.into()),
    );

    let output = serde_json::to_string_pretty(&value).map_err(|e| {
        ReleaseError::VersionBump(format!("failed to serialize {}: {e}", path.display()))
    })?;

    write_file(path, &format!("{output}\n"))?;

    // Auto-discover and bump workspace member package.json files
    let mut extra = Vec::new();
    if !workspace_patterns.is_empty() {
        let root_dir = path.parent().unwrap_or(Path::new("."));
        for member_path in resolve_member_globs(root_dir, &workspace_patterns, "package.json") {
            if member_path == path {
                continue;
            }
            match bump_json_version(&member_path, new_version) {
                Ok(true) => extra.push(member_path),
                Ok(false) => {}
                Err(e) => eprintln!("warning: {e}"),
            }
        }
    }

    Ok(extra)
}

/// Bump `version` in a member package.json (skip if no version field).
/// Returns `true` if the file was actually modified.
fn bump_json_version(path: &Path, new_version: &str) -> Result<bool, ReleaseError> {
    let contents = read_file(path)?;
    let mut value: serde_json::Value = serde_json::from_str(&contents).map_err(|e| {
        ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
    })?;

    let obj = match value.as_object_mut() {
        Some(o) => o,
        None => return Ok(false),
    };

    if obj.get("version").is_none() {
        return Ok(false);
    }

    obj.insert(
        "version".into(),
        serde_json::Value::String(new_version.into()),
    );

    let output = serde_json::to_string_pretty(&value).map_err(|e| {
        ReleaseError::VersionBump(format!("failed to serialize {}: {e}", path.display()))
    })?;

    write_file(path, &format!("{output}\n"))?;
    Ok(true)
}

fn bump_pyproject_toml(path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
    let contents = read_file(path)?;
    let mut doc: toml_edit::DocumentMut = contents.parse().map_err(|e| {
        ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
    })?;

    if doc.get("project").and_then(|p| p.get("version")).is_some() {
        doc["project"]["version"] = toml_edit::value(new_version);
    } else if doc
        .get("tool")
        .and_then(|t| t.get("poetry"))
        .and_then(|p| p.get("version"))
        .is_some()
    {
        doc["tool"]["poetry"]["version"] = toml_edit::value(new_version);
    } else {
        return Err(ReleaseError::VersionBump(format!(
            "no version field found in {}",
            path.display()
        )));
    }

    write_file(path, &doc.to_string())?;

    // Auto-discover uv workspace members
    let members = extract_toml_string_array(&doc, &["tool", "uv", "workspace", "members"]);
    let mut extra = Vec::new();
    if !members.is_empty() {
        let root_dir = path.parent().unwrap_or(Path::new("."));
        for member_path in resolve_member_globs(root_dir, &members, "pyproject.toml") {
            if member_path.as_path() == path {
                continue;
            }
            match bump_pyproject_member(&member_path, new_version) {
                Ok(true) => extra.push(member_path),
                Ok(false) => {}
                Err(e) => eprintln!("warning: {e}"),
            }
        }
    }

    Ok(extra)
}

/// Bump version in a uv workspace member pyproject.toml (skip if no version field).
/// Returns `true` if the file was actually modified.
fn bump_pyproject_member(path: &Path, new_version: &str) -> Result<bool, ReleaseError> {
    let contents = read_file(path)?;
    let mut doc: toml_edit::DocumentMut = contents.parse().map_err(|e| {
        ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
    })?;

    if doc.get("project").and_then(|p| p.get("version")).is_some() {
        doc["project"]["version"] = toml_edit::value(new_version);
    } else if doc
        .get("tool")
        .and_then(|t| t.get("poetry"))
        .and_then(|p| p.get("version"))
        .is_some()
    {
        doc["tool"]["poetry"]["version"] = toml_edit::value(new_version);
    } else {
        return Ok(false); // No version field — skip
    }

    write_file(path, &doc.to_string())?;
    Ok(true)
}

fn bump_gradle(path: &Path, new_version: &str) -> Result<(), ReleaseError> {
    let contents = read_file(path)?;
    let re = Regex::new(r#"(version\s*=\s*["'])([^"']*)(["'])"#).unwrap();
    if !re.is_match(&contents) {
        return Err(ReleaseError::VersionBump(format!(
            "no version assignment found in {}",
            path.display()
        )));
    }
    let result = re.replacen(&contents, 1, format!("${{1}}{new_version}${{3}}"));
    write_file(path, &result)
}

fn bump_pom_xml(path: &Path, new_version: &str) -> Result<(), ReleaseError> {
    let contents = read_file(path)?;

    // Determine search start: skip past </parent> if present, else after </modelVersion>
    let search_start = if let Some(pos) = contents.find("</parent>") {
        pos + "</parent>".len()
    } else if let Some(pos) = contents.find("</modelVersion>") {
        pos + "</modelVersion>".len()
    } else {
        0
    };

    let rest = &contents[search_start..];
    let re = Regex::new(r"<version>[^<]*</version>").unwrap();
    if let Some(m) = re.find(rest) {
        let replacement = format!("<version>{new_version}</version>");
        let mut result = String::with_capacity(contents.len());
        result.push_str(&contents[..search_start + m.start()]);
        result.push_str(&replacement);
        result.push_str(&contents[search_start + m.end()..]);
        write_file(path, &result)
    } else {
        Err(ReleaseError::VersionBump(format!(
            "no <version> element found in {}",
            path.display()
        )))
    }
}

fn bump_go_version(path: &Path, new_version: &str) -> Result<(), ReleaseError> {
    let contents = read_file(path)?;
    let re = Regex::new(r#"((?:var|const)\s+Version\s*(?:string\s*)?=\s*")([^"]*)(")"#).unwrap();
    if !re.is_match(&contents) {
        return Err(ReleaseError::VersionBump(format!(
            "no Version variable found in {}",
            path.display()
        )));
    }
    let result = re.replacen(&contents, 1, format!("${{1}}{new_version}${{3}}"));
    write_file(path, &result)
}

/// Extract a string array from a nested TOML path (e.g. `["workspace", "members"]`).
fn extract_toml_string_array(doc: &toml_edit::DocumentMut, keys: &[&str]) -> Vec<String> {
    let mut item: Option<&toml_edit::Item> = None;
    for key in keys {
        item = match item {
            None => doc.get(key),
            Some(parent) => parent.get(key),
        };
        if item.is_none() {
            return vec![];
        }
    }
    item.and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str().map(String::from))
                .collect()
        })
        .unwrap_or_default()
}

/// Resolve workspace member glob patterns into manifest file paths.
/// Each glob is resolved relative to `root_dir`, and `manifest_name` is appended
/// to each matched directory (e.g. "Cargo.toml", "package.json", "pyproject.toml").
fn resolve_member_globs(root_dir: &Path, patterns: &[String], manifest_name: &str) -> Vec<PathBuf> {
    let mut paths = Vec::new();
    for pattern in patterns {
        let full_pattern = root_dir.join(pattern).to_string_lossy().into_owned();
        let Ok(entries) = glob::glob(&full_pattern) else {
            continue;
        };
        for entry in entries.flatten() {
            let manifest = if entry.is_dir() {
                entry.join(manifest_name)
            } else {
                continue;
            };
            if manifest.exists() {
                paths.push(manifest);
            }
        }
    }
    paths
}

fn read_file(path: &Path) -> Result<String, ReleaseError> {
    fs::read_to_string(path)
        .map_err(|e| ReleaseError::VersionBump(format!("failed to read {}: {e}", path.display())))
}

fn write_file(path: &Path, contents: &str) -> Result<(), ReleaseError> {
    fs::write(path, contents)
        .map_err(|e| ReleaseError::VersionBump(format!("failed to write {}: {e}", path.display())))
}

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

    #[test]
    fn bump_cargo_toml_package_version() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("Cargo.toml");
        fs::write(
            &path,
            r#"[package]
name = "my-crate"
version = "0.1.0"
edition = "2021"

[dependencies]
serde = "1"
"#,
        )
        .unwrap();

        bump_version_file(&path, "1.2.3").unwrap();

        let contents = fs::read_to_string(&path).unwrap();
        assert!(contents.contains("version = \"1.2.3\""));
        assert!(contents.contains("name = \"my-crate\""));
        assert!(contents.contains("serde = \"1\""));
    }

    #[test]
    fn bump_cargo_toml_workspace_version() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("Cargo.toml");
        fs::write(
            &path,
            r#"[workspace]
members = ["crates/*"]

[workspace.package]
version = "0.0.1"
edition = "2021"
"#,
        )
        .unwrap();

        bump_version_file(&path, "2.0.0").unwrap();

        let contents = fs::read_to_string(&path).unwrap();
        assert!(contents.contains("version = \"2.0.0\""));
        assert!(contents.contains("members = [\"crates/*\"]"));
    }

    #[test]
    fn bump_package_json_version() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("package.json");
        fs::write(
            &path,
            r#"{
  "name": "my-pkg",
  "version": "0.0.0",
  "description": "test"
}"#,
        )
        .unwrap();

        bump_version_file(&path, "3.1.0").unwrap();

        let contents = fs::read_to_string(&path).unwrap();
        let value: serde_json::Value = serde_json::from_str(&contents).unwrap();
        assert_eq!(value["version"], "3.1.0");
        assert_eq!(value["name"], "my-pkg");
        assert_eq!(value["description"], "test");
        assert!(contents.ends_with('\n'));
    }

    #[test]
    fn bump_pyproject_toml_project_version() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("pyproject.toml");
        fs::write(
            &path,
            r#"[project]
name = "my-project"
version = "0.1.0"
description = "A test project"
"#,
        )
        .unwrap();

        bump_version_file(&path, "1.0.0").unwrap();

        let contents = fs::read_to_string(&path).unwrap();
        assert!(contents.contains("version = \"1.0.0\""));
        assert!(contents.contains("name = \"my-project\""));
    }

    #[test]
    fn bump_pyproject_toml_poetry_version() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("pyproject.toml");
        fs::write(
            &path,
            r#"[tool.poetry]
name = "my-poetry-project"
version = "0.2.0"
description = "A poetry project"
"#,
        )
        .unwrap();

        bump_version_file(&path, "0.3.0").unwrap();

        let contents = fs::read_to_string(&path).unwrap();
        assert!(contents.contains("version = \"0.3.0\""));
        assert!(contents.contains("name = \"my-poetry-project\""));
    }

    #[test]
    fn bump_unknown_file_returns_error() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("unknown.txt");
        fs::write(&path, "version = 1").unwrap();

        let err = bump_version_file(&path, "1.0.0").unwrap_err();
        assert!(matches!(err, ReleaseError::VersionBump(_)));
        assert!(err.to_string().contains("unsupported"));
    }

    #[test]
    fn bump_build_gradle_version() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("build.gradle");
        fs::write(
            &path,
            r#"plugins {
    id 'java'
}

group = 'com.example'
version = '1.0.0'

dependencies {
    implementation 'org.slf4j:slf4j-api:2.0.0'
}
"#,
        )
        .unwrap();

        bump_version_file(&path, "2.0.0").unwrap();

        let contents = fs::read_to_string(&path).unwrap();
        assert!(contents.contains("version = '2.0.0'"));
        assert!(contents.contains("group = 'com.example'"));
        // dependency version must not change
        assert!(contents.contains("slf4j-api:2.0.0"));
    }

    #[test]
    fn bump_build_gradle_kts_version() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("build.gradle.kts");
        fs::write(
            &path,
            r#"plugins {
    kotlin("jvm") version "1.9.0"
}

group = "com.example"
version = "1.0.0"

dependencies {
    implementation("org.slf4j:slf4j-api:2.0.0")
}
"#,
        )
        .unwrap();

        bump_version_file(&path, "3.0.0").unwrap();

        let contents = fs::read_to_string(&path).unwrap();
        assert!(contents.contains("version = \"3.0.0\""));
        assert!(contents.contains("group = \"com.example\""));
    }

    #[test]
    fn bump_pom_xml_version() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("pom.xml");
        fs::write(
            &path,
            r#"<?xml version="1.0" encoding="UTF-8"?>
<project>
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.example</groupId>
    <artifactId>my-app</artifactId>
    <version>1.0.0</version>
</project>
"#,
        )
        .unwrap();

        bump_version_file(&path, "2.0.0").unwrap();

        let contents = fs::read_to_string(&path).unwrap();
        assert!(contents.contains("<version>2.0.0</version>"));
        assert!(contents.contains("<groupId>com.example</groupId>"));
    }

    #[test]
    fn bump_pom_xml_with_parent_version() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("pom.xml");
        fs::write(
            &path,
            r#"<?xml version="1.0" encoding="UTF-8"?>
<project>
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.example</groupId>
        <artifactId>parent</artifactId>
        <version>5.0.0</version>
    </parent>
    <artifactId>my-app</artifactId>
    <version>1.0.0</version>
</project>
"#,
        )
        .unwrap();

        bump_version_file(&path, "2.0.0").unwrap();

        let contents = fs::read_to_string(&path).unwrap();
        // Parent version must NOT be changed
        assert!(contents.contains("<version>5.0.0</version>"));
        // Project version must be changed
        assert!(contents.contains("<version>2.0.0</version>"));
        // Verify there are exactly two <version> tags with expected values
        let version_count: Vec<&str> = contents.matches("<version>").collect();
        assert_eq!(version_count.len(), 2);
    }

    #[test]
    fn bump_cargo_toml_workspace_dependencies_with_path() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("Cargo.toml");
        fs::write(
            &path,
            r#"[workspace]
members = ["crates/*"]

[workspace.package]
version = "0.1.0"
edition = "2021"

[workspace.dependencies]
# Internal crates
sr-core = { path = "crates/sr-core", version = "0.1.0" }
sr-git = { path = "crates/sr-git", version = "0.1.0" }
# External dep should not change
serde = { version = "1", features = ["derive"] }
"#,
        )
        .unwrap();

        bump_version_file(&path, "2.0.0").unwrap();

        let contents = fs::read_to_string(&path).unwrap();
        let doc: toml_edit::DocumentMut = contents.parse().unwrap();

        // workspace.package.version should be bumped
        assert_eq!(
            doc["workspace"]["package"]["version"].as_str().unwrap(),
            "2.0.0"
        );
        // Internal path deps should have their version bumped
        assert_eq!(
            doc["workspace"]["dependencies"]["sr-core"]["version"]
                .as_str()
                .unwrap(),
            "2.0.0"
        );
        assert_eq!(
            doc["workspace"]["dependencies"]["sr-git"]["version"]
                .as_str()
                .unwrap(),
            "2.0.0"
        );
        // External dep version must NOT change
        assert_eq!(
            doc["workspace"]["dependencies"]["serde"]["version"]
                .as_str()
                .unwrap(),
            "1"
        );
    }

    #[test]
    fn bump_go_version_var() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("version.go");
        fs::write(
            &path,
            r#"package main

var Version = "1.0.0"

func main() {}
"#,
        )
        .unwrap();

        bump_version_file(&path, "2.0.0").unwrap();

        let contents = fs::read_to_string(&path).unwrap();
        assert!(contents.contains(r#"var Version = "2.0.0""#));
    }

    #[test]
    fn bump_go_version_const() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("version.go");
        fs::write(
            &path,
            r#"package main

const Version string = "0.5.0"

func main() {}
"#,
        )
        .unwrap();

        bump_version_file(&path, "0.6.0").unwrap();

        let contents = fs::read_to_string(&path).unwrap();
        assert!(contents.contains(r#"const Version string = "0.6.0""#));
    }

    // --- workspace auto-discovery tests ---

    #[test]
    fn bump_cargo_workspace_discovers_members() {
        let dir = tempfile::tempdir().unwrap();

        // Create workspace root
        let root = dir.path().join("Cargo.toml");
        fs::write(
            &root,
            r#"[workspace]
members = ["crates/*"]

[workspace.package]
version = "1.0.0"
edition = "2021"

[workspace.dependencies]
my-core = { path = "crates/core", version = "1.0.0" }
"#,
        )
        .unwrap();

        // Create member with hardcoded version
        fs::create_dir_all(dir.path().join("crates/core")).unwrap();
        let member = dir.path().join("crates/core/Cargo.toml");
        fs::write(
            &member,
            r#"[package]
name = "my-core"
version = "1.0.0"
edition = "2021"
"#,
        )
        .unwrap();

        // Create member that uses workspace inheritance (should be skipped)
        fs::create_dir_all(dir.path().join("crates/cli")).unwrap();
        let inherited_member = dir.path().join("crates/cli/Cargo.toml");
        fs::write(
            &inherited_member,
            r#"[package]
name = "my-cli"
version.workspace = true
edition.workspace = true
"#,
        )
        .unwrap();

        let extra = bump_version_file(&root, "2.0.0").unwrap();

        // Root should be bumped
        let root_contents = fs::read_to_string(&root).unwrap();
        assert!(root_contents.contains("version = \"2.0.0\""));

        // Workspace dep should be bumped
        let doc: toml_edit::DocumentMut = root_contents.parse().unwrap();
        assert_eq!(
            doc["workspace"]["dependencies"]["my-core"]["version"]
                .as_str()
                .unwrap(),
            "2.0.0"
        );

        // Member with hardcoded version should be bumped
        let member_contents = fs::read_to_string(&member).unwrap();
        assert!(member_contents.contains("version = \"2.0.0\""));

        // Member with workspace inheritance should NOT be modified
        let inherited_contents = fs::read_to_string(&inherited_member).unwrap();
        assert!(inherited_contents.contains("version.workspace = true"));

        // Only the hardcoded member should be in extra
        assert_eq!(extra.len(), 1);
        assert_eq!(extra[0], member);
    }

    #[test]
    fn bump_npm_workspace_discovers_members() {
        let dir = tempfile::tempdir().unwrap();

        // Create root package.json with workspaces
        let root = dir.path().join("package.json");
        fs::write(
            &root,
            r#"{
  "name": "my-monorepo",
  "version": "1.0.0",
  "workspaces": ["packages/*"]
}"#,
        )
        .unwrap();

        // Create member
        fs::create_dir_all(dir.path().join("packages/core")).unwrap();
        let member = dir.path().join("packages/core/package.json");
        fs::write(
            &member,
            r#"{
  "name": "@my/core",
  "version": "1.0.0"
}"#,
        )
        .unwrap();

        // Create member without version (should be skipped)
        fs::create_dir_all(dir.path().join("packages/utils")).unwrap();
        let no_version_member = dir.path().join("packages/utils/package.json");
        fs::write(
            &no_version_member,
            r#"{
  "name": "@my/utils",
  "private": true
}"#,
        )
        .unwrap();

        let extra = bump_version_file(&root, "2.0.0").unwrap();

        // Root bumped
        let root_contents: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&root).unwrap()).unwrap();
        assert_eq!(root_contents["version"], "2.0.0");

        // Member with version bumped
        let member_contents: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&member).unwrap()).unwrap();
        assert_eq!(member_contents["version"], "2.0.0");

        // Member without version untouched
        let utils_contents: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&no_version_member).unwrap()).unwrap();
        assert!(utils_contents.get("version").is_none());

        assert_eq!(extra.len(), 1);
        assert_eq!(extra[0], member);
    }

    #[test]
    fn bump_uv_workspace_discovers_members() {
        let dir = tempfile::tempdir().unwrap();

        // Create root pyproject.toml with uv workspace
        let root = dir.path().join("pyproject.toml");
        fs::write(
            &root,
            r#"[project]
name = "my-monorepo"
version = "1.0.0"

[tool.uv.workspace]
members = ["packages/*"]
"#,
        )
        .unwrap();

        // Create member
        fs::create_dir_all(dir.path().join("packages/core")).unwrap();
        let member = dir.path().join("packages/core/pyproject.toml");
        fs::write(
            &member,
            r#"[project]
name = "my-core"
version = "1.0.0"
"#,
        )
        .unwrap();

        let extra = bump_version_file(&root, "2.0.0").unwrap();

        // Root bumped
        let root_contents = fs::read_to_string(&root).unwrap();
        assert!(root_contents.contains("version = \"2.0.0\""));

        // Member bumped
        let member_contents = fs::read_to_string(&member).unwrap();
        assert!(member_contents.contains("version = \"2.0.0\""));

        assert_eq!(extra.len(), 1);
        assert_eq!(extra[0], member);
    }

    #[test]
    fn bump_cargo_workspace_refreshes_lockfile() {
        let dir = tempfile::tempdir().unwrap();

        // Workspace root
        let root = dir.path().join("Cargo.toml");
        fs::write(
            &root,
            r#"[workspace]
members = ["crates/*"]

[workspace.package]
version = "1.0.0"
"#,
        )
        .unwrap();

        // Member with hardcoded version
        fs::create_dir_all(dir.path().join("crates/my-core")).unwrap();
        fs::write(
            dir.path().join("crates/my-core/Cargo.toml"),
            r#"[package]
name = "my-core"
version = "1.0.0"
"#,
        )
        .unwrap();

        // Cargo.lock with both a workspace member and a published dep
        let lock = dir.path().join("Cargo.lock");
        fs::write(
            &lock,
            r#"version = 3

[[package]]
name = "my-core"
version = "1.0.0"

[[package]]
name = "serde"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abc123"
"#,
        )
        .unwrap();

        bump_version_file(&root, "2.0.0").unwrap();

        let lock_contents = fs::read_to_string(&lock).unwrap();
        let doc: toml_edit::DocumentMut = lock_contents.parse().unwrap();
        let packages = doc["package"].as_array_of_tables().unwrap();

        let my_core = packages
            .iter()
            .find(|p| p.get("name").and_then(|n| n.as_str()) == Some("my-core"))
            .unwrap();
        assert_eq!(my_core["version"].as_str().unwrap(), "2.0.0");

        // Published dep must NOT be touched
        let serde = packages
            .iter()
            .find(|p| p.get("name").and_then(|n| n.as_str()) == Some("serde"))
            .unwrap();
        assert_eq!(serde["version"].as_str().unwrap(), "1.0.0");
        assert!(serde.contains_key("source"));
        assert_eq!(serde["checksum"].as_str().unwrap(), "abc123");
    }

    #[test]
    fn bump_non_workspace_returns_empty_extra() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("Cargo.toml");
        fs::write(
            &path,
            r#"[package]
name = "solo-crate"
version = "1.0.0"
"#,
        )
        .unwrap();

        let extra = bump_version_file(&path, "2.0.0").unwrap();
        assert!(extra.is_empty());
    }

    // --- auto-detection tests ---

    #[test]
    fn detect_cargo_toml() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(
            dir.path().join("Cargo.toml"),
            "[package]\nname = \"x\"\nversion = \"0.1.0\"\n",
        )
        .unwrap();

        let detected = detect_version_files(dir.path());
        assert_eq!(detected, vec!["Cargo.toml"]);
    }

    #[test]
    fn detect_package_json() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(
            dir.path().join("package.json"),
            r#"{"name": "x", "version": "1.0.0"}"#,
        )
        .unwrap();

        let detected = detect_version_files(dir.path());
        assert_eq!(detected, vec!["package.json"]);
    }

    #[test]
    fn detect_pyproject_toml() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(
            dir.path().join("pyproject.toml"),
            "[project]\nname = \"x\"\nversion = \"0.1.0\"\n",
        )
        .unwrap();

        let detected = detect_version_files(dir.path());
        assert_eq!(detected, vec!["pyproject.toml"]);
    }

    #[test]
    fn detect_multiple_ecosystems() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(
            dir.path().join("Cargo.toml"),
            "[package]\nname = \"x\"\nversion = \"0.1.0\"\n",
        )
        .unwrap();
        fs::write(
            dir.path().join("package.json"),
            r#"{"name": "x", "version": "1.0.0"}"#,
        )
        .unwrap();

        let detected = detect_version_files(dir.path());
        assert!(detected.contains(&"Cargo.toml".to_string()));
        assert!(detected.contains(&"package.json".to_string()));
    }

    #[test]
    fn detect_empty_directory() {
        let dir = tempfile::tempdir().unwrap();
        let detected = detect_version_files(dir.path());
        assert!(detected.is_empty());
    }

    #[test]
    fn detect_go_version_file() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(
            dir.path().join("version.go"),
            "package main\n\nvar Version = \"1.0.0\"\n",
        )
        .unwrap();

        let detected = detect_version_files(dir.path());
        assert_eq!(detected, vec!["version.go"]);
    }

    #[test]
    fn is_supported_recognizes_all_types() {
        assert!(is_supported_version_file("Cargo.toml"));
        assert!(is_supported_version_file("package.json"));
        assert!(is_supported_version_file("pyproject.toml"));
        assert!(is_supported_version_file("pom.xml"));
        assert!(is_supported_version_file("build.gradle"));
        assert!(is_supported_version_file("build.gradle.kts"));
        assert!(is_supported_version_file("version.go"));
        assert!(!is_supported_version_file("unknown.txt"));
    }
}
