//! Generator module: Generate Rust code from ResolvedSpec
//!
//! Implements the M1 generation path from PLAN.md:
//! - prepend `use ...` imports for imports + deps
//! - write generated `.rs` files
//! - generate `mod.rs` contents
//! - owned-tree orphan cleanup with `.spec-generated` marker safety rails

use crate::syntax::validate_expect_expr;
use crate::types::{DepRef, ResolvedMoleculeTest, ResolvedSpec};
use crate::{Result, SpecError};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs::{self, File};
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use walkdir::WalkDir;

const GENERATED_MARKER: &str = ".spec-generated";

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct GenerateOptions {
    pub allow_unsafe_local_test_expect: bool,
}

fn build_fn_signature(spec: &ResolvedSpec) -> String {
    let params = spec
        .contract
        .as_ref()
        .and_then(|c| c.inputs.as_ref())
        .map(|inputs| {
            inputs
                .iter()
                .map(|(name, ty)| format!("{name}: {ty}"))
                .collect::<Vec<_>>()
                .join(", ")
        })
        .unwrap_or_default();

    let return_type = spec
        .contract
        .as_ref()
        .and_then(|c| c.returns.as_ref())
        .map(|r| format!(" -> {}", r));

    match return_type {
        Some(ret) => format!("pub fn {}({}){}", spec.fn_name, params, ret),
        None => format!("pub fn {}({})", spec.fn_name, params),
    }
}

fn build_doc_comment(intent_why: &str) -> Option<String> {
    let trimmed = intent_why.trim();
    if trimmed.is_empty() {
        return None;
    }

    let mut output = String::new();
    for line in trimmed.lines() {
        if line.trim().is_empty() {
            output.push_str("///\n");
        } else {
            output.push_str("/// ");
            output.push_str(line);
            output.push('\n');
        }
    }

    Some(output)
}

pub fn generate_code(spec: &ResolvedSpec) -> Result<String> {
    generate_code_with_options(spec, &GenerateOptions::default())
}

pub fn generate_code_with_options(
    spec: &ResolvedSpec,
    options: &GenerateOptions,
) -> Result<String> {
    let (import_statements, dep_statements) = build_use_groups(spec)?;
    let mut output = String::new();

    for statement in import_statements {
        output.push_str(&statement);
        output.push('\n');
    }

    if !spec.imports.is_empty() && !spec.deps.is_empty() {
        output.push('\n');
    }

    for statement in dep_statements {
        output.push_str(&statement);
        output.push('\n');
    }

    if !spec.imports.is_empty() || !spec.deps.is_empty() {
        output.push('\n');
    }

    if let Some(doc_comment) = build_doc_comment(&spec.intent_why) {
        output.push_str(&doc_comment);
    }

    let signature = build_fn_signature(spec);
    let block = spec.body_rust.trim();
    output.push_str(&format!("{signature} {block}"));
    output.push('\n');

    if !spec.local_tests.is_empty() {
        // One blank line between the generated unit body and the tests module.
        output.push('\n');
        output.push_str("#[cfg(test)]\n");
        output.push_str("mod tests {\n");
        output.push_str("    use super::*;\n\n");

        for (index, local_test) in spec.local_tests.iter().enumerate() {
            let expect = local_test.expect.trim();
            validate_expect_expr(expect, options.allow_unsafe_local_test_expect).map_err(
                |err| SpecError::Generator {
                    message: format!(
                        "invalid local test expect for unit '{}' test '{}': {}",
                        spec.id,
                        local_test.id,
                        err.message()
                    ),
                },
            )?;
            output.push_str("    #[test]\n");
            output.push_str(&format!("    fn test_{}() {{\n", local_test.id));
            output.push_str(&format!("        assert!({expect});\n"));
            output.push_str("    }\n");

            if index + 1 != spec.local_tests.len() {
                output.push('\n');
            }
        }

        output.push_str("}\n");
    }
    Ok(output)
}

pub fn write_generated_file(output_path: &str, content: &str) -> Result<()> {
    let path = Path::new(output_path);

    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|err| SpecError::OutputDir {
            message: format!(
                "Unable to create output directory {}: {}",
                parent.display(),
                err
            ),
        })?;
    } else {
        return Err(SpecError::Generator {
            message: format!(
                "Unable to write {}: missing parent directory",
                path.display()
            ),
        });
    }

    let parent_dir = path
        .parent()
        .ok_or_else(|| SpecError::Generator {
            message: format!(
                "Unable to write {}: missing parent directory",
                path.display()
            ),
        })?
        .to_path_buf();

    // Write to a temp file in the same directory and rename into place (per-file atomic).
    let mut tmp = tempfile::Builder::new()
        .prefix(".spec-tmp-")
        .suffix(".tmp")
        .tempfile_in(&parent_dir)
        .map_err(|err| SpecError::Generator {
            message: format!(
                "Unable to create temp file in {}: {}",
                parent_dir.display(),
                err
            ),
        })?;

    tmp.write_all(content.as_bytes())
        .map_err(|err| SpecError::Generator {
            message: format!("Unable to write temp file for {}: {}", path.display(), err),
        })?;

    if !content.ends_with('\n') {
        tmp.write_all(b"\n").map_err(|err| SpecError::Generator {
            message: format!(
                "Unable to finalize temp file for {}: {}",
                path.display(),
                err
            ),
        })?;
    }

    tmp.flush().map_err(|err| SpecError::Generator {
        message: format!("Unable to flush temp file for {}: {}", path.display(), err),
    })?;

    // On Windows, renaming over an existing file fails; remove it first.
    if cfg!(windows) && path.exists() {
        fs::remove_file(path).map_err(|err| SpecError::Generator {
            message: format!("Unable to remove existing {}: {}", path.display(), err),
        })?;
    }

    let tmp_path = tmp.into_temp_path();
    fs::rename(&tmp_path, path).map_err(|err| SpecError::Generator {
        message: format!(
            "Unable to rename temp file into {}: {}",
            path.display(),
            err
        ),
    })?;

    Ok(())
}

