//! Publish pipeline for alef — vendoring, building, and packaging artifacts
//! for distribution across language package registries.
//!
//! This crate provides the local logic behind `alef publish prepare`,
//! `alef publish build`, and `alef publish package`. It does NOT handle
//! registry authentication or publishing — those remain in CI actions.

pub mod ffi_stage;
pub mod package;
pub mod platform;
pub mod vendor;
pub mod workspace;

use crate::core::config::ResolvedCrateConfig;
use crate::core::config::extras::Language;
use crate::core::config::publish::{PublishLanguageConfig, VendorMode};
use anyhow::{Context, Result};
use platform::RustTarget;
use std::path::{Path, PathBuf};

/// Prepare a language package for publishing: vendor dependencies, stage FFI artifacts.
///
/// When `require_registry` is true (CI/release), the Registry vendor mode
/// regenerates each rewritten binding crate's `Cargo.lock` and fails hard if
/// resolution fails — catching the case where a referenced workspace-member
/// version is not yet published to the registry. When false (the default for
/// local/pre-release dev), the lock is simply deleted so the consumer regenerates
/// it at build time.
pub fn prepare(
    config: &ResolvedCrateConfig,
    languages: &[Language],
    target: Option<&RustTarget>,
    dry_run: bool,
    require_registry: bool,
) -> Result<()> {
    for &lang in languages {
        let lang_config = publish_config_for_language(config, lang);

        if !dry_run && !run_publish_hooks(lang, &lang_config)? {
            continue;
        }

        let vendor_mode = lang_config
            .vendor_mode
            .as_ref()
            .unwrap_or(&default_vendor_mode(lang))
            .clone();

        match vendor_mode {
            VendorMode::CoreOnly => {
                let core_crate_dir = resolve_core_crate_dir(config);
                let core_path = Path::new(&core_crate_dir);
                if !core_path.exists() {
                    anyhow::bail!("core crate directory does not exist: {core_crate_dir}");
                }
                let workspace_root = resolve_workspace_root(config);
                let dest_dir = resolve_vendor_dest(config, lang);
                if dry_run {
                    eprintln!("[dry-run] Would vendor core crate from {core_crate_dir} for {lang}");
                } else {
                    eprintln!("Vendoring core crate from {core_crate_dir} for {lang}...");
                    let generate_ws = matches!(lang, Language::Ruby);
                    let result = vendor::vendor_core_only(
                        Path::new(&workspace_root),
                        core_path,
                        Path::new(&dest_dir),
                        generate_ws,
                    )?;
                    eprintln!("  vendored to {}", result.vendor_dir.display());
                }
            }
            VendorMode::Full => {
                let core_crate_dir = resolve_core_crate_dir(config);
                let workspace_root = resolve_workspace_root(config);
                let dest_dir = resolve_vendor_dest(config, lang);
                if dry_run {
                    eprintln!("[dry-run] Would vendor all dependencies from {core_crate_dir} for {lang}");
                } else {
                    eprintln!("Vendoring all dependencies from {core_crate_dir} for {lang}...");
                    let result = vendor::vendor_full(
                        Path::new(&workspace_root),
                        Path::new(&core_crate_dir),
                        Path::new(&dest_dir),
                    )?;
                    eprintln!("  vendored to {}", result.vendor_dir.display());
                }
            }
            VendorMode::Registry => {
                // Rewrite the shipped binding manifest's workspace-member path
                // deps to registry version-deps so the crate builds from source
                // on the consumer's machine (no workspace present).
                match resolve_binding_manifest(config, lang) {
                    Some(manifest) => {
                        let workspace_root = resolve_workspace_root(config);
                        let ws_root = Path::new(&workspace_root);
                        let manifest_abs = if manifest.is_absolute() {
                            manifest.clone()
                        } else {
                            ws_root.join(&manifest)
                        };

                        if !manifest_abs.exists() {
                            eprintln!(
                                "Skipping Registry rewrite for {lang}: binding manifest not found at {}",
                                manifest_abs.display()
                            );
                        } else {
                            let members = workspace::workspace_member_crates(ws_root)?;
                            let version = config
                                .resolved_version()
                                .context("cannot resolve crate version for Registry vendor mode")?;
                            if dry_run {
                                eprintln!(
                                    "[dry-run] Would rewrite workspace-member path deps to registry \
                                     version-deps (v{version}) in {} for {lang}",
                                    manifest_abs.display()
                                );
                            } else {
                                eprintln!(
                                    "Rewriting workspace-member path deps to registry version-deps \
                                     (v{version}) in {} for {lang}...",
                                    manifest_abs.display()
                                );
                                vendor::rewrite_path_deps_to_registry(&manifest_abs, &members, &version)?;
                                if let Some(manifest_dir) = manifest_abs.parent() {
                                    // In require_registry (CI/release) mode, regenerate the
                                    // lock and fail hard if a member version is not yet on the
                                    // registry. Otherwise, delete the lock (lenient default).
                                    vendor::scrub_or_regenerate_lock(manifest_dir, require_registry, require_registry)?;
                                }
                                eprintln!("  rewrote {}", manifest_abs.display());
                            }
                        }
                    }
                    None => {
                        eprintln!("Skipping Registry rewrite for {lang}: no shipped binding manifest");
                    }
                }
            }
            VendorMode::None => {}
        }

        // Stage FFI artifacts for FFI-dependent languages.
        if is_ffi_dependent(lang) {
            if let Some(target) = target {
                let workspace_root = resolve_workspace_root(config);
                if dry_run {
                    let platform = target.platform_for(lang);
                    eprintln!("[dry-run] Would stage FFI artifacts for {lang} (platform: {platform})");
                } else {
                    eprintln!("Staging FFI artifacts for {lang}...");
                    let dest = ffi_stage::stage_ffi(config, lang, target, Path::new(&workspace_root))?;
                    eprintln!("  staged to {}", dest.display());
                    if let Some(header) = ffi_stage::stage_header(config, lang, target, Path::new(&workspace_root))? {
                        eprintln!("  header staged to {}", header.display());
                    }
                }
            } else {
                eprintln!("Skipping FFI staging for {lang}: no --target specified");
            }
        }

        // Run after hooks on success (before moving to next language).
        if !dry_run {
            run_publish_after_hooks(lang, &lang_config)?;
        }
    }
    Ok(())
}

/// Validate an identifier against shell-safe character set.
fn validate_identifier(s: &str, label: &str) -> Result<()> {
    if s.chars()
        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
    {
        Ok(())
    } else {
        anyhow::bail!(
            "{label} contains invalid characters: {s}. Only alphanumeric, underscore, dash, and period allowed."
        )
    }
}