pub fn clean_output_dir(
    output_base: &Path,
    generated_rs_rel_paths: &HashSet<PathBuf>,
) -> Result<()> {
    let base = safe_output_path(output_base)?;

    let marker = base.join(GENERATED_MARKER);
    if !marker.exists() {
        return Err(SpecError::MissingMarker {
            path: base.display().to_string(),
        });
    }

    // Remove orphaned `.rs` files (anything not in the generated set).
    for entry in WalkDir::new(&base).follow_links(false) {
        let entry = entry.map_err(SpecError::from)?;
        if !entry.file_type().is_file() {
            continue;
        }
        let path = entry.path();
        if path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
            continue;
        }

        let rel = path
            .strip_prefix(&base)
            .map_err(|err| SpecError::Generator {
                message: format!(
                    "Unable to compute relative path for {}: {}",
                    path.display(),
                    err
                ),
            })?;

        if !generated_rs_rel_paths.contains(rel) {
            fs::remove_file(path).map_err(|err| SpecError::Generator {
                message: format!("Unable to remove {}: {}", path.display(), err),
            })?;
        }
    }

    // Remove empty directories bottom-up (but never remove the base itself).
    for entry in WalkDir::new(&base).follow_links(false).contents_first(true) {
        let entry = entry.map_err(SpecError::from)?;
        if !entry.file_type().is_dir() || entry.file_type().is_symlink() {
            continue;
        }
        let path = entry.path();
        if path == base {
            continue;
        }

        let mut entries = fs::read_dir(path).map_err(|err| SpecError::Generator {
            message: format!("Unable to read dir {}: {}", path.display(), err),
        })?;
        if entries.next().is_none() {
            fs::remove_dir(path).map_err(|err| SpecError::Generator {
                message: format!("Unable to remove dir {}: {}", path.display(), err),
            })?;
        }
    }

    File::create(&marker).map_err(|err| SpecError::Generator {
        message: format!("Unable to recreate marker {}: {}", marker.display(), err),
    })?;

    Ok(())
}

pub fn generate_mod_rs(
    unit_files: &[String],
    subdirs: &[String],
    has_molecule_tests: bool,
) -> Result<String> {
    let mut seen = HashSet::new();
    let mut unit_mods = Vec::new();
    let mut subdir_mods = Vec::new();

    for unit_file in unit_files {
        if let Some(name) = module_item_name(unit_file) {
            let decl = format!("pub mod {};", name);
            if seen.insert(decl.clone()) {
                unit_mods.push(decl);
            }
        }
    }

    for subdir in subdirs {
        if let Some(name) = module_item_name(subdir) {
            let decl = format!("pub mod {};", name);
            if seen.insert(decl.clone()) {
                subdir_mods.push(decl);
            }
        }
    }

    unit_mods.sort();
    subdir_mods.sort();

    let mut output = String::new();
    for line in &unit_mods {
        output.push_str(line);
        output.push('\n');
    }

    if !unit_mods.is_empty() && !subdir_mods.is_empty() {
        output.push('\n');
    }

    for line in &subdir_mods {
        output.push_str(line);
        output.push('\n');
    }

    if has_molecule_tests {
        if !unit_mods.is_empty() || !subdir_mods.is_empty() {
            output.push('\n');
        }
        output.push_str("#[cfg(test)]\npub mod molecule_tests;\n");
    }

    Ok(output)
}

/// Returns the path for a molecule_tests.rs file relative to the output base.
pub fn molecule_tests_file_path(output_base: &Path, module_path: &str) -> PathBuf {
    if module_path.is_empty() {
        output_base.join("molecule_tests.rs")
    } else {
        output_base
            .join(module_path.replace('/', std::path::MAIN_SEPARATOR_STR))
            .join("molecule_tests.rs")
    }
}

/// Generate the Rust source code for a group of molecule tests in one namespace.
///
/// `tests` must all belong to the same `module_path`.
/// `specs_by_id` is used to look up imports for covered units.
pub fn generate_molecule_tests_code(
    tests: &[&ResolvedMoleculeTest],
    specs_by_id: &HashMap<&str, &ResolvedSpec>,
) -> Result<String> {
    // Collect all unique cover_ids in alphabetical order for deterministic output
    let mut cover_id_seen: HashSet<&str> = HashSet::new();
    let mut all_cover_ids: Vec<&str> = Vec::new();
    for test in tests {
        for cover_id in &test.covers {
            if cover_id_seen.insert(cover_id.as_str()) {
                all_cover_ids.push(cover_id.as_str());
            }
        }
    }
    all_cover_ids.sort_unstable();

    // Collect imports from covered specs, deduplicated in stable insertion order
    let mut import_seen: HashSet<String> = HashSet::new();
    let mut all_imports: Vec<String> = Vec::new();
    for cover_id in &all_cover_ids {
        let spec = specs_by_id.get(cover_id).ok_or_else(|| SpecError::Generator {
            message: format!(
                "covered unit '{}' not found in spec set (should have been caught by validation)",
                cover_id
            ),
        })?;
        for import in &spec.imports {
            if import_seen.insert(import.clone()) {
                all_imports.push(import.clone());
            }
        }
    }

    let mut output = String::new();

    // Emit external import use statements
    for import in &all_imports {
        output.push_str(&format!("use {};\n", import));
    }

    if !all_imports.is_empty() && !all_cover_ids.is_empty() {
        output.push('\n');
    }

    // Emit `use crate::...` for each covered unit.
    // dep_to_use_path already includes the trailing semicolon.
    for cover_id in &all_cover_ids {
        output.push_str(&format!(
            "use {}\n",
            ResolvedSpec::dep_to_use_path(cover_id)
        ));
    }

    if !all_cover_ids.is_empty() {
        output.push('\n');
    }

    // Emit test functions
    for (index, test) in tests.iter().enumerate() {
        let block = test.body_rust.trim();
        output.push_str("#[test]\n");
        output.push_str(&format!("fn test_{}() {}\n", test.fn_name, block));

        if index + 1 != tests.len() {
            output.push('\n');
        }
    }

    Ok(output)
}

/// Generate and write molecule_tests.rs files for all resolved molecule tests.
///
/// Groups tests by module_path and generates one molecule_tests.rs per group.
///
/// Returns the set of relative paths for generated molecule_tests.rs files
/// (for inclusion in `generated_rs_rel_paths` passed to `clean_output_dir`).
pub fn generate_and_write_molecule_tests(
    resolved_tests: &[ResolvedMoleculeTest],
    specs_by_id: &HashMap<&str, &ResolvedSpec>,
    output_base: &Path,
) -> Result<HashSet<PathBuf>> {
    // Group tests by module_path (BTreeMap for deterministic iteration order)
    let mut by_module: BTreeMap<String, Vec<&ResolvedMoleculeTest>> = BTreeMap::new();
    for test in resolved_tests {
        by_module
            .entry(test.module_path.clone())
            .or_default()
            .push(test);
    }

    let mut generated_paths = HashSet::new();

    for (module_path, tests) in &by_module {
        // Generate molecule_tests.rs content
        let content = generate_molecule_tests_code(tests, specs_by_id)?;
        let file_path = molecule_tests_file_path(output_base, module_path);
        write_generated_file(&file_path.to_string_lossy(), &content)?;

        // Track the relative path for clean_output_dir
        let rel: PathBuf = if module_path.is_empty() {
            PathBuf::from("molecule_tests.rs")
        } else {
            PathBuf::from(module_path.replace('/', std::path::MAIN_SEPARATOR_STR))
                .join("molecule_tests.rs")
        };
        generated_paths.insert(rel);
    }

    Ok(generated_paths)
}

fn build_use_groups(spec: &ResolvedSpec) -> Result<(Vec<String>, Vec<String>)> {
    if let Some((dep1, dep2)) = ResolvedSpec::has_dep_collision(&spec.deps) {
        return Err(SpecError::DepCollision {
            dep1: dep1.clone(),
            dep2: dep2.clone(),
            fn_name: ResolvedSpec::dep_fn_name(dep1).to_string(),
            path: spec.id.clone(),
        });
    }

    if let Some(dep) = spec.deps.iter().find(|dep| {
        DepRef::parse(dep)
            .map(|parsed| parsed.callable_name() == spec.fn_name)
            .unwrap_or(false)
    }) {
        return Err(SpecError::DepCollision {
            dep1: dep.clone(),
            dep2: spec.id.clone(),
            fn_name: spec.fn_name.clone(),
            path: spec.id.clone(),
        });
    }

    let mut import_seen = HashSet::new();
    let mut import_statements = Vec::new();

    for import_path in &spec.imports {
        if import_seen.insert(import_path.clone()) {
            import_statements.push(format!("use {};", import_path));
        }
    }

    let mut dep_seen = HashSet::new();
    let mut dep_statements = Vec::new();

    for dep in &spec.deps {
        if dep_seen.insert(dep.clone()) {
            dep_statements.push(build_dep_use_statement(dep)?);
        }
    }

    Ok((import_statements, dep_statements))
}

fn build_dep_use_statement(dep: &str) -> Result<String> {
    let dep_ref = DepRef::parse(dep).map_err(|err| SpecError::Generator {
        message: format!(
            "invalid dep '{}' reached generator after validation: {}",
            dep, err
        ),
    })?;

    let unit_path = dep_ref.unit_id().replace('/', "::");
    let callable_name = dep_ref.callable_name();
    let prefix = dep_ref.library_alias().unwrap_or("crate");
    Ok(format!("use {prefix}::{unit_path}::{callable_name};"))
}