/// Build release artifacts for a specific platform.
pub fn build(
    config: &ResolvedCrateConfig,
    languages: &[Language],
    target: Option<&RustTarget>,
    use_cross: bool,
) -> Result<()> {
    let crate_name = &config.name;
    validate_identifier(crate_name, "crate_name")?;
    if let Some(t) = target {
        validate_identifier(&t.triple, "target.triple")?;
    }

    // For FFI-dependent languages, build the FFI crate first.
    let needs_ffi = languages.iter().any(|l| is_ffi_dependent(*l));
    let ffi_in_list = languages.contains(&Language::Ffi);
    if needs_ffi && !ffi_in_list {
        let cmd = build_command_for_lang(Language::Ffi, config, target, use_cross);
        eprintln!("Building FFI crate (dependency)...");
        run_shell_command(&cmd)?;
    }

    for &lang in languages {
        let lang_config = publish_config_for_language(config, lang);
        if !run_publish_hooks(lang, &lang_config)? {
            continue;
        }

        // Skip FFI-dependent languages if FFI was already built as dependency.
        if matches!(lang, Language::Go | Language::Java | Language::Csharp) && needs_ffi && !ffi_in_list {
            eprintln!("Skipping {lang}: FFI already built as dependency");
            continue;
        }

        // Use custom build command from [publish.languages.{lang}] if set.
        // Otherwise fall back to [build_commands.{lang}].build_release if set.
        // Otherwise use the config-driven default.
        let cmd = if let Some(custom) = &lang_config.build_command {
            substitute_target(&custom.commands().join(" && "), target)
        } else if let Some(build_cmd_cfg) = config
            .build_commands
            .get(&lang.to_string())
            .and_then(|c| c.build_release.as_ref())
        {
            substitute_target(&build_cmd_cfg.commands().join(" && "), target)
        } else {
            build_command_for_lang(lang, config, target, use_cross)
        };

        let target_str = target.map(|t| t.triple.as_str()).unwrap_or("host");
        eprintln!("Building {lang} for target {target_str}...");
        run_shell_command(&cmd)?;
        eprintln!("  build complete for {lang}");

        // Run after hooks on success.
        run_publish_after_hooks(lang, &lang_config)?;
    }
    Ok(())
}

/// Substitute `{target}` placeholder in a command string with the actual triple.
fn substitute_target(cmd: &str, target: Option<&RustTarget>) -> String {
    if let Some(t) = target {
        cmd.replace("{target}", &t.triple)
    } else {
        cmd.replace("{target}", "")
    }
}

/// Extract the Rust crate name from an output path in the config.
///
/// `"crates/sample-markdown-ffi/src/"` → `Some("sample-markdown-ffi")`
pub(crate) fn crate_name_from_output(config: &ResolvedCrateConfig, lang: Language) -> Option<String> {
    let output_path = match lang {
        Language::Python => config.explicit_output.python.as_deref(),
        Language::Node => config.explicit_output.node.as_deref(),
        Language::Ruby => config.explicit_output.ruby.as_deref(),
        Language::Php => config.explicit_output.php.as_deref(),
        Language::Elixir => config.explicit_output.elixir.as_deref(),
        Language::Wasm => config.explicit_output.wasm.as_deref(),
        Language::Ffi => config.explicit_output.ffi.as_deref(),
        Language::Go => config.explicit_output.go.as_deref(),
        Language::Java => config.explicit_output.java.as_deref(),
        Language::Csharp => config.explicit_output.csharp.as_deref(),
        Language::R => config.explicit_output.r.as_deref(),
        Language::Kotlin => config.explicit_output.kotlin.as_deref(),
        Language::KotlinAndroid => config.explicit_output.kotlin_android.as_deref(),
        Language::Gleam => config.explicit_output.gleam.as_deref(),
        Language::Zig => config.explicit_output.zig.as_deref(),
        Language::Rust | Language::C | Language::Jni => None,
        Language::Swift | Language::Dart => None,
    }?;
    let path = std::path::Path::new(output_path);
    // Strip trailing `src/` component if present.
    let crate_dir = if path.file_name().is_some_and(|n| n == "src") {
        path.parent()?
    } else {
        path
    };
    crate_dir.file_name()?.to_str().map(|s| s.to_string())
}

/// Generate the build command for a language, deriving crate names from output path config.
///
/// Falls back to `{crate_name}-{suffix}` when no output path is configured.
fn build_command_for_lang(
    lang: Language,
    config: &ResolvedCrateConfig,
    target: Option<&RustTarget>,
    use_cross: bool,
) -> String {
    let crate_name = &config.name;
    let cargo = if use_cross { "cross" } else { "cargo" };
    let target_flag = target.map(|t| format!(" --target {}", t.triple)).unwrap_or_default();

    match lang {
        Language::Python => {
            let pkg = crate_name_from_output(config, Language::Python).unwrap_or_else(|| format!("{crate_name}-py"));
            format!("maturin build --release --manifest-path crates/{pkg}/Cargo.toml{target_flag}")
        }
        Language::Node => {
            let pkg = crate_name_from_output(config, Language::Node).unwrap_or_else(|| format!("{crate_name}-node"));
            let napi_target = target.map(|t| format!(" --target {}", t.triple)).unwrap_or_default();
            format!(
                "napi build --manifest-path crates/{pkg}/Cargo.toml \
                 -o crates/{pkg} --platform --release{napi_target}"
            )
        }
        Language::Wasm => {
            let pkg = crate_name_from_output(config, Language::Wasm).unwrap_or_else(|| format!("{crate_name}-wasm"));
            format!("wasm-pack build crates/{pkg} --release")
        }
        Language::Ruby => {
            let pkg = crate_name_from_output(config, Language::Ruby).unwrap_or_else(|| format!("{crate_name}-rb"));
            format!("{cargo} build --release -p {pkg}{target_flag}")
        }
        Language::Php => {
            let pkg = crate_name_from_output(config, Language::Php).unwrap_or_else(|| format!("{crate_name}-php"));
            format!("{cargo} build --release -p {pkg}{target_flag}")
        }
        Language::Ffi => {
            let pkg = crate_name_from_output(config, Language::Ffi).unwrap_or_else(|| format!("{crate_name}-ffi"));
            format!("{cargo} build --release -p {pkg}{target_flag}")
        }
        Language::Go | Language::Java | Language::Csharp => {
            // FFI-dependent languages: build the FFI crate.
            let pkg = crate_name_from_output(config, Language::Ffi).unwrap_or_else(|| format!("{crate_name}-ffi"));
            format!("{cargo} build --release -p {pkg}{target_flag}")
        }
        Language::Elixir => {
            format!("{cargo} build --release{target_flag}")
        }
        Language::R => {
            let pkg = crate_name_from_output(config, Language::R).unwrap_or_else(|| format!("{crate_name}-r"));
            format!("{cargo} build --release -p {pkg}{target_flag}")
        }
        Language::Rust => {
            format!("{cargo} build --release --workspace{target_flag}")
        }
        Language::Kotlin
        | Language::KotlinAndroid
        | Language::Swift
        | Language::Dart
        | Language::Gleam
        | Language::Zig
        | Language::C
        | Language::Jni => {
            eprintln!("Warning: Phase 1: {lang} backend build command not yet implemented");
            String::new()
        }
    }
}

/// Run a shell command and return an error if it fails.
pub(crate) fn run_shell_command(cmd: &str) -> Result<()> {
    eprintln!("  $ {cmd}");
    let status = std::process::Command::new("sh")
        .arg("-c")
        .arg(cmd)
        .status()
        .with_context(|| format!("running: {cmd}"))?;

    if !status.success() {
        anyhow::bail!("command failed with exit code {}: {cmd}", status.code().unwrap_or(-1));
    }
    Ok(())
}

/// Run a shell command in a specific working directory.
pub(crate) fn run_shell_command_in(cmd: &str, dir: &std::path::Path) -> Result<()> {
    eprintln!("  $ {cmd}  (in {})", dir.display());
    let status = std::process::Command::new("sh")
        .arg("-c")
        .arg(cmd)
        .current_dir(dir)
        .status()
        .with_context(|| format!("running: {cmd}"))?;

    if !status.success() {
        anyhow::bail!("command failed with exit code {}: {cmd}", status.code().unwrap_or(-1));
    }
    Ok(())
}

/// Language-specific options forwarded into individual package functions.
///
/// All fields are optional so callers that don't package PHP can pass a
/// default-constructed value without knowing about PHP-specific flags.
#[derive(Default)]
pub struct PackageOptions<'a> {
    /// Options for PIE-conventional PHP packaging.  Required when `lang == php`.
    pub php: Option<package::php::PiePackageOptions<'a>>,
}

/// Package built artifacts into distributable archives.
pub fn package(
    config: &ResolvedCrateConfig,
    languages: &[Language],
    target: Option<&RustTarget>,
    output_dir: &Path,
    version: &str,
    dry_run: bool,
    options: &PackageOptions<'_>,
) -> Result<()> {
    let workspace_root = resolve_workspace_root(config);
    let ws_root = Path::new(&workspace_root);
    std::fs::create_dir_all(output_dir)?;

    for &lang in languages {
        let lang_config = publish_config_for_language(config, lang);
        let platform = target
            .map(|t| t.platform_for(lang))
            .unwrap_or_else(|| "host".to_string());
        if dry_run {
            eprintln!(
                "[dry-run] Would package {lang} for platform {platform} into {}",
                output_dir.display()
            );
            continue;
        }

        if !run_publish_hooks(lang, &lang_config)? {
            continue;
        }

        eprintln!("Packaging {lang} for platform {platform}...");

        // Defense-in-depth: if this language vendors in Registry mode, re-scan
        // the shipped binding manifest and bail if any workspace-member dep still
        // has a `path` (catches a skipped `prepare`). Cheap — a single read+parse.
        let pkg_vendor_mode = lang_config
            .vendor_mode
            .as_ref()
            .unwrap_or(&default_vendor_mode(lang))
            .clone();
        if matches!(pkg_vendor_mode, VendorMode::Registry) {
            if let Some(manifest) = resolve_binding_manifest(config, lang) {
                let manifest_abs = if manifest.is_absolute() {
                    manifest
                } else {
                    ws_root.join(&manifest)
                };
                let members = workspace::workspace_member_crates(ws_root)?;
                assert_no_member_path_deps(&manifest_abs, &members, lang)?;
            }
        }

        let result = match lang {
            Language::Ffi => {
                let t = target.context("--target required for FFI packaging")?;
                let artifact = package::c_ffi::package_c_ffi(config, t, ws_root, output_dir, version)?;
                Some(vec![artifact])
            }
            Language::Php => {
                let t = target.context("--target required for PHP packaging")?;
                let pie_opts = options
                    .php
                    .as_ref()
                    .context("--php-version (and other PHP flags) required for PHP packaging")?;
                let artifact = package::php::package_php(config, t, ws_root, output_dir, version, pie_opts)?;
                Some(vec![artifact])
            }
            Language::Go => {
                let t = target.context("--target required for Go packaging")?;
                let artifact = package::go::package_go_ffi(config, t, ws_root, output_dir, version)?;
                Some(vec![artifact])
            }
            Language::Python => {
                let t = target.context("--target required for Python packaging")?;
                let artifacts = package::python::package_python(config, t, ws_root, output_dir, version)?;
                Some(artifacts)
            }
            Language::Wasm => {
                let artifacts = package::wasm::package_wasm(config, ws_root, output_dir, version)?;
                Some(vec![artifacts])
            }
            Language::Node => {
                let t = target.context("--target required for Node packaging")?;
                let artifact = package::node::package_node(config, t, ws_root, output_dir, version)?;
                Some(vec![artifact])
            }
            Language::Ruby => {
                let t = target.context("--target required for Ruby packaging")?;
                let artifact = package::ruby::package_ruby(config, t, ws_root, output_dir, version)?;
                Some(vec![artifact])
            }
            Language::Elixir => {
                let t = target.context("--target required for Elixir packaging")?;
                let artifacts = package::elixir::package_elixir(config, t, ws_root, output_dir, version)?;
                Some(artifacts)
            }
            Language::Java => {
                let t = target.context("--target required for Java packaging")?;
                let artifact = package::java::package_java(config, t, ws_root, output_dir, version)?;
                Some(vec![artifact])
            }
            Language::Csharp => {
                let t = target.context("--target required for C# packaging")?;
                let artifact = package::csharp::package_csharp(config, t, ws_root, output_dir, version)?;
                Some(vec![artifact])
            }
            Language::Kotlin => {
                // Kotlin/JVM packaging is target-independent — Gradle produces a JVM jar.
                let artifact = package::kotlin::package_kotlin(config, ws_root, output_dir, version)?;
                Some(vec![artifact])
            }
            Language::Gleam => {
                // Gleam source packaging is target-independent.
                let artifact = package::gleam::package_gleam(config, ws_root, output_dir, version)?;
                Some(vec![artifact])
            }
            Language::Zig => {
                let t = target.context("--target required for Zig packaging")?;
                let artifact = package::zig::package_zig(config, t, ws_root, output_dir, version)?;
                Some(vec![artifact])
            }
            Language::Dart => {
                // Dart source packaging is target-independent (FRB handles cross-compilation).
                let artifact = package::dart::package_dart(config, ws_root, output_dir, version)?;
                Some(vec![artifact])
            }
            Language::Swift => {
                // Swift source packaging is target-independent; XCFramework requires xcodebuild.
                let artifact = package::swift::package_swift(config, ws_root, output_dir, version)?;
                Some(vec![artifact])
            }
            Language::Rust => {
                // CLI packaging is invoked explicitly from alef-cli, not through the language dispatch.
                eprintln!("  CLI (Rust) packaging handled separately");
                None
            }
            _ => {
                eprintln!("  packaging not yet implemented for {lang}");
                None
            }
        };

        if let Some(artifacts) = result {
            for artifact in &artifacts {
                eprintln!("  produced {}", artifact.name);
            }
        }

        // Run after hooks on success.
        run_publish_after_hooks(lang, &lang_config)?;
    }
    Ok(())
}

/// Validate that all package manifests are ready for publishing.
///
/// Checks:
/// - All required package directories exist
/// - Key manifest files are present (pyproject.toml, package.json, gemspec, etc.)
/// - Cargo.toml version can be read
pub fn validate(config: &ResolvedCrateConfig, languages: &[Language]) -> Result<Vec<String>> {
    let mut issues = Vec::new();
    let workspace_root = resolve_workspace_root(config);
    let workspace_path = Path::new(&workspace_root);

    // Check version is readable.
    if config.resolved_version().is_none() {
        issues.push(format!("cannot read version from {}", config.version_from));
    }

    // Check package directories and key manifest files exist.
    for &lang in languages {
        let pkg_dir = config.package_dir(lang);
        let pkg_path = workspace_path.join(&pkg_dir);

        // Skip languages that don't have standalone package dirs (Rust, FFI, JNI).
        // JNI is a transitive language used by Java/Kotlin bindings — no separate
        // publish artifact exists.
        if matches!(lang, Language::Rust | Language::Ffi | Language::Jni) {
            continue;
        }

        if !pkg_path.exists() {
            issues.push(format!("{lang}: package directory {pkg_dir} does not exist"));
            continue;
        }

        // Check for key manifest files per language.
        let expected_files: Vec<&str> = match lang {
            Language::Python => vec!["pyproject.toml"],
            Language::Node => vec!["package.json"],
            Language::Ruby => vec![], // gemspec name varies
            Language::Php => vec!["composer.json"],
            Language::Elixir => vec!["mix.exs"],
            Language::Go => vec!["go.mod"],
            Language::Java => vec!["pom.xml"],
            Language::Csharp => vec![], // .csproj name varies
            Language::Wasm => vec![],
            Language::R => vec!["DESCRIPTION"],
            Language::Kotlin => vec!["build.gradle.kts"],
            Language::Gleam => vec!["gleam.toml"],
            Language::Zig => vec!["build.zig"],
            Language::Dart => vec!["pubspec.yaml"],
            Language::Swift => vec!["Package.swift"],
            _ => vec![],
        };

        for file in expected_files {
            if !pkg_path.join(file).exists() {
                issues.push(format!("{lang}: missing {pkg_dir}/{file}"));
            }
        }

        if lang == Language::Ruby {
            validate_ruby_gemspecs(&pkg_path, &pkg_dir, &mut issues);
        }
        validate_language_manifest(config, lang, workspace_path, &pkg_dir, &pkg_path, &mut issues);
    }

    Ok(issues)
}