fn module_item_name(fragment: &str) -> Option<String> {
    Path::new(fragment)
        .file_name()
        .and_then(|name| name.to_str())
        .map(|name| name.trim_end_matches(".rs").to_string())
        .filter(|name| !name.is_empty())
}

pub fn safe_output_path<P: AsRef<Path>>(path: P) -> Result<PathBuf> {
    let path = path.as_ref();
    let project_root = canonicalize_existing_path(&std::env::current_dir().map_err(|err| {
        SpecError::OutputDir {
            message: format!("Unable to determine project root: {err}"),
        }
    })?)?;
    let output_base = canonicalize_output_path(path)?;

    if !output_base.starts_with(&project_root) {
        return Err(SpecError::OutputDir {
            message: format!(
                "Refusing to generate into {}: output path is outside the project root {}",
                output_base.display(),
                project_root.display()
            ),
        });
    }

    Ok(output_base)
}

pub fn normalized_absolute_path<P: AsRef<Path>>(path: P) -> PathBuf {
    let path = path.as_ref();
    let mut normalized = if path.is_absolute() {
        PathBuf::new()
    } else {
        std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
    };

    for component in path.components() {
        match component {
            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
            Component::RootDir => normalized.push(component.as_os_str()),
            Component::CurDir => {}
            Component::ParentDir => {
                normalized.pop();
            }
            Component::Normal(segment) => normalized.push(segment),
        }
    }

    normalized
}

fn canonicalize_output_path(path: &Path) -> Result<PathBuf> {
    let absolute = normalized_absolute_path(path);
    if absolute.exists() {
        return canonicalize_existing_path(&absolute);
    }

    let mut current = absolute.as_path();
    let mut missing_segments = Vec::new();

    while !current.exists() {
        let segment = current.file_name().ok_or_else(|| SpecError::OutputDir {
            message: format!(
                "Unable to resolve output path {}: no existing ancestor found",
                absolute.display()
            ),
        })?;
        missing_segments.push(segment.to_os_string());
        current = current.parent().ok_or_else(|| SpecError::OutputDir {
            message: format!(
                "Unable to resolve output path {}: no existing ancestor found",
                absolute.display()
            ),
        })?;
    }

    let mut resolved = canonicalize_existing_path(current)?;
    for segment in missing_segments.iter().rev() {
        resolved.push(segment);
    }

    Ok(resolved)
}