fn validate_language_manifest(
    config: &ResolvedCrateConfig,
    lang: Language,
    workspace_root: &Path,
    pkg_dir: &str,
    pkg_path: &Path,
    issues: &mut Vec<String>,
) {
    match lang {
        Language::Elixir => validate_elixir_manifest(config, pkg_dir, pkg_path, issues),
        Language::Php => validate_php_manifests(pkg_dir, pkg_path, workspace_root, issues),
        Language::Csharp => validate_csharp_project(config, workspace_root, pkg_dir, issues),
        Language::Go => validate_go_module(config, pkg_dir, pkg_path, issues),
        Language::Java => validate_java_manifest(config, pkg_dir, pkg_path, issues),
        Language::Dart => validate_dart_manifest(config, pkg_dir, pkg_path, issues),
        Language::Swift => validate_swift_manifest(pkg_dir, pkg_path, issues),
        Language::Zig => validate_zig_manifest(config, pkg_dir, pkg_path, issues),
        _ => {}
    }
}

fn validate_elixir_manifest(config: &ResolvedCrateConfig, pkg_dir: &str, pkg_path: &Path, issues: &mut Vec<String>) {
    let mix_path = pkg_path.join("mix.exs");
    let Ok(content) = std::fs::read_to_string(&mix_path) else {
        return;
    };
    let targets = elixir_nif_targets(config).join(" ");
    if !content.contains(&format!("targets: ~w({targets})")) {
        issues.push(format!(
            "elixir: {pkg_dir}/mix.exs rustler_crates targets must match configured nif_targets: {targets}"
        ));
    }
}

fn validate_php_manifests(pkg_dir: &str, pkg_path: &Path, workspace_root: &Path, issues: &mut Vec<String>) {
    let package_manifest = pkg_path.join("composer.json");
    let root_manifest = workspace_root.join("composer.json");
    let Ok(package_json) = read_json(&package_manifest) else {
        return;
    };
    let Ok(root_json) = read_json(&root_manifest) else {
        issues.push("php: missing root composer.json".to_string());
        return;
    };

    if psr4_path(&package_json) != Some("src/") {
        issues.push(format!("php: {pkg_dir}/composer.json PSR-4 path must be src/"));
    }
    if psr4_path(&root_json) != Some("packages/php/src/") {
        issues.push("php: root composer.json PSR-4 path must be packages/php/src/".to_string());
    }

    let mut package_without_autoload = package_json.clone();
    let mut root_without_autoload = root_json.clone();
    if let Some(obj) = package_without_autoload.as_object_mut() {
        obj.remove("autoload");
    }
    if let Some(obj) = root_without_autoload.as_object_mut() {
        obj.remove("autoload");
    }
    if package_without_autoload != root_without_autoload {
        issues.push("php: root composer.json metadata must stay in sync with packages/php/composer.json".to_string());
    }
}

fn validate_csharp_project(
    config: &ResolvedCrateConfig,
    workspace_root: &Path,
    pkg_dir: &str,
    issues: &mut Vec<String>,
) {
    let namespace = config.csharp_namespace();
    let configured_project_file = config
        .project_file_for_language(Language::Csharp)
        .map(PathBuf::from)
        .filter(|path| path.extension().is_some_and(|ext| ext == "csproj"));
    let nested = PathBuf::from(pkg_dir)
        .join(&namespace)
        .join(format!("{namespace}.csproj"));
    let root = PathBuf::from(pkg_dir).join(format!("{namespace}.csproj"));
    let nested_path = workspace_root.join(&nested);
    let root_path = workspace_root.join(&root);
    let project_file = configured_project_file.unwrap_or_else(|| {
        if nested_path.exists() {
            nested.clone()
        } else {
            root.clone()
        }
    });
    let project_path = if project_file.is_absolute() {
        project_file.clone()
    } else {
        workspace_root.join(&project_file)
    };

    if root_path.exists() && nested_path.exists() {
        issues.push(format!(
            "csharp: stale root project {pkg_dir}/{namespace}.csproj exists; keep only {pkg_dir}/{namespace}/{namespace}.csproj"
        ));
    }
    let Ok(content) = std::fs::read_to_string(&project_path) else {
        issues.push(format!("csharp: missing {}", project_file.display()));
        return;
    };
    for required in [
        r#"<None Include="../../../LICENSE" Pack="true" PackagePath="/" />"#,
        r#"<None Include="runtimes/**" Pack="true" PackagePath="runtimes/" CopyToOutputDirectory="PreserveNewest" />"#,
        r#"<Compile Include="../src/**/*.cs" />"#,
    ] {
        if !content.contains(required) {
            issues.push(format!("csharp: {namespace}.csproj missing expected item: {required}"));
        }
    }
}

fn validate_go_module(config: &ResolvedCrateConfig, pkg_dir: &str, pkg_path: &Path, issues: &mut Vec<String>) {
    let go_mod = pkg_path.join("go.mod");
    let Ok(content) = std::fs::read_to_string(&go_mod) else {
        return;
    };
    let module = content
        .lines()
        .find_map(|line| line.strip_prefix("module ").map(str::trim));
    let expected = config.go_module();
    if module != Some(expected.as_str()) {
        issues.push(format!("go: {pkg_dir}/go.mod module must be {expected}"));
        return;
    }
    if let Some(major) = go_major_suffix(&expected) {
        let expected_dir = format!("packages/go/{major}");
        if pkg_dir != expected_dir {
            issues.push(format!(
                "go: module path {expected} requires package directory {expected_dir}; set go scaffold output or use a non-/vN module path"
            ));
        }
    }
}

fn validate_java_manifest(config: &ResolvedCrateConfig, pkg_dir: &str, pkg_path: &Path, issues: &mut Vec<String>) {
    let pom = pkg_path.join("pom.xml");
    let Ok(content) = std::fs::read_to_string(&pom) else {
        return;
    };
    let group_id = config.java_group_id();
    let artifact_id = config.java_artifact_id();
    if !content.contains(&format!("<groupId>{group_id}</groupId>")) {
        issues.push(format!("java: {pkg_dir}/pom.xml groupId must be {group_id}"));
    }
    if !content.contains(&format!("<artifactId>{artifact_id}</artifactId>")) {
        issues.push(format!("java: {pkg_dir}/pom.xml artifactId must be {artifact_id}"));
    }
}

fn validate_dart_manifest(config: &ResolvedCrateConfig, pkg_dir: &str, pkg_path: &Path, issues: &mut Vec<String>) {
    let pubspec = pkg_path.join("pubspec.yaml");
    let Ok(content) = std::fs::read_to_string(&pubspec) else {
        return;
    };
    let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Value>(&content) else {
        issues.push(format!("dart: {pkg_dir}/pubspec.yaml is not valid YAML"));
        return;
    };
    let name = yaml.get("name").and_then(|v| v.as_str());
    let expected = config.dart_pubspec_name();
    if name != Some(expected.as_str()) {
        issues.push(format!("dart: {pkg_dir}/pubspec.yaml name must be {expected}"));
    }
    for required in ["version", "description", "repository"] {
        if yaml.get(required).is_none() {
            issues.push(format!("dart: {pkg_dir}/pubspec.yaml missing {required}"));
        }
    }
}

fn validate_swift_manifest(pkg_dir: &str, pkg_path: &Path, issues: &mut Vec<String>) {
    let pkg_manifest = pkg_path.join("Package.swift");
    if let Ok(content) = std::fs::read_to_string(&pkg_manifest)
        && !content.contains("Sources/RustBridge")
    {
        issues.push(format!(
            "swift: {pkg_dir}/Package.swift must include RustBridge source targets"
        ));
    }
}