fn canonicalize_existing_path(path: &Path) -> Result<PathBuf> {
    path.canonicalize().map_err(|err| SpecError::OutputDir {
        message: format!("Unable to canonicalize {}: {}", path.display(), err),
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::syntax::{ExpectExprErrorKind, validate_expect_expr};
    use crate::types::{Body, Intent, LocalTest, ResolvedMoleculeTest, ResolvedSpec, SpecStruct};
    #[cfg(unix)]
    use std::os::unix::fs as unix_fs;
    use tempfile::TempDir;

    fn make_resolved_molecule_test(
        id: &str,
        covers: Vec<&str>,
        body_rust: &str,
    ) -> ResolvedMoleculeTest {
        let (module_path, fn_name) = id
            .rsplit_once('/')
            .map(|(m, f)| (m.to_string(), f.to_string()))
            .unwrap_or_else(|| (String::new(), id.to_string()));
        ResolvedMoleculeTest {
            id: id.to_string(),
            fn_name,
            module_path,
            intent_why: format!("Test {id}"),
            covers: covers.into_iter().map(str::to_string).collect(),
            body_rust: body_rust.to_string(),
            spec_version: None,
        }
    }

    fn make_resolved_spec_with_imports(id: &str, imports: Vec<&str>) -> ResolvedSpec {
        ResolvedSpec::from_spec(SpecStruct {
            id: id.to_string(),
            kind: "function".to_string(),
            intent: Intent {
                why: format!("Why {id}"),
            },
            contract: None,
            deps: vec![],
            imports: imports.into_iter().map(str::to_string).collect(),
            body: Body {
                rust: "{ }".to_string(),
            },
            local_tests: vec![],
            links: None,
            spec_version: None,
        })
    }

    fn test_spec_with_intent(
        deps: Vec<&str>,
        imports: Vec<&str>,
        body: &str,
        intent_why: &str,
    ) -> ResolvedSpec {
        ResolvedSpec::from_spec(SpecStruct {
            id: "pricing/apply_discount".to_string(),
            kind: "function".to_string(),
            intent: Intent {
                why: intent_why.to_string(),
            },
            contract: None,
            deps: deps.into_iter().map(|dep| dep.to_string()).collect(),
            imports: imports
                .into_iter()
                .map(|import| import.to_string())
                .collect(),
            body: Body {
                rust: body.to_string(),
            },
            local_tests: vec![],
            links: None,
            spec_version: None,
        })
    }

    fn test_spec_with(deps: Vec<&str>, imports: Vec<&str>, body: &str) -> ResolvedSpec {
        test_spec_with_intent(deps, imports, body, " ")
    }

    fn test_spec(deps: Vec<&str>, body: &str) -> ResolvedSpec {
        test_spec_with(deps, vec![], body)
    }

    #[test]
    fn generate_code_includes_doc_comment_from_intent() {
        let spec = test_spec_with_intent(
            vec![],
            vec!["rust_decimal::Decimal"],
            "{\n    Decimal::ZERO\n}",
            "Apply a percentage discount.",
        );

        let code = generate_code(&spec).unwrap();
        assert_eq!(
            code,
            "use rust_decimal::Decimal;\n\n/// Apply a percentage discount.\npub fn apply_discount() {\n    Decimal::ZERO\n}\n"
        );
    }

    #[test]
    fn generate_code_multiline_intent_produces_multiline_doc_comment() {
        let spec = test_spec_with_intent(
            vec![],
            vec![],
            "{\n    Decimal::ZERO\n}",
            "\nFirst line.\n\nSecond line.\n",
        );

        let code = generate_code(&spec).unwrap();
        assert_eq!(
            code,
            "/// First line.\n///\n/// Second line.\npub fn apply_discount() {\n    Decimal::ZERO\n}\n"
        );
    }

    #[test]
    fn generate_code_omits_doc_comment_for_blank_intent() {
        let spec = test_spec_with_intent(vec![], vec![], "{\n    Decimal::ZERO\n}", "   \n  ");

        let code = generate_code(&spec).unwrap();
        assert_eq!(code, "pub fn apply_discount() {\n    Decimal::ZERO\n}\n");
    }

    #[test]
    fn test_generate_code_prepends_use_statements() {
        let spec = test_spec(
            vec!["money/round", "utils/math/normalize"],
            "{\n    round(Decimal::ONE)\n}",
        );

        let code = generate_code(&spec).unwrap();
        assert_eq!(
            code,
            "use crate::money::round::round;\nuse crate::utils::math::normalize::normalize;\n\npub fn apply_discount() {\n    round(Decimal::ONE)\n}\n"
        );
    }

    #[test]
    fn test_generate_code_rejects_dep_collision() {
        let spec = test_spec(
            vec!["money/round", "utils/round"],
            "{\n    round(Decimal::ONE)\n}",
        );

        let err = generate_code(&spec).unwrap_err();
        assert!(err.to_string().contains("Dep fn_name collision"));
    }

    #[test]
    fn imports_field_generates_correct_use_statement() {
        let spec = test_spec_with(
            vec![],
            vec!["rust_decimal::Decimal"],
            "{\n    Decimal::ZERO\n}",
        );

        let code = generate_code(&spec).unwrap();
        assert_eq!(
            code,
            "use rust_decimal::Decimal;\n\npub fn apply_discount() {\n    Decimal::ZERO\n}\n"
        );
    }

    #[test]
    fn deps_unchanged_after_imports_split() {
        let spec = test_spec_with(
            vec!["money/round"],
            vec![],
            "{\n    round(Decimal::ZERO)\n}",
        );

        let code = generate_code(&spec).unwrap();
        assert!(code.contains("use crate::money::round::round;"));
    }

    #[test]
    fn imports_emitted_before_deps_in_use_statements() {
        let spec = test_spec_with(
            vec!["money/round"],
            vec!["rust_decimal::Decimal"],
            "{\n    round(Decimal::ZERO)\n}",
        );

        let code = generate_code(&spec).unwrap();
        assert_eq!(
            code,
            "use rust_decimal::Decimal;\n\nuse crate::money::round::round;\n\npub fn apply_discount() {\n    round(Decimal::ZERO)\n}\n"
        );
    }

    #[test]
    fn external_deps_generate_alias_use_statements() {
        let spec = test_spec_with(
            vec!["shared::money/round"],
            vec![],
            "{\n    round(Decimal::ZERO)\n}",
        );

        let code = generate_code(&spec).unwrap();
        assert_eq!(
            code,
            "use shared::money::round::round;\n\npub fn apply_discount() {\n    round(Decimal::ZERO)\n}\n"
        );
    }

    #[test]
    fn generate_code_rejects_local_external_dep_collision() {
        let spec = test_spec(
            vec!["money/round", "shared::math/round"],
            "{\n    round(Decimal::ONE)\n}",
        );

        let err = generate_code(&spec).unwrap_err().to_string();
        assert!(err.contains("Dep fn_name collision"), "{err}");
        assert!(err.contains("money/round"), "{err}");
        assert!(err.contains("shared::math/round"), "{err}");
    }

    #[test]
    fn generate_code_rejects_dep_collision_with_unit_callable_name() {
        let spec = ResolvedSpec::from_spec(SpecStruct {
            id: "money/round".to_string(),
            kind: "function".to_string(),
            intent: Intent {
                why: "Round money values.".to_string(),
            },
            contract: None,
            deps: vec!["shared::money/round".to_string()],
            imports: vec![],
            body: Body {
                rust: "{\n    round(value)\n}".to_string(),
            },
            local_tests: vec![],
            links: None,
            spec_version: None,
        });

        let err = generate_code(&spec).unwrap_err();
        match err {
            SpecError::DepCollision {
                dep1,
                dep2,
                fn_name,
                path,
            } => {
                assert_eq!(dep1, "shared::money/round");
                assert_eq!(dep2, "money/round");
                assert_eq!(fn_name, "round");
                assert_eq!(path, "money/round");
            }
            other => panic!("expected DepCollision, got {other:?}"),
        }
    }

    #[test]
    fn test_write_generated_file_creates_parent_dirs() {
        let temp_dir = TempDir::new().unwrap();
        let file_path = temp_dir
            .path()
            .join("generated/spec/pricing/apply_discount.rs");

        write_generated_file(
            file_path.to_str().unwrap(),
            "pub fn apply_discount() -> Decimal { Decimal::ZERO }\n",
        )
        .unwrap();

        let contents = fs::read_to_string(&file_path).unwrap();
        assert_eq!(
            contents,
            "pub fn apply_discount() -> Decimal { Decimal::ZERO }\n"
        );
    }

    #[test]
    fn test_generate_mod_rs_lists_units_and_subdirs() {
        let content = generate_mod_rs(
            &["apply_discount.rs".to_string(), "refund.rs".to_string()],
            &["taxes".to_string(), "discounts".to_string()],
            false,
        )
        .unwrap();

        assert_eq!(
            content,
            "pub mod apply_discount;\npub mod refund;\n\npub mod discounts;\npub mod taxes;\n"
        );
    }

    #[test]
    fn generate_local_tests_produces_cfg_test_block() {
        let mut spec = test_spec_with(
            vec![],
            vec!["rust_decimal::Decimal"],
            "{\n    Decimal::ZERO\n}",
        );
        spec.local_tests = vec![LocalTest {
            id: "happy_path".to_string(),
            expect: "apply_discount() == Decimal::ZERO".to_string(),
        }];

        let code = generate_code(&spec).unwrap();
        assert!(code.contains("#[cfg(test)]\nmod tests {"));
        assert!(code.contains("use super::*;"));
        assert!(code.contains("fn test_happy_path() {"));
        assert!(code.contains("assert!(apply_discount() == Decimal::ZERO);"));
    }

    #[test]
    fn generate_no_local_tests_produces_no_test_block() {
        let spec = test_spec_with(vec![], vec![], "{ }");
        let code = generate_code(&spec).unwrap();
        assert!(!code.contains("#[cfg(test)]"));
        assert!(!code.contains("mod tests {"));
    }

    #[test]
    fn generate_code_rejects_unsafe_expect_at_sink() {
        let mut spec = test_spec_with(vec![], vec![], "{ true }");
        spec.local_tests = vec![LocalTest {
            id: "unsafe_attempt".to_string(),
            expect: "{ let ok = apply_discount(); ok }".to_string(),
        }];

        let err = generate_code(&spec).unwrap_err().to_string();
        assert!(err.contains("pricing/apply_discount"), "{err}");
        assert!(err.contains("unsafe_attempt"), "{err}");
        assert!(err.contains("block, unsafe, closure"), "{err}");
    }

    #[test]
    fn generate_code_rejects_deeply_nested_expect_at_sink() {
        let mut spec = test_spec_with(vec![], vec![], "{ true }");
        spec.local_tests = vec![LocalTest {
            id: "deep".to_string(),
            expect: format!("{}true{}", "(".repeat(200), ")".repeat(200)),
        }];

        let err = generate_code(&spec).unwrap_err().to_string();
        assert!(err.contains("pricing/apply_discount"), "{err}");
        assert!(err.contains("deep"), "{err}");
        assert!(err.contains("maximum depth of 128"), "{err}");
    }

    #[test]
    fn generate_code_sink_guard_includes_unit_and_test_id_in_error() {
        let mut spec = test_spec_with(vec![], vec![], "{ true }");
        spec.local_tests = vec![LocalTest {
            id: "broken".to_string(),
            expect: "(".to_string(),
        }];

        let err = generate_code(&spec).unwrap_err().to_string();
        assert!(err.contains("pricing/apply_discount"), "{err}");
        assert!(err.contains("broken"), "{err}");
    }

    #[test]
    fn generate_code_with_options_preserves_escape_hatch() {
        let mut spec = test_spec_with(vec![], vec![], "{ true }");
        spec.local_tests = vec![LocalTest {
            id: "unsafe_allowed".to_string(),
            expect: "{ let ok = apply_discount(); ok }".to_string(),
        }];

        let code = generate_code_with_options(
            &spec,
            &GenerateOptions {
                allow_unsafe_local_test_expect: true,
            },
        )
        .unwrap();

        assert!(code.contains("assert!({ let ok = apply_discount(); ok });"));
    }

    #[test]
    fn shared_expect_validation_reports_too_deep_before_syn_parse() {
        let result = validate_expect_expr(
            &format!("{}true{}", "(".repeat(200), ")".repeat(200)),
            false,
        );
        match result {
            Err(ExpectExprErrorKind::TooDeep { max_depth }) => assert_eq!(max_depth, 128),
            Err(other) => panic!("expected too-deep error, got {:?}", other),
            Ok(_) => panic!("expected too-deep error, got success"),
        }
    }

    #[test]
    fn clean_output_dir_removes_stale_module_from_prior_run() {
        let temp_dir = TempDir::new_in(std::env::current_dir().unwrap()).unwrap();
        let base = temp_dir.path().join("generated/spec");
        let pricing = base.join("pricing");
        let test_mod = base.join("test");
        fs::create_dir_all(&pricing).unwrap();
        fs::create_dir_all(&test_mod).unwrap();
        fs::write(base.join(GENERATED_MARKER), "").unwrap();

        // "Current run" generated files.
        fs::write(base.join("mod.rs"), "pub mod pricing;\n").unwrap();
        fs::write(pricing.join("apply_discount.rs"), "fn a() {}\n").unwrap();
        fs::write(pricing.join("mod.rs"), "pub mod apply_discount;\n").unwrap();

        // Stale module files from a prior run should be removed (and the empty dir pruned).
        fs::write(test_mod.join("foo.rs"), "fn stale() {}\n").unwrap();
        fs::write(test_mod.join("mod.rs"), "pub mod foo;\n").unwrap();

        let mut generated = HashSet::new();
        generated.insert(PathBuf::from("pricing/apply_discount.rs"));
        generated.insert(PathBuf::from("pricing/mod.rs"));
        generated.insert(PathBuf::from("mod.rs"));

        clean_output_dir(&base, &generated).unwrap();

        assert!(pricing.join("apply_discount.rs").exists());
        assert!(pricing.join("mod.rs").exists());

        assert!(!test_mod.join("foo.rs").exists());
        assert!(!test_mod.join("mod.rs").exists());
        assert!(!test_mod.exists(), "stale module dir should be removed");

        assert!(base.join("mod.rs").exists());
        assert!(base.join(GENERATED_MARKER).exists());
    }

    #[test]
    fn test_clean_output_dir_refuses_without_marker() {
        let temp_dir = TempDir::new_in(std::env::current_dir().unwrap()).unwrap();
        let base = temp_dir.path().join("generated/spec");
        fs::create_dir_all(&base).unwrap();

        let generated = HashSet::new();
        let err = clean_output_dir(&base, &generated).unwrap_err();
        assert!(matches!(err, SpecError::MissingMarker { .. }));
    }

    #[test]
    #[cfg(unix)]
    fn test_clean_output_dir_does_not_follow_symlink_dirs() {
        let temp_dir = TempDir::new_in(std::env::current_dir().unwrap()).unwrap();
        let base = temp_dir.path().join("generated/spec");
        let pricing = base.join("pricing");
        fs::create_dir_all(&pricing).unwrap();
        fs::write(base.join(GENERATED_MARKER), "").unwrap();
        fs::write(pricing.join("apply_discount.rs"), "fn a() {}\n").unwrap();

        let outside_dir = temp_dir.path().join("outside");
        fs::create_dir_all(&outside_dir).unwrap();
        let outside_rs = outside_dir.join("outside.rs");
        fs::write(&outside_rs, "fn outside() {}\n").unwrap();

        unix_fs::symlink(&outside_dir, pricing.join("link")).unwrap();

        let generated = HashSet::new();
        clean_output_dir(&base, &generated).unwrap();

        assert!(!pricing.join("apply_discount.rs").exists());
        assert!(
            outside_rs.exists(),
            "clean_output_dir must not delete files through symlinks"
        );
        assert!(base.join(GENERATED_MARKER).exists());
    }

    #[test]
    fn safe_output_path_accepts_existing_path_inside_project_root() {
        let temp_dir = TempDir::new_in(std::env::current_dir().unwrap()).unwrap();
        let path = temp_dir.path().join("generated/spec");
        fs::create_dir_all(&path).unwrap();

        let resolved = safe_output_path(&path).unwrap();
        assert_eq!(resolved, path.canonicalize().unwrap());
    }

    #[test]
    fn safe_output_path_resolves_nonexistent_nested_path_from_existing_ancestor() {
        let temp_dir = TempDir::new_in(std::env::current_dir().unwrap()).unwrap();
        let base = temp_dir.path().join("generated");
        fs::create_dir_all(&base).unwrap();
        let path = base.join("spec/pricing");

        let resolved = safe_output_path(&path).unwrap();
        assert_eq!(
            resolved,
            base.canonicalize().unwrap().join("spec").join("pricing")
        );
    }

    #[test]
    #[cfg(unix)]
    fn safe_output_path_rejects_symlink_escape_in_nonexistent_path() {
        let temp_dir = TempDir::new_in(std::env::current_dir().unwrap()).unwrap();
        let outside = tempfile::TempDir::new().unwrap();
        let link = temp_dir.path().join("escape");
        unix_fs::symlink(outside.path(), &link).unwrap();

        let err = safe_output_path(link.join("generated/spec")).unwrap_err();
        assert!(err.to_string().contains("outside the project root"));
    }

    #[test]
    fn generate_molecule_tests_code_single_test_single_cover() {
        let test = make_resolved_molecule_test(
            "pricing/checkout",
            vec!["pricing/apply_tax"],
            "{ assert!(true); }",
        );
        let spec =
            make_resolved_spec_with_imports("pricing/apply_tax", vec!["rust_decimal::Decimal"]);
        let specs_by_id: HashMap<&str, &ResolvedSpec> =
            [("pricing/apply_tax", &spec)].into_iter().collect();

        let code = generate_molecule_tests_code(&[&test], &specs_by_id).unwrap();

        assert!(
            code.contains("use rust_decimal::Decimal;"),
            "should emit spec imports"
        );
        assert!(
            code.contains("use crate::pricing::apply_tax::apply_tax;"),
            "should emit use crate path for covered unit"
        );
        assert!(code.contains("#[test]"), "should emit test attribute");
        assert!(
            code.contains("fn test_checkout()"),
            "should use fn_name with test_ prefix"
        );
        assert!(
            code.contains("{ assert!(true); }"),
            "should include body verbatim"
        );
    }

    #[test]
    fn generate_molecule_tests_code_deduplicates_shared_imports() {
        let test = make_resolved_molecule_test(
            "pricing/multi",
            vec!["pricing/apply_tax", "pricing/apply_discount"],
            "{ assert!(true); }",
        );
        let spec_tax =
            make_resolved_spec_with_imports("pricing/apply_tax", vec!["rust_decimal::Decimal"]);
        let spec_discount = make_resolved_spec_with_imports(
            "pricing/apply_discount",
            vec!["rust_decimal::Decimal"],
        );
        let specs_by_id: HashMap<&str, &ResolvedSpec> = [
            ("pricing/apply_tax", &spec_tax),
            ("pricing/apply_discount", &spec_discount),
        ]
        .into_iter()
        .collect();

        let code = generate_molecule_tests_code(&[&test], &specs_by_id).unwrap();

        let decimal_count = code.matches("use rust_decimal::Decimal;").count();
        assert_eq!(decimal_count, 1, "shared import should appear exactly once");
    }

    #[test]
    fn generate_molecule_tests_code_missing_cover_id_returns_error() {
        let test = make_resolved_molecule_test(
            "pricing/checkout",
            vec!["pricing/nonexistent"],
            "{ assert!(true); }",
        );
        let specs_by_id: HashMap<&str, &ResolvedSpec> = HashMap::new();

        let err = generate_molecule_tests_code(&[&test], &specs_by_id).unwrap_err();
        assert!(
            err.to_string().contains("pricing/nonexistent"),
            "error should name the missing cover id"
        );
    }

    #[test]
    fn generate_molecule_tests_code_multiple_tests_in_namespace() {
        let test_a = make_resolved_molecule_test(
            "pricing/flow_a",
            vec!["pricing/apply_tax"],
            "{ assert!(1 == 1); }",
        );
        let test_b = make_resolved_molecule_test(
            "pricing/flow_b",
            vec!["pricing/apply_tax"],
            "{ assert!(2 == 2); }",
        );
        let spec = make_resolved_spec_with_imports("pricing/apply_tax", vec![]);
        let specs_by_id: HashMap<&str, &ResolvedSpec> =
            [("pricing/apply_tax", &spec)].into_iter().collect();

        let code = generate_molecule_tests_code(&[&test_a, &test_b], &specs_by_id).unwrap();

        assert!(
            code.contains("fn test_flow_a()"),
            "first test should be present"
        );
        assert!(
            code.contains("fn test_flow_b()"),
            "second test should be present"
        );
        // Use statement should appear only once despite two tests covering the same unit
        let use_count = code
            .matches("use crate::pricing::apply_tax::apply_tax;")
            .count();
        assert_eq!(use_count, 1, "use path should be deduplicated across tests");
    }

    #[test]
    fn generate_mod_rs_with_molecule_tests_appends_declaration() {
        let content = generate_mod_rs(&["apply_discount.rs".to_string()], &[], true).unwrap();

        assert!(
            content.contains("#[cfg(test)]\npub mod molecule_tests;"),
            "should declare molecule_tests module gated by cfg(test)"
        );
        assert!(
            content.contains("pub mod apply_discount;"),
            "should still include unit mods"
        );
    }

    #[test]
    fn generate_mod_rs_molecule_tests_only_namespace() {
        let content = generate_mod_rs(&[], &[], true).unwrap();
        assert_eq!(content, "#[cfg(test)]\npub mod molecule_tests;\n");
    }

    #[test]
    fn generate_molecule_tests_code_zero_covers_emits_test_fn_no_use_statements() {
        // A test with no covers should emit the #[test] function but no `use crate::` lines.
        let test =
            make_resolved_molecule_test("pricing/standalone_flow", vec![], "{ assert!(true); }");
        let specs_by_id: HashMap<&str, &ResolvedSpec> = HashMap::new();

        let code = generate_molecule_tests_code(&[&test], &specs_by_id).unwrap();

        assert!(code.contains("#[test]"), "should emit #[test] attribute");
        assert!(
            code.contains("fn test_standalone_flow()"),
            "should emit test function"
        );
        assert!(
            !code.contains("use crate::"),
            "no covers means no use crate:: imports"
        );
    }

    #[test]
    fn molecule_tests_file_path_root_namespace() {
        let base = PathBuf::from("/tmp/generated");
        let path = molecule_tests_file_path(&base, "");
        assert_eq!(path, PathBuf::from("/tmp/generated/molecule_tests.rs"));
    }

    #[test]
    fn molecule_tests_file_path_nested_namespace() {
        let base = PathBuf::from("/tmp/generated");
        let path = molecule_tests_file_path(&base, "pricing/sub");
        assert_eq!(
            path,
            PathBuf::from("/tmp/generated/pricing/sub/molecule_tests.rs")
        );
    }
}