fn validate_zig_manifest(config: &ResolvedCrateConfig, pkg_dir: &str, pkg_path: &Path, issues: &mut Vec<String>) {
    let zon = pkg_path.join("build.zig.zon");
    let Ok(content) = std::fs::read_to_string(&zon) else {
        issues.push(format!("zig: missing {pkg_dir}/build.zig.zon"));
        return;
    };
    let expected_name = format!(".name = .{}", config.zig_module_name());
    if !content.contains(&expected_name) {
        issues.push(format!(
            "zig: {pkg_dir}/build.zig.zon name must be {}",
            config.zig_module_name()
        ));
    }
    for path in ["\"build.zig\"", "\"build.zig.zon\"", "\"src\""] {
        if !content.contains(path) {
            issues.push(format!("zig: {pkg_dir}/build.zig.zon paths must include {path}"));
        }
    }
}

fn read_json(path: &Path) -> Result<serde_json::Value> {
    let content = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
    serde_json::from_str(&content).with_context(|| format!("parsing {}", path.display()))
}

fn psr4_path(json: &serde_json::Value) -> Option<&str> {
    json.get("autoload")?
        .get("psr-4")?
        .as_object()?
        .values()
        .next()?
        .as_str()
}

fn go_major_suffix(module: &str) -> Option<String> {
    let suffix = module.rsplit('/').next()?;
    let major = suffix.strip_prefix('v')?;
    if !major.is_empty() && major.chars().all(|c| c.is_ascii_digit()) && major.parse::<u32>().ok()? >= 2 {
        Some(suffix.to_string())
    } else {
        None
    }
}

fn elixir_nif_targets(config: &ResolvedCrateConfig) -> Vec<String> {
    config
        .elixir
        .as_ref()
        .filter(|elixir| !elixir.nif_targets.is_empty())
        .map(|elixir| elixir.nif_targets.clone())
        .unwrap_or_else(|| {
            [
                "aarch64-apple-darwin",
                "aarch64-unknown-linux-gnu",
                "x86_64-unknown-linux-gnu",
                "x86_64-pc-windows-gnu",
            ]
            .into_iter()
            .map(str::to_string)
            .collect()
        })
}

fn validate_ruby_gemspecs(pkg_path: &Path, pkg_dir: &str, issues: &mut Vec<String>) {
    let mut root_gemspecs = Vec::new();
    let mut nested_gemspecs = Vec::new();
    collect_gemspecs(pkg_path, pkg_path, &mut root_gemspecs, &mut nested_gemspecs);

    if root_gemspecs.is_empty() {
        issues.push(format!("ruby: missing {pkg_dir}/*.gemspec"));
    }
    for nested in nested_gemspecs {
        issues.push(format!(
            "ruby: stale nested gemspec {} (only {pkg_dir}/*.gemspec should remain)",
            nested.display()
        ));
    }
}

fn collect_gemspecs(root: &Path, dir: &Path, root_gemspecs: &mut Vec<PathBuf>, nested_gemspecs: &mut Vec<PathBuf>) {
    if dir
        .strip_prefix(root)
        .ok()
        .is_some_and(|rel| rel.components().any(|component| component.as_os_str() == "vendor"))
    {
        return;
    }
    let Ok(entries) = std::fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        let Ok(file_type) = entry.file_type() else {
            continue;
        };
        if file_type.is_dir() {
            collect_gemspecs(root, &path, root_gemspecs, nested_gemspecs);
            continue;
        }
        if !file_type.is_file() || path.extension().is_none_or(|ext| ext != "gemspec") {
            continue;
        }
        if path.parent() == Some(root) {
            root_gemspecs.push(path);
        } else {
            nested_gemspecs.push(path);
        }
    }
}

/// Get the publish configuration for a language, falling back to defaults.
fn publish_config_for_language(config: &ResolvedCrateConfig, lang: Language) -> PublishLanguageConfig {
    if let Some(publish) = &config.publish {
        let lang_str = lang.to_string();
        if let Some(lang_config) = publish.languages.get(&lang_str) {
            return lang_config.clone();
        }
    }
    PublishLanguageConfig::default()
}

/// Resolve the core crate directory path.
fn resolve_core_crate_dir(config: &ResolvedCrateConfig) -> String {
    if let Some(publish) = &config.publish {
        if let Some(core_crate) = &publish.core_crate {
            return core_crate.clone();
        }
    }
    // Fall back to deriving from [crate].sources.
    let dir = config.core_crate_dir();
    if !config.sources.is_empty() {
        let first = config.sources[0].to_string_lossy();
        if first.contains("crates/") {
            return format!("crates/{dir}");
        }
    }
    dir
}

/// Resolve the workspace root directory.
fn resolve_workspace_root(config: &ResolvedCrateConfig) -> String {
    config
        .workspace_root
        .as_ref()
        .map(|p| p.to_string_lossy().to_string())
        .unwrap_or_else(|| ".".to_string())
}

/// Resolve the path of the binding `Cargo.toml` that SHIPS for a source-build
/// language — the manifest a consumer compiles on their own machine.
///
/// These are the manifests whose workspace-member `path` dependencies must be
/// rewritten to registry version-dependencies (since the workspace is not
/// shipped alongside the package). Crate/directory names are derived from config
/// accessors, never hardcoded.
///
/// Returns `None` for languages that ship no compilable manifest (e.g. Zig).
fn resolve_binding_manifest(config: &ResolvedCrateConfig, lang: Language) -> Option<PathBuf> {
    let pkg_dir = config.package_dir(lang);
    match lang {
        // Ruby: rb-sys compiles `{pkg}/ext/{ext}/native/Cargo.toml`, where the
        // ext dir is `{core_crate_dir}_rb` (matching scaffold_ruby_cargo).
        Language::Ruby => {
            let ext = format!("{}_rb", config.core_crate_dir().replace('-', "_"));
            Some(
                Path::new(&pkg_dir)
                    .join("ext")
                    .join(ext)
                    .join("native")
                    .join("Cargo.toml"),
            )
        }
        // Elixir: the rustler NIF crate at `{pkg}/native/{app}_nif/Cargo.toml`.
        Language::Elixir => {
            let nif = format!("{}_nif", config.elixir_app_name());
            Some(Path::new(&pkg_dir).join("native").join(nif).join("Cargo.toml"))
        }
        // Python: the maturin source build uses the binding crate manifest at
        // `crates/{py_crate}/Cargo.toml` (same crate the python packager uses).
        Language::Python => {
            let py_crate =
                crate_name_from_output(config, Language::Python).unwrap_or_else(|| format!("{}-py", config.name));
            Some(Path::new("crates").join(py_crate).join("Cargo.toml"))
        }
        // PHP: the ext-php-rs binding crate at `crates/{php_crate}/Cargo.toml`.
        Language::Php => {
            let php_crate =
                crate_name_from_output(config, Language::Php).unwrap_or_else(|| format!("{}-php", config.name));
            Some(Path::new("crates").join(php_crate).join("Cargo.toml"))
        }
        // Swift: the swift-bridge crate ships at `{pkg}/rust/Cargo.toml`.
        Language::Swift => Some(Path::new(&pkg_dir).join("rust").join("Cargo.toml")),
        _ => None,
    }
}

/// Re-scan a shipped binding manifest and bail if any workspace-member dep still
/// carries a `path` (a cheap defense-in-depth check that `prepare()` ran).
fn assert_no_member_path_deps(
    manifest_path: &Path,
    members: &workspace::WorkspaceMembers,
    lang: Language,
) -> Result<()> {
    let content = match std::fs::read_to_string(manifest_path) {
        Ok(c) => c,
        // A missing manifest → nothing to assert (matches prepare()'s skip).
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
        // Any other IO error (permissions, dangling symlink) is a real failure.
        Err(e) => return Err(e).with_context(|| format!("reading {}", manifest_path.display())),
    };
    let doc: toml_edit::DocumentMut = content
        .parse()
        .with_context(|| format!("parsing {}", manifest_path.display()))?;

    let section_has_member_path = |table: Option<&toml_edit::Item>| -> Option<String> {
        let table = table?.as_table_like()?;
        for (key, item) in table.iter() {
            if members.names.contains(key) && item.as_table_like().is_some_and(|t| t.contains_key("path")) {
                return Some(key.to_string());
            }
        }
        None
    };

    for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
        if let Some(dep) = section_has_member_path(doc.get(section)) {
            anyhow::bail!(
                "{lang}: workspace-member dependency '{dep}' in [{section}] of {} still has a `path` — \
                 did `alef publish prepare` run for Registry vendor mode?",
                manifest_path.display()
            );
        }
    }
    if let Some(targets) = doc.get("target").and_then(|t| t.as_table_like()) {
        for (cfg, cfg_item) in targets.iter() {
            let Some(cfg_tbl) = cfg_item.as_table_like() else {
                continue;
            };
            for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
                if let Some(dep) = section_has_member_path(cfg_tbl.get(section)) {
                    anyhow::bail!(
                        "{lang}: workspace-member dependency '{dep}' in \
                         [target.{cfg}.{section}] of {} still has a `path` — \
                         did `alef publish prepare` run for Registry vendor mode?",
                        manifest_path.display()
                    );
                }
            }
        }
    }
    Ok(())
}

/// Resolve the vendor destination directory for a language.
fn resolve_vendor_dest(config: &ResolvedCrateConfig, lang: Language) -> String {
    let pkg_dir = config.package_dir(lang);
    match lang {
        Language::Ruby => format!("{pkg_dir}/vendor"),
        Language::Elixir => {
            let app_name = config.elixir_app_name();
            format!("{pkg_dir}/native/{app_name}/vendor")
        }
        Language::R => format!("{pkg_dir}/src/rust"),
        _ => format!("{pkg_dir}/vendor"),
    }
}

/// Return the default vendor mode for a language.
fn default_vendor_mode(lang: Language) -> VendorMode {
    match lang {
        // Source-build languages compile the Rust crate from source on the
        // user's machine, so their path dependencies are rewritten to
        // registry version-dependencies rather than vendored.
        Language::Ruby | Language::Elixir | Language::Python | Language::Php | Language::Swift => VendorMode::Registry,
        Language::R => VendorMode::Full,
        _ => VendorMode::None,
    }
}

/// Whether a language depends on the C FFI crate for its bindings.
fn is_ffi_dependent(lang: Language) -> bool {
    matches!(lang, Language::Go | Language::Java | Language::Csharp)
}

/// Run precondition check and before hooks for a language.
///
/// Returns `true` if the main command should proceed, `false` if the
/// precondition failed (skip with warning).
fn run_publish_hooks(lang: Language, lang_config: &PublishLanguageConfig) -> Result<bool> {
    // Check precondition.
    if let Some(precondition) = &lang_config.precondition {
        let status = std::process::Command::new("sh")
            .arg("-c")
            .arg(precondition)
            .status()
            .with_context(|| format!("running precondition for {lang}: {precondition}"))?;
        if !status.success() {
            eprintln!("Skipping {lang}: precondition failed ({precondition})");
            return Ok(false);
        }
    }

    // Run before hooks.
    if let Some(before) = &lang_config.before {
        for cmd in before.commands() {
            run_shell_command(cmd)?;
        }
    }

    Ok(true)
}

/// Run after hooks for a language after successful completion.
///
/// After hooks run only when the main operation succeeds (symmetrical with before hooks,
/// which run only before a successful start). This ensures cleanup/finalization logic
/// only runs when the operation completed.
fn run_publish_after_hooks(lang: Language, lang_config: &PublishLanguageConfig) -> Result<()> {
    if let Some(after) = &lang_config.after {
        for cmd in after.commands() {
            run_shell_command(cmd).with_context(|| format!("running after hook for {lang}: {cmd}"))?;
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::config::output::StringOrVec;
    #[cfg(not(target_os = "windows"))]
    use std::fs;
    use std::path::{Path, PathBuf};
    use tempfile::TempDir;

    fn make_temp_marker_file() -> (TempDir, PathBuf) {
        let temp_dir = TempDir::new().unwrap();
        let marker = temp_dir.path().join("marker.txt");
        (temp_dir, marker)
    }

    #[test]
    #[cfg(not(target_os = "windows"))] // sh + > redirect doesn't work portably on Windows CI runners
    fn test_run_publish_hooks_runs_before_only() {
        let (_temp_dir, marker) = make_temp_marker_file();
        let marker_str = marker.to_str().unwrap();

        // Config with before hook only.
        let config = PublishLanguageConfig {
            before: Some(StringOrVec::Single(format!("echo 'before' > {marker_str}"))),
            ..Default::default()
        };

        let result = run_publish_hooks(Language::Python, &config);
        assert!(result.is_ok());
        assert!(marker.exists(), "before hook should have created marker file");
    }

    #[test]
    fn test_run_publish_hooks_precondition_failure_skips() {
        let (_temp_dir, marker) = make_temp_marker_file();
        let marker_str = marker.to_str().unwrap();

        let config = PublishLanguageConfig {
            precondition: Some("false".to_string()), // Always fails
            before: Some(StringOrVec::Single(format!("echo 'before' > {marker_str}"))),
            ..Default::default()
        };

        let result = run_publish_hooks(Language::Python, &config);
        assert!(result.is_ok());
        // Precondition failed, so before hook should not run
        assert!(!marker.exists(), "before hook should not run when precondition fails");
    }

    #[cfg(not(target_os = "windows"))] // sh + > redirect doesn't work portably on Windows CI runners
    #[test]
    fn test_run_publish_after_hooks_runs_after_only() {
        let (_temp_dir, marker) = make_temp_marker_file();
        let marker_str = marker.to_str().unwrap();

        let config = PublishLanguageConfig {
            after: Some(StringOrVec::Single(format!("echo 'after' > {marker_str}"))),
            ..Default::default()
        };

        let result = run_publish_after_hooks(Language::Python, &config);
        assert!(result.is_ok());
        assert!(marker.exists(), "after hook should have created marker file");

        let content = fs::read_to_string(&marker).unwrap();
        assert!(content.contains("after"));
    }

    #[test]
    fn default_vendor_mode_source_build_langs_use_registry() {
        assert_eq!(default_vendor_mode(Language::Python), VendorMode::Registry);
        assert_eq!(default_vendor_mode(Language::Ruby), VendorMode::Registry);
        assert_eq!(default_vendor_mode(Language::Elixir), VendorMode::Registry);
        assert_eq!(default_vendor_mode(Language::Php), VendorMode::Registry);
        assert_eq!(default_vendor_mode(Language::Swift), VendorMode::Registry);
        assert_eq!(default_vendor_mode(Language::R), VendorMode::Full);
        assert_eq!(default_vendor_mode(Language::Zig), VendorMode::None);
    }

    fn ruby_validate_config(package_dir: &Path, version_manifest: &Path) -> ResolvedCrateConfig {
        let cfg: crate::core::config::NewAlefConfig = toml::from_str(&format!(
            r#"
[workspace]
languages = ["ruby"]

[[crates]]
name = "my-lib"
sources = ["src/lib.rs"]
version_from = "{}"

[crates.ruby]
scaffold_output = "{}"
"#,
            version_manifest.display(),
            package_dir.display(),
        ))
        .unwrap();
        cfg.resolve().unwrap().remove(0)
    }

    #[test]
    fn validate_ruby_detects_nested_stale_gemspecs() {
        let temp_dir = TempDir::new().unwrap();
        let root = temp_dir.path();
        let package_dir = root.join("packages/ruby");
        std::fs::create_dir_all(package_dir.join("ext/my_lib_rb")).unwrap();
        std::fs::write(
            root.join("Cargo.toml"),
            "[package]\nname = \"my-lib\"\nversion = \"1.2.3\"\n",
        )
        .unwrap();
        std::fs::write(package_dir.join("my_lib.gemspec"), "Gem::Specification.new\n").unwrap();
        std::fs::write(
            package_dir.join("ext/my_lib_rb/my_lib.gemspec"),
            "Gem::Specification.new\n",
        )
        .unwrap();

        let config = ruby_validate_config(&package_dir, &root.join("Cargo.toml"));
        let issues = validate(&config, &[Language::Ruby]).unwrap();

        assert!(
            issues.iter().any(|issue| issue.contains("stale nested gemspec")),
            "nested gemspec must be reported; got: {issues:?}"
        );
    }

    #[test]
    fn validate_ruby_requires_root_gemspec() {
        let temp_dir = TempDir::new().unwrap();
        let root = temp_dir.path();
        let package_dir = root.join("packages/ruby");
        std::fs::create_dir_all(&package_dir).unwrap();
        std::fs::write(
            root.join("Cargo.toml"),
            "[package]\nname = \"my-lib\"\nversion = \"1.2.3\"\n",
        )
        .unwrap();

        let config = ruby_validate_config(&package_dir, &root.join("Cargo.toml"));
        let issues = validate(&config, &[Language::Ruby]).unwrap();

        assert!(
            issues
                .iter()
                .any(|issue| issue.contains("missing") && issue.contains("*.gemspec")),
            "missing root gemspec must be reported; got: {issues:?}"
        );
    }

    fn validate_config_for(root: &Path, language: &str, extra: &str) -> ResolvedCrateConfig {
        let cfg: crate::core::config::NewAlefConfig = toml::from_str(&format!(
            r#"
[workspace]
languages = ["{language}"]

[[crates]]
name = "my-lib"
sources = ["src/lib.rs"]
version_from = "{}"

[crates.scaffold]
repository = "https://github.com/acme/my-lib"
description = "My library"
license = "MIT"

{extra}
"#,
            root.join("Cargo.toml").display(),
        ))
        .unwrap();
        let mut config = cfg.resolve().unwrap().remove(0);
        config.workspace_root = Some(root.to_path_buf());
        config
    }

    #[test]
    fn validate_go_reports_v2_layout_mismatch() {
        let temp_dir = TempDir::new().unwrap();
        let root = temp_dir.path();
        std::fs::write(
            root.join("Cargo.toml"),
            "[package]\nname = \"my-lib\"\nversion = \"1.2.3\"\n",
        )
        .unwrap();
        std::fs::create_dir_all(root.join("packages/go")).unwrap();
        std::fs::write(
            root.join("packages/go/go.mod"),
            "module github.com/acme/my-lib/v2\n\ngo 1.26\n",
        )
        .unwrap();

        let config = validate_config_for(
            root,
            "go",
            r#"
[crates.go]
module = "github.com/acme/my-lib/v2"
"#,
        );
        let issues = validate(&config, &[Language::Go]).unwrap();

        assert!(
            issues
                .iter()
                .any(|issue| issue.contains("requires package directory packages/go/v2")),
            "v2 module layout mismatch must be reported; got: {issues:?}"
        );
    }

    #[test]
    fn validate_php_reports_root_psr4_mismatch() {
        let temp_dir = TempDir::new().unwrap();
        let root = temp_dir.path();
        std::fs::write(
            root.join("Cargo.toml"),
            "[package]\nname = \"my-lib\"\nversion = \"1.2.3\"\n",
        )
        .unwrap();
        std::fs::create_dir_all(root.join("packages/php")).unwrap();
        let composer = r#"{
  "name": "acme/my-lib",
  "autoload": {"psr-4": {"Acme\\MyLib\\": "src/"}}
}
"#;
        std::fs::write(root.join("packages/php/composer.json"), composer).unwrap();
        std::fs::write(root.join("composer.json"), composer).unwrap();

        let config = validate_config_for(root, "php", "");
        let issues = validate(&config, &[Language::Php]).unwrap();

        assert!(
            issues
                .iter()
                .any(|issue| issue.contains("PSR-4 path must be packages/php/src/")),
            "root PSR-4 mismatch must be reported; got: {issues:?}"
        );
    }

    #[test]
    fn validate_csharp_reports_stale_root_project() {
        let temp_dir = TempDir::new().unwrap();
        let root = temp_dir.path();
        std::fs::write(
            root.join("Cargo.toml"),
            "[package]\nname = \"my-lib\"\nversion = \"1.2.3\"\n",
        )
        .unwrap();
        std::fs::create_dir_all(root.join("packages/csharp/MyLib")).unwrap();
        let project = crate::scaffold::render_csharp_csproj(&validate_config_for(root, "csharp", ""), "1.2.3");
        std::fs::write(root.join("packages/csharp/MyLib/MyLib.csproj"), &project).unwrap();
        std::fs::write(root.join("packages/csharp/MyLib.csproj"), &project).unwrap();

        let config = validate_config_for(root, "csharp", "");
        let issues = validate(&config, &[Language::Csharp]).unwrap();

        assert!(
            issues.iter().any(|issue| issue.contains("stale root project")),
            "stale root csproj must be reported; got: {issues:?}"
        );
    }

    #[test]
    fn validate_dart_and_zig_check_central_metadata() {
        let temp_dir = TempDir::new().unwrap();
        let root = temp_dir.path();
        std::fs::write(
            root.join("Cargo.toml"),
            "[package]\nname = \"my-lib\"\nversion = \"1.2.3\"\n",
        )
        .unwrap();
        std::fs::create_dir_all(root.join("packages/dart")).unwrap();
        std::fs::write(
            root.join("packages/dart/pubspec.yaml"),
            "name: wrong\nversion: 1.2.3\ndescription: My library\nrepository: https://github.com/acme/my-lib\n",
        )
        .unwrap();
        let dart_config = validate_config_for(root, "dart", "");
        let dart_issues = validate(&dart_config, &[Language::Dart]).unwrap();
        assert!(
            dart_issues
                .iter()
                .any(|issue| issue.contains("pubspec.yaml name must be my_lib")),
            "Dart name mismatch must be reported; got: {dart_issues:?}"
        );

        std::fs::create_dir_all(root.join("packages/zig")).unwrap();
        std::fs::write(root.join("packages/zig/build.zig"), "").unwrap();
        std::fs::write(
            root.join("packages/zig/build.zig.zon"),
            ".{ .name = .wrong, .paths = .{} }\n",
        )
        .unwrap();
        let zig_config = validate_config_for(root, "zig", "");
        let zig_issues = validate(&zig_config, &[Language::Zig]).unwrap();
        assert!(
            zig_issues
                .iter()
                .any(|issue| issue.contains("build.zig.zon name must be my_lib")),
            "Zig name mismatch must be reported; got: {zig_issues:?}"
        );
    }

    #[test]
    fn test_run_publish_after_hooks_no_after_is_noop() {
        let config = PublishLanguageConfig::default();
        // Config has no after hook
        let result = run_publish_after_hooks(Language::Python, &config);
        assert!(result.is_ok(), "after hooks should succeed when not specified");
    }
    #[cfg(not(target_os = "windows"))] // sh + > redirect doesn't work portably on Windows CI runners
    #[test]
    fn test_run_publish_after_hooks_multiple_commands() {
        let temp_dir = TempDir::new().unwrap();
        let marker1 = temp_dir.path().join("marker1.txt");
        let marker2 = temp_dir.path().join("marker2.txt");

        let marker1_str = marker1.to_str().unwrap();
        let marker2_str = marker2.to_str().unwrap();

        let config = PublishLanguageConfig {
            after: Some(StringOrVec::Multiple(vec![
                format!("echo 'after1' > {marker1_str}"),
                format!("echo 'after2' > {marker2_str}"),
            ])),
            ..Default::default()
        };

        let result = run_publish_after_hooks(Language::Python, &config);
        assert!(result.is_ok());
        assert!(marker1.exists(), "first after command should execute");
        assert!(marker2.exists(), "second after command should execute");
    }

    #[test]
    fn test_run_publish_after_hooks_failure_propagates_error() {
        let config = PublishLanguageConfig {
            after: Some(StringOrVec::Single("false".to_string())), // Command fails
            ..Default::default()
        };

        let result = run_publish_after_hooks(Language::Python, &config);
        assert!(result.is_err(), "after hook failure should propagate error");
    }

    #[cfg(not(target_os = "windows"))] // sh + > redirect doesn't work portably on Windows CI runners
    #[test]
    fn test_publish_hooks_full_lifecycle_success() {
        let temp_dir = TempDir::new().unwrap();
        let before_marker = temp_dir.path().join("before.txt");
        let after_marker = temp_dir.path().join("after.txt");

        let before_str = before_marker.to_str().unwrap();
        let after_str = after_marker.to_str().unwrap();

        let config = PublishLanguageConfig {
            before: Some(StringOrVec::Single(format!("echo 'before' > {before_str}"))),
            after: Some(StringOrVec::Single(format!("echo 'after' > {after_str}"))),
            ..Default::default()
        };

        // Simulate before hook
        let before_result = run_publish_hooks(Language::Python, &config);
        assert!(before_result.is_ok());
        assert!(before_marker.exists(), "before hook should run");

        // Simulate successful operation (no actual work)

        // Simulate after hook
        let after_result = run_publish_after_hooks(Language::Python, &config);
        assert!(after_result.is_ok());
        assert!(after_marker.exists(), "after hook should run on success");
    }

    // ---------------------------------------------------------------------
    // S4: Registry vendor mode wired into prepare()
    // ---------------------------------------------------------------------

    /// Build a temp workspace with a core crate `my-lib` and a Python binding
    /// crate `my-lib-py` whose manifest carries a workspace-member path dep.
    /// Returns (TempDir, resolved config wired to the temp root).
    fn setup_registry_workspace() -> (TempDir, ResolvedCrateConfig) {
        let tmp = TempDir::new().unwrap();
        let root = tmp.path();

        std::fs::write(
            root.join("Cargo.toml"),
            r#"
[workspace]
resolver = "2"
members = ["crates/my-lib", "crates/my-lib-py"]

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

        // Core member crate.
        std::fs::create_dir_all(root.join("crates/my-lib/src")).unwrap();
        std::fs::write(root.join("crates/my-lib/src/lib.rs"), "pub fn hi() {}").unwrap();
        std::fs::write(
            root.join("crates/my-lib/Cargo.toml"),
            "[package]\nname = \"my-lib\"\nversion = \"3.1.4\"\nedition = \"2021\"\n",
        )
        .unwrap();

        // Python binding crate with a workspace-member path dep + a registry dep.
        std::fs::create_dir_all(root.join("crates/my-lib-py/src")).unwrap();
        std::fs::write(root.join("crates/my-lib-py/src/lib.rs"), "pub fn hi() {}").unwrap();
        std::fs::write(
            root.join("crates/my-lib-py/Cargo.toml"),
            r#"
[package]
name = "my-lib-py"
version = "3.1.4"
edition = "2021"

[dependencies]
my-lib = { path = "../my-lib", features = ["x"] }
anyhow = "1"
"#,
        )
        .unwrap();

        let cfg: crate::core::config::NewAlefConfig = toml::from_str(
            r#"
[workspace]
languages = ["python"]
[[crates]]
name = "my-lib"
sources = ["crates/my-lib/src/lib.rs"]
"#,
        )
        .unwrap();
        let mut config = cfg.resolve().unwrap().remove(0);
        config.workspace_root = Some(root.to_path_buf());
        // resolved_version() reads version_from as a path; point it at the temp root.
        config.version_from = root.join("Cargo.toml").to_string_lossy().to_string();

        (tmp, config)
    }

    fn read_py_manifest(root: &Path) -> toml_edit::DocumentMut {
        let manifest = root.join("crates/my-lib-py/Cargo.toml");
        std::fs::read_to_string(manifest).unwrap().parse().unwrap()
    }

    #[test]
    fn resolve_binding_manifest_python_path() {
        let (_tmp, config) = setup_registry_workspace();
        let path = resolve_binding_manifest(&config, Language::Python).unwrap();
        assert_eq!(path, Path::new("crates").join("my-lib-py").join("Cargo.toml"));
    }

    #[test]
    fn resolve_binding_manifest_zig_is_none() {
        let (_tmp, config) = setup_registry_workspace();
        assert!(resolve_binding_manifest(&config, Language::Zig).is_none());
    }

    #[test]
    fn prepare_registry_rewrites_member_path_deps() {
        let (tmp, config) = setup_registry_workspace();
        let root = tmp.path();

        prepare(&config, &[Language::Python], None, false, false).unwrap();

        let doc = read_py_manifest(root);
        let deps = doc["dependencies"].as_table().unwrap();
        let my_lib = deps["my-lib"].as_inline_table().unwrap();
        assert_eq!(my_lib.get("version").and_then(|v| v.as_str()), Some("3.1.4"));
        assert!(my_lib.get("path").is_none(), "path must be stripped");
        assert!(my_lib.get("features").is_some(), "features preserved");
        // Registry dep untouched.
        assert_eq!(deps["anyhow"].as_str(), Some("1"));
    }

    #[test]
    fn prepare_registry_dry_run_mutates_nothing() {
        let (tmp, config) = setup_registry_workspace();
        let root = tmp.path();

        let before = std::fs::read_to_string(root.join("crates/my-lib-py/Cargo.toml")).unwrap();
        prepare(&config, &[Language::Python], None, true, false).unwrap();
        let after = std::fs::read_to_string(root.join("crates/my-lib-py/Cargo.toml")).unwrap();

        assert_eq!(before, after, "dry-run must not modify the manifest");
        // The path dep must still be present.
        let doc: toml_edit::DocumentMut = after.parse().unwrap();
        let my_lib = doc["dependencies"]["my-lib"].as_inline_table().unwrap();
        assert!(my_lib.get("path").is_some(), "dry-run leaves path intact");
    }

    #[test]
    fn assert_no_member_path_deps_detects_skipped_prepare() {
        let (_tmp, config) = setup_registry_workspace();
        let ws_root = config.workspace_root.clone().unwrap();
        let manifest = ws_root.join(resolve_binding_manifest(&config, Language::Python).unwrap());
        let members = workspace::workspace_member_crates(&ws_root).unwrap();

        // Before prepare: the member path dep is present → must bail.
        let err = assert_no_member_path_deps(&manifest, &members, Language::Python).unwrap_err();
        assert!(err.to_string().contains("still has a `path`"), "got: {err}");

        // After prepare: clean.
        vendor::rewrite_path_deps_to_registry(&manifest, &members, "3.1.4").unwrap();
        assert_no_member_path_deps(&manifest, &members, Language::Python).unwrap();
    }
}
