//! Content hashing and generated-file headers.
//!
//! Every file produced by alef gets a standard header that identifies it as
//! generated, tells agents/developers how to fix issues, and embeds a blake3
//! hash so `alef verify` can detect staleness without external state.
//!
//! # Hash semantics
//!
//! As of alef v0.21.0, the embedded `alef:hash:<hex>` value is a
//! **generation-inputs fingerprint** produced by [`compute_inputs_hash`]:
//!
//! ```text
//! blake3(
//!   "alef:inputs\0"
//!   || ALEF_REV || "\0"
//!   || sources_hash || "\0"
//!   || alef_toml_bytes
//! )
//! ```
//!
//! Where `sources_hash` is [`compute_sources_hash`] over the sorted Rust source
//! files alef parses to build the IR, and `alef_toml_bytes` is the raw content
//! of the `alef.toml` file. The hash answers **"was this file generated from the
//! current alef inputs?"** — post-generation formatter drift (rustfmt, ruff,
//! rumdl-fmt, oxfmt, etc.) is irrelevant because the hash is not derived from
//! the emitted file content.
//!
//! `alef verify` re-derives the same inputs hash from the current `alef.toml`
//! and Rust sources, embeds nothing from the on-disk file, and compares to the
//! embedded line — pure read+compare, no regeneration, no writes.
//!
//! # Migration from v0.10.1 — v0.20.x
//!
//! Pre-v0.21.0 alef embedded `blake3(sources_hash || file_content_without_hash_line)`.
//! Any file regenerated with v0.21.0+ will carry a new hash value; `alef verify`
//! from v0.21.0+ rejects old-format hashes. Run `alef generate` once after
//! upgrading to stamp all files with the new inputs hash.

const HASH_PREFIX: &str = "alef:hash:";
const DEFAULT_REGENERATE_COMMAND: &str = "alef generate";
const DEFAULT_VERIFY_COMMAND: &str = "alef verify --exit-code";
const DEFAULT_ISSUES_URL: &str = "https://github.com/sample_crate-dev/alef";

/// Comment style for the generated header.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommentStyle {
    /// `// line comment`  (Rust, Go, Java, C#, TypeScript, C, PHP)
    DoubleSlash,
    /// `# line comment`   (Python, Ruby, Elixir, R, TOML, Shell, Makefile)
    Hash,
    /// `/* block comment */` (C headers)
    Block,
}

/// Return the standard alef header as a comment block.
///
/// ```text
/// // This file is auto-generated by alef — DO NOT EDIT.
/// // To regenerate: alef generate
/// // To verify freshness: alef verify --exit-code
/// // Issues & docs: https://github.com/sample_crate-dev/alef
/// ```
pub fn header(style: CommentStyle) -> String {
    render_header(style, &default_header_body())
}

/// Return the standard alef header using metadata from a resolved crate config.
pub fn header_for_config(style: CommentStyle, config: &crate::core::config::ResolvedCrateConfig) -> String {
    let header_config = config.scaffold.as_ref().and_then(|s| s.generated_header.as_ref());
    let body = match header_config {
        Some(header) => {
            let regenerate = header
                .regenerate_command
                .as_deref()
                .unwrap_or(DEFAULT_REGENERATE_COMMAND);
            let verify = header.verify_command.as_deref().unwrap_or(DEFAULT_VERIFY_COMMAND);
            let issues_url = header.issues_url.as_deref().unwrap_or(DEFAULT_ISSUES_URL);
            format!(
                "This file is auto-generated by alef — DO NOT EDIT.\n\
To regenerate: {regenerate}\n\
To verify freshness: {verify}\n\
Issues & docs: {issues_url}"
            )
        }
        None => default_header_body(),
    };
    render_header(style, &body)
}

fn default_header_body() -> String {
    format!(
        "This file is auto-generated by alef — DO NOT EDIT.\n\
To regenerate: {DEFAULT_REGENERATE_COMMAND}\n\
To verify freshness: {DEFAULT_VERIFY_COMMAND}\n\
Issues & docs: {DEFAULT_ISSUES_URL}"
    )
}

fn render_header(style: CommentStyle, body: &str) -> String {
    match style {
        CommentStyle::DoubleSlash => body.lines().map(|l| format!("// {l}\n")).collect(),
        CommentStyle::Hash => body.lines().map(|l| format!("# {l}\n")).collect(),
        CommentStyle::Block => {
            let mut out = String::from("/*\n");
            for line in body.lines() {
                out.push_str(&format!(" * {line}\n"));
            }
            out.push_str(" */\n");
            out
        }
    }
}

/// The marker string that `inject_hash_line` and `extract_hash` look for.
/// Every alef-generated header contains this on the first line.
const HEADER_MARKER: &str = "auto-generated by alef";

/// Blake3 hash of a content string, returned as hex.
///
/// Used by the IR / language caches and any caller that needs a hash of an
/// in-memory string. **Not used for the embedded `alef:hash:` header** — that
/// is computed by [`compute_file_hash`].
pub fn hash_content(content: &str) -> String {
    blake3::hash(content.as_bytes()).to_hex().to_string()
}

/// Compute a stable hash over the Rust source files that alef extracts.
///
/// This is the "source side" of the per-file verify hash. Sources are sorted
/// by path so the hash is stable regardless of ordering in
/// `alef.toml`'s `[crate].sources`. The path is mixed in alongside the
/// content because the same byte-content at a different path produces
/// different IR (the `rust_path` on extracted types differs).
///
/// Used by [`compute_file_hash`]; not by itself the value embedded in any
/// file header.
///
/// # Errors
/// Returns an error if any source file is missing or unreadable.
pub fn compute_sources_hash(sources: &[std::path::PathBuf]) -> std::io::Result<String> {
    let mut hasher = blake3::Hasher::new();
    let mut sorted: Vec<&std::path::PathBuf> = sources.iter().collect();
    sorted.sort();
    for source in sorted {
        let content = std::fs::read(source)?;
        hasher.update(b"src\0");
        hasher.update(source.to_string_lossy().as_bytes());
        hasher.update(b"\0");
        hasher.update(&content);
    }
    Ok(hasher.finalize().to_hex().to_string())
}

/// Compute a stable hex-encoded Blake3 hash over all Rust source files
/// belonging to a [`crate::core::config::resolved::ResolvedCrateConfig`].
///
/// Returns a hex string so callers can feed the result directly to
/// [`compute_file_hash`], matching [`compute_sources_hash`]'s return type.
///
/// The hash covers the union of:
/// - `crate_cfg.sources` (direct sources on the crate)
/// - every `source_crates[*].sources` entry
///
/// All paths are sorted before hashing so the result is independent of the
/// order they appear in `alef.toml`.  The path string is mixed in alongside
/// the file content because the same byte-content at a different path produces
/// different IR (the `rust_path` on extracted types differs).
///
/// # Phase 3 migration note
///
/// Phase 3 callers should migrate from the per-file `compute_sources_hash` to
/// this function when they have a `ResolvedCrateConfig` available, so that
/// multi-source-crate workspaces produce a single stable hash across all
/// contributing source files.
///
/// # Errors
///
/// Returns an error if any source file is missing or unreadable.
pub fn compute_crate_sources_hash(
    crate_cfg: &crate::core::config::resolved::ResolvedCrateConfig,
) -> std::io::Result<String> {
    let mut all_sources: Vec<&std::path::PathBuf> = Vec::new();

    for src in &crate_cfg.sources {
        all_sources.push(src);
    }
    for sc in &crate_cfg.source_crates {
        for src in &sc.sources {
            all_sources.push(src);
        }
    }

    // Stable sort by path so the hash is order-independent.
    all_sources.sort();
    all_sources.dedup();

    let mut hasher = blake3::Hasher::new();
    for source in all_sources {
        let content = std::fs::read(source)?;
        hasher.update(b"src\0");
        hasher.update(source.to_string_lossy().as_bytes());
        hasher.update(b"\0");
        hasher.update(&content);
    }
    Ok(hasher.finalize().to_hex().to_string())
}

/// Compute the generation-inputs hash that alef embeds in each generated file.
///
/// The hash covers the alef revision, the Rust source fingerprint, and the
/// raw `alef.toml` bytes. It does **not** include the emitted file content, so
/// post-generation formatter rewrites (rustfmt, ruff, rumdl-fmt, oxfmt, …)
/// never invalidate the embedded hash.
///
/// - **Generate**: compute once per run, inject into every generated file header.
/// - **Verify**: re-derive from the current inputs, compare to the embedded line.
///   No file content is read or hashed — pure input comparison.
///
/// # Arguments
///
/// * `sources_hash` — output of [`compute_sources_hash`] or
///   [`compute_crate_sources_hash`] for the crate being generated.
/// * `alef_toml_bytes` — raw bytes of the `alef.toml` config file. Pass an
///   empty slice when the config path is unavailable (e.g. in tests); the hash
///   will still change when `sources_hash` or `ALEF_REV` changes.
pub fn compute_inputs_hash(sources_hash: &str, alef_toml_bytes: &[u8]) -> String {
    let alef_rev = crate::core::template_versions::precommit::ALEF_REV;
    let mut hasher = blake3::Hasher::new();
    hasher.update(b"alef:inputs\0");
    hasher.update(alef_rev.as_bytes());
    hasher.update(b"\0");
    hasher.update(sources_hash.as_bytes());
    hasher.update(b"\0");
    hasher.update(alef_toml_bytes);
    hasher.finalize().to_hex().to_string()
}

/// Compute the per-file verify hash that alef embeds in each generated file.
///
/// Kept for internal use by tests that verify the old content-derived hash
/// semantics. New callers should use [`compute_inputs_hash`].
///
/// `sources_hash` comes from [`compute_sources_hash`]. `content` is the file
/// content; any pre-existing `alef:hash:` line is stripped before hashing so
/// the function is idempotent.
#[doc(hidden)]
pub fn compute_file_hash(sources_hash: &str, content: &str) -> String {
    let stripped = strip_hash_line(content);
    let mut hasher = blake3::Hasher::new();
    hasher.update(b"sources\0");
    hasher.update(sources_hash.as_bytes());
    hasher.update(b"\0content\0");
    hasher.update(stripped.as_bytes());
    hasher.finalize().to_hex().to_string()
}

/// Inject an `alef:hash:<hex>` line immediately after the first header marker
/// line found in the first 10 lines.  The comment syntax is inferred from the
/// marker line itself.
///
/// If no marker line is found, the content is returned unchanged.
pub fn inject_hash_line(content: &str, hash: &str) -> String {
    let mut result = String::with_capacity(content.len() + 80);
    let mut injected = false;

    for (i, line) in content.lines().enumerate() {
        result.push_str(line);
        result.push('\n');

        if !injected && i < 10 && line.contains(HEADER_MARKER) {
            let trimmed = line.trim();
            let hash_line = if trimmed.starts_with("<!--") {
                // XML comment: inject hash line as XML comment
                format!("<!-- {HASH_PREFIX}{hash} -->")
            } else if trimmed.starts_with("//") {
                format!("// {HASH_PREFIX}{hash}")
            } else if trimmed.starts_with('#') {
                format!("# {HASH_PREFIX}{hash}")
            } else if trimmed.starts_with("/*") || trimmed.starts_with('*') || trimmed.ends_with("*/") {
                format!(" * {HASH_PREFIX}{hash}")
            } else {
                format!("// {HASH_PREFIX}{hash}")
            };
            result.push_str(&hash_line);
            result.push('\n');
            injected = true;
        }
    }

    // Preserve original trailing-newline behavior.
    if !content.ends_with('\n') && result.ends_with('\n') {
        result.pop();
    }

    result
}

/// Extract the hash from an `alef:hash:<hex>` token in the first 10 lines.
pub fn extract_hash(content: &str) -> Option<String> {
    for (i, line) in content.lines().enumerate() {
        if i >= 10 {
            break;
        }
        if let Some(pos) = line.find(HASH_PREFIX) {
            let rest = &line[pos + HASH_PREFIX.len()..];
            // Trim trailing comment closers and whitespace.
            let hex = rest.trim().trim_end_matches("*/").trim_end_matches("-->").trim();
            if !hex.is_empty() {
                return Some(hex.to_string());
            }
        }
    }
    None
}

/// Strip the `alef:hash:` line from content (for fallback comparison).
pub fn strip_hash_line(content: &str) -> String {
    let mut result = String::with_capacity(content.len());
    for line in content.lines() {
        if line.contains(HASH_PREFIX) {
            continue;
        }
        result.push_str(line);
        result.push('\n');
    }
    // Preserve original trailing-newline behavior.
    if !content.ends_with('\n') && result.ends_with('\n') {
        result.pop();
    }
    result
}

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

    #[test]
    fn test_header_double_slash() {
        let h = header(CommentStyle::DoubleSlash);
        assert!(h.contains("// This file is auto-generated by alef"));
        assert!(h.contains("// Issues & docs: https://github.com/sample_crate-dev/alef"));
    }

    #[test]
    fn test_header_for_config_uses_configured_metadata() {
        let cfg: crate::core::config::NewAlefConfig = toml::from_str(
            r#"
[workspace]
languages = ["python"]

[workspace.generated_header]
issues_url = "https://docs.example.invalid/alef"
regenerate_command = "task generate"
verify_command = "task verify"

[[crates]]
name = "demo"
sources = ["src/lib.rs"]
"#,
        )
        .unwrap();
        let resolved = cfg.resolve().unwrap().remove(0);

        let h = header_for_config(CommentStyle::DoubleSlash, &resolved);

        assert!(h.contains("// To regenerate: task generate"));
        assert!(h.contains("// To verify freshness: task verify"));
        assert!(h.contains("// Issues & docs: https://docs.example.invalid/alef"));
    }

    #[test]
    fn test_header_hash() {
        let h = header(CommentStyle::Hash);
        assert!(h.contains("# This file is auto-generated by alef"));
    }

    #[test]
    fn test_header_block() {
        let h = header(CommentStyle::Block);
        assert!(h.starts_with("/*\n"));
        assert!(h.contains(" * This file is auto-generated by alef"));
        assert!(h.ends_with(" */\n"));
    }

    #[test]
    fn test_inject_and_extract_rust() {
        let h = header(CommentStyle::DoubleSlash);
        let content = format!("{h}use foo;\n");
        let hash = hash_content(&content);
        let injected = inject_hash_line(&content, &hash);
        assert!(injected.contains(HASH_PREFIX));
        assert_eq!(extract_hash(&injected), Some(hash));
    }

    #[test]
    fn test_inject_and_extract_python() {
        let h = header(CommentStyle::Hash);
        let content = format!("{h}import foo\n");
        let hash = hash_content(&content);
        let injected = inject_hash_line(&content, &hash);
        assert!(injected.contains(&format!("# {HASH_PREFIX}")));
        assert_eq!(extract_hash(&injected), Some(hash));
    }

    #[test]
    fn test_inject_and_extract_c_block() {
        let h = header(CommentStyle::Block);
        let content = format!("{h}#include <stdio.h>\n");
        let hash = hash_content(&content);
        let injected = inject_hash_line(&content, &hash);
        assert!(injected.contains(HASH_PREFIX));
        // Hash line must use C block-comment continuation (` * `), not `//`.
        // Mixing `//` inside a `/* ... */` block produces a malformed header
        // and would (depending on formatter) break verify.
        assert!(
            injected.contains(&format!(" * {HASH_PREFIX}")),
            "expected ' * {HASH_PREFIX}' in block-comment header, got:\n{injected}"
        );
        assert!(
            !injected.contains(&format!("// {HASH_PREFIX}")),
            "block-comment header must not use '//' for the hash line, got:\n{injected}"
        );
        assert_eq!(extract_hash(&injected), Some(hash));
    }

    #[test]
    fn test_inject_php_line2() {
        let h = header(CommentStyle::DoubleSlash);
        let content = format!("<?php\n{h}namespace Foo;\n");
        let hash = hash_content(&content);
        let injected = inject_hash_line(&content, &hash);
        let lines: Vec<&str> = injected.lines().collect();
        assert_eq!(lines[0], "<?php");
        assert!(lines[1].contains(HEADER_MARKER));
        assert!(lines.iter().any(|l| l.contains(HASH_PREFIX)));
        assert_eq!(extract_hash(&injected), Some(hash));
    }

    #[test]
    fn test_no_header_returns_unchanged() {
        let content = "fn main() {}\n";
        let injected = inject_hash_line(content, "abc123");
        assert_eq!(injected, content);
        assert_eq!(extract_hash(&injected), None);
    }

    #[test]
    fn test_strip_hash_line() {
        let content = "// auto-generated by alef\n// alef:hash:abc123\nuse foo;\n";
        let stripped = strip_hash_line(content);
        assert_eq!(stripped, "// auto-generated by alef\nuse foo;\n");
    }

    #[test]
    fn test_roundtrip() {
        let h = header(CommentStyle::Hash);
        let original = format!("{h}import sys\n");
        let hash = hash_content(&original);
        let injected = inject_hash_line(&original, &hash);
        let stripped = strip_hash_line(&injected);
        assert_eq!(stripped, original);
        assert_eq!(hash_content(&stripped), hash);
    }

    // ----- compute_sources_hash / compute_file_hash --------------------------

    use std::path::{Path, PathBuf};
    use tempfile::tempdir;

    fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
        let path = dir.join(name);
        std::fs::write(&path, content).unwrap();
        path
    }

    #[test]
    fn sources_hash_changes_when_path_changes_even_if_content_same() {
        let dir = tempdir().unwrap();
        let s_a = write_file(dir.path(), "a.rs", "fn a() {}");
        std::fs::create_dir_all(dir.path().join("moved")).unwrap();
        let s_b = write_file(dir.path(), "moved/a.rs", "fn a() {}");
        let h_a = compute_sources_hash(&[s_a]).unwrap();
        let h_b = compute_sources_hash(&[s_b]).unwrap();
        assert_ne!(
            h_a, h_b,
            "same content at a different path can produce different IR (rust_path differs)"
        );
    }

    #[test]
    fn sources_hash_errors_on_missing_source() {
        let dir = tempdir().unwrap();
        let bogus = dir.path().join("does-not-exist.rs");
        assert!(compute_sources_hash(&[bogus]).is_err());
    }

    #[test]
    fn sources_hash_stable_across_runs() {
        let dir = tempdir().unwrap();
        let s1 = write_file(dir.path(), "a.rs", "fn a() {}");
        let s2 = write_file(dir.path(), "b.rs", "fn b() {}");
        let sources = vec![s1, s2];
        let h1 = compute_sources_hash(&sources).unwrap();
        let h2 = compute_sources_hash(&sources).unwrap();
        assert_eq!(h1, h2);
    }

    #[test]
    fn sources_hash_path_order_independent() {
        let dir = tempdir().unwrap();
        let s1 = write_file(dir.path(), "a.rs", "fn a() {}");
        let s2 = write_file(dir.path(), "b.rs", "fn b() {}");
        let h_forward = compute_sources_hash(&[s1.clone(), s2.clone()]).unwrap();
        let h_reverse = compute_sources_hash(&[s2, s1]).unwrap();
        assert_eq!(h_forward, h_reverse);
    }

    #[test]
    fn sources_hash_changes_with_content() {
        let dir = tempdir().unwrap();
        let s = write_file(dir.path(), "a.rs", "fn a() {}");
        let h_before = compute_sources_hash(std::slice::from_ref(&s)).unwrap();
        std::fs::write(&s, "fn a() { let _ = 1; }").unwrap();
        let h_after = compute_sources_hash(&[s]).unwrap();
        assert_ne!(h_before, h_after);
    }

    #[test]
    fn file_hash_idempotent_under_strip_hash_line() {
        // The defining property: hash(content with hash line) == hash(content without hash line).
        // This is what makes the verify path symmetric with the generate path.
        let sources_hash = "abc123";
        let bare = "// auto-generated by alef\nfn body() {}\n";
        let with_line = "// auto-generated by alef\n// alef:hash:deadbeef\nfn body() {}\n";

        let h1 = compute_file_hash(sources_hash, bare);
        let h2 = compute_file_hash(sources_hash, with_line);
        assert_eq!(h1, h2, "hash must ignore an existing alef:hash: line");
    }

    #[test]
    fn file_hash_changes_when_sources_change() {
        let content = "// auto-generated by alef\nfn body() {}\n";
        let h_a = compute_file_hash("sources_a", content);
        let h_b = compute_file_hash("sources_b", content);
        assert_ne!(h_a, h_b);
    }

    #[test]
    fn file_hash_changes_when_content_changes() {
        let sources_hash = "abc123";
        let h_a = compute_file_hash(sources_hash, "fn a() {}\n");
        let h_b = compute_file_hash(sources_hash, "fn b() {}\n");
        assert_ne!(h_a, h_b);
    }

    #[test]
    fn file_hash_independent_of_alef_version() {
        // Idempotency property: the hash is purely a function of (sources, content).
        // Bumping the alef CLI version must not change it. Encoded by the type
        // signature — there is no version parameter — but make it explicit so
        // a future regression that re-introduces a version dimension is caught.
        let h = compute_file_hash("sources_hash", "fn a() {}\n");
        assert_eq!(h.len(), 64, "blake3 hex output is 64 chars");
    }

    // ----- compute_inputs_hash ------------------------------------------------

    #[test]
    fn inputs_hash_is_stable() {
        let h1 = compute_inputs_hash("abc", b"toml");
        let h2 = compute_inputs_hash("abc", b"toml");
        assert_eq!(h1, h2, "compute_inputs_hash must be deterministic");
        assert_eq!(h1.len(), 64, "blake3 hex output is 64 chars");
    }

    #[test]
    fn inputs_hash_changes_when_sources_hash_changes() {
        let h1 = compute_inputs_hash("sources_a", b"toml");
        let h2 = compute_inputs_hash("sources_b", b"toml");
        assert_ne!(h1, h2);
    }

    #[test]
    fn inputs_hash_changes_when_alef_toml_changes() {
        let h1 = compute_inputs_hash("sources", b"[workspace]\nlanguages=[\"python\"]\n");
        let h2 = compute_inputs_hash("sources", b"[workspace]\nlanguages=[\"ruby\"]\n");
        assert_ne!(h1, h2);
    }

    #[test]
    fn inputs_hash_changes_when_alef_rev_changes() {
        // We cannot change ALEF_REV at runtime, but we can verify the hash
        // includes a non-trivial prefix by checking that an empty sources_hash
        // and empty toml still produces a non-trivial 64-char hex string.
        let h = compute_inputs_hash("", b"");
        assert_eq!(h.len(), 64);
        // Verify it differs from a plain blake3("") which would be a pure null hash.
        let plain_empty = blake3::hash(b"").to_hex().to_string();
        assert_ne!(
            h, plain_empty,
            "inputs hash must include the alef:inputs prefix and ALEF_REV"
        );
    }

    #[test]
    fn inputs_hash_tolerates_empty_alef_toml() {
        // Empty alef_toml_bytes is valid (e.g. in unit tests without a real config).
        let h = compute_inputs_hash("some_sources_hash", b"");
        assert_eq!(h.len(), 64);
    }

    #[test]
    fn inputs_hash_differs_from_file_hash() {
        // compute_inputs_hash and compute_file_hash must not collide — they use
        // different domain separators so the outputs are distinct even when given
        // the same source input.
        let sources = "abc";
        let content = "fn a() {}\n";
        let ih = compute_inputs_hash(sources, content.as_bytes());
        let fh = compute_file_hash(sources, content);
        assert_ne!(ih, fh, "inputs hash and file hash must not collide");
    }

    #[test]
    fn crate_sources_hash_differs_across_crates_with_disjoint_sources() {
        use crate::core::config::resolved::ResolvedCrateConfig;

        let dir = tempdir().unwrap();
        let a = write_file(dir.path(), "a.rs", "fn a() {}");
        let b = write_file(dir.path(), "b.rs", "fn b() {}");

        // Build two minimal ResolvedCrateConfig values using the builder pattern
        // isn't available, so we construct via serde round-trip from JSON to avoid
        // requiring Default on the struct.  Instead, use helper that constructs the
        // minimal required fields directly.
        let make_cfg = |name: &str, sources: Vec<std::path::PathBuf>| ResolvedCrateConfig {
            name: name.to_string(),
            sources,
            source_crates: vec![],
            version_from: "Cargo.toml".to_string(),
            core_import: None,
            workspace_root: None,
            skip_core_import: false,
            error_type: None,
            error_constructor: None,
            features: vec![],
            path_mappings: Default::default(),
            extra_dependencies: Default::default(),
            auto_path_mappings: true,
            languages: vec![],
            python: None,
            node: None,
            ruby: None,
            php: None,
            elixir: None,
            wasm: None,
            ffi: None,
            go: None,
            java: None,
            dart: None,
            kotlin: None,
            kotlin_android: None,
            jni: None,
            swift: None,
            gleam: None,
            csharp: None,
            r: None,
            zig: None,
            exclude: Default::default(),
            include: Default::default(),
            output_paths: Default::default(),
            explicit_output: Default::default(),
            lint: Default::default(),
            test: Default::default(),
            setup: Default::default(),
            update: Default::default(),
            clean: Default::default(),
            build_commands: Default::default(),
            generate: Default::default(),
            generate_overrides: Default::default(),
            format: Default::default(),
            format_overrides: Default::default(),
            dto: Default::default(),
            tools: Default::default(),
            opaque_types: Default::default(),
            client_constructors: Default::default(),
            sync: None,
            citation: None,
            publish: None,
            e2e: None,
            adapters: vec![],
            trait_bridges: vec![],
            services: vec![],
            handler_contracts: vec![],
            scaffold: None,
            package_metadata: None,
            readme: None,
            custom_files: Default::default(),
            custom_modules: Default::default(),
            custom_registrations: Default::default(),
        };

        let cfg_a = make_cfg("alpha", vec![a]);
        let cfg_b = make_cfg("beta", vec![b]);

        let hash_a = compute_crate_sources_hash(&cfg_a).unwrap();
        let hash_b = compute_crate_sources_hash(&cfg_b).unwrap();

        assert_ne!(
            hash_a, hash_b,
            "crates with disjoint sources must produce different hashes"
        );
    }

    #[test]
    fn crate_sources_hash_includes_source_crates() {
        use crate::core::config::{SourceCrate, resolved::ResolvedCrateConfig};

        let dir = tempdir().unwrap();
        let a = write_file(dir.path(), "a.rs", "fn a() {}");
        let b = write_file(dir.path(), "b.rs", "fn b() {}");

        let make_cfg =
            |sources: Vec<std::path::PathBuf>, source_crate_sources: Vec<std::path::PathBuf>| -> ResolvedCrateConfig {
                let source_crates = if source_crate_sources.is_empty() {
                    vec![]
                } else {
                    vec![SourceCrate {
                        name: "extra-crate".to_string(),
                        sources: source_crate_sources,
                    }]
                };
                ResolvedCrateConfig {
                    name: "test".to_string(),
                    sources,
                    source_crates,
                    version_from: "Cargo.toml".to_string(),
                    core_import: None,
                    workspace_root: None,
                    skip_core_import: false,
                    error_type: None,
                    error_constructor: None,
                    features: vec![],
                    path_mappings: Default::default(),
                    extra_dependencies: Default::default(),
                    auto_path_mappings: true,
                    languages: vec![],
                    python: None,
                    node: None,
                    ruby: None,
                    php: None,
                    elixir: None,
                    wasm: None,
                    ffi: None,
                    go: None,
                    java: None,
                    dart: None,
                    kotlin: None,
                    kotlin_android: None,
                    jni: None,
                    swift: None,
                    gleam: None,
                    csharp: None,
                    r: None,
                    zig: None,
                    exclude: Default::default(),
                    include: Default::default(),
                    output_paths: Default::default(),
                    explicit_output: Default::default(),
                    lint: Default::default(),
                    test: Default::default(),
                    setup: Default::default(),
                    update: Default::default(),
                    clean: Default::default(),
                    build_commands: Default::default(),
                    generate: Default::default(),
                    generate_overrides: Default::default(),
                    format: Default::default(),
                    format_overrides: Default::default(),
                    dto: Default::default(),
                    tools: Default::default(),
                    opaque_types: Default::default(),
                    client_constructors: Default::default(),
                    sync: None,
                    citation: None,
                    publish: None,
                    e2e: None,
                    adapters: vec![],
                    trait_bridges: vec![],
                    services: vec![],
                    handler_contracts: vec![],
                    scaffold: None,
                    package_metadata: None,
                    readme: None,
                    custom_files: Default::default(),
                    custom_modules: Default::default(),
                    custom_registrations: Default::default(),
                }
            };

        let cfg_without_extra = make_cfg(vec![a.clone()], vec![]);
        let cfg_with_extra = make_cfg(vec![a.clone()], vec![b.clone()]);

        let hash_without = compute_crate_sources_hash(&cfg_without_extra).unwrap();
        let hash_with = compute_crate_sources_hash(&cfg_with_extra).unwrap();

        assert_ne!(
            hash_without, hash_with,
            "adding a source_crate source file must change the hash"
        );
    }

    #[test]
    fn compute_crate_sources_hash_dedupes_overlapping_paths() {
        use crate::core::config::{SourceCrate, resolved::ResolvedCrateConfig};
        // A source path appearing in both `sources` and a `source_crates` entry
        // (or repeated within `sources`) is hashed once: the hash equals the
        // hash of the same crate config with the duplicates removed.
        let dir = tempdir().unwrap();
        let a = write_file(dir.path(), "a.rs", "fn a() {}");
        let b = write_file(dir.path(), "b.rs", "fn b() {}");

        let make_cfg =
            |sources: Vec<std::path::PathBuf>, source_crate_sources: Vec<std::path::PathBuf>| -> ResolvedCrateConfig {
                let source_crates = if source_crate_sources.is_empty() {
                    vec![]
                } else {
                    vec![SourceCrate {
                        name: "extra-crate".to_string(),
                        sources: source_crate_sources,
                    }]
                };
                ResolvedCrateConfig {
                    name: "test".to_string(),
                    sources,
                    source_crates,
                    version_from: "Cargo.toml".to_string(),
                    core_import: None,
                    workspace_root: None,
                    skip_core_import: false,
                    error_type: None,
                    error_constructor: None,
                    features: vec![],
                    path_mappings: Default::default(),
                    extra_dependencies: Default::default(),
                    auto_path_mappings: true,
                    languages: vec![],
                    python: None,
                    node: None,
                    ruby: None,
                    php: None,
                    elixir: None,
                    wasm: None,
                    ffi: None,
                    go: None,
                    java: None,
                    dart: None,
                    kotlin: None,
                    kotlin_android: None,
                    jni: None,
                    swift: None,
                    gleam: None,
                    csharp: None,
                    r: None,
                    zig: None,
                    exclude: Default::default(),
                    include: Default::default(),
                    output_paths: Default::default(),
                    explicit_output: Default::default(),
                    lint: Default::default(),
                    test: Default::default(),
                    setup: Default::default(),
                    update: Default::default(),
                    clean: Default::default(),
                    build_commands: Default::default(),
                    generate: Default::default(),
                    generate_overrides: Default::default(),
                    format: Default::default(),
                    format_overrides: Default::default(),
                    dto: Default::default(),
                    tools: Default::default(),
                    opaque_types: Default::default(),
                    client_constructors: Default::default(),
                    sync: None,
                    citation: None,
                    publish: None,
                    e2e: None,
                    adapters: vec![],
                    trait_bridges: vec![],
                    services: vec![],
                    handler_contracts: vec![],
                    scaffold: None,
                    package_metadata: None,
                    readme: None,
                    custom_files: Default::default(),
                    custom_modules: Default::default(),
                    custom_registrations: Default::default(),
                }
            };

        // `sources` lists `a` twice and `source_crates` also references `a`.
        let cfg_with_dupes = make_cfg(vec![a.clone(), a.clone(), b.clone()], vec![a.clone()]);
        let cfg_unique = make_cfg(vec![a.clone(), b.clone()], vec![]);

        let hash_dup = compute_crate_sources_hash(&cfg_with_dupes).unwrap();
        let hash_unique = compute_crate_sources_hash(&cfg_unique).unwrap();
        assert_eq!(
            hash_dup, hash_unique,
            "duplicate source paths must not affect the per-crate sources hash"
        );
    }

    #[test]
    fn compute_crate_sources_hash_is_order_independent() {
        use crate::core::config::resolved::ResolvedCrateConfig;
        // Reordering `sources` (or the entries inside a `source_crates` entry)
        // does not change the per-crate sources hash.
        let dir = tempdir().unwrap();
        let a = write_file(dir.path(), "a.rs", "fn a() {}");
        let b = write_file(dir.path(), "b.rs", "fn b() {}");
        let c = write_file(dir.path(), "c.rs", "fn c() {}");

        let make_cfg = |sources: Vec<std::path::PathBuf>| -> ResolvedCrateConfig {
            ResolvedCrateConfig {
                name: "test".to_string(),
                sources,
                source_crates: vec![],
                version_from: "Cargo.toml".to_string(),
                core_import: None,
                workspace_root: None,
                skip_core_import: false,
                error_type: None,
                error_constructor: None,
                features: vec![],
                path_mappings: Default::default(),
                extra_dependencies: Default::default(),
                auto_path_mappings: true,
                languages: vec![],
                python: None,
                node: None,
                ruby: None,
                php: None,
                elixir: None,
                wasm: None,
                ffi: None,
                go: None,
                java: None,
                dart: None,
                kotlin: None,
                kotlin_android: None,
                jni: None,
                swift: None,
                gleam: None,
                csharp: None,
                r: None,
                zig: None,
                exclude: Default::default(),
                include: Default::default(),
                output_paths: Default::default(),
                explicit_output: Default::default(),
                lint: Default::default(),
                test: Default::default(),
                setup: Default::default(),
                update: Default::default(),
                clean: Default::default(),
                build_commands: Default::default(),
                generate: Default::default(),
                generate_overrides: Default::default(),
                format: Default::default(),
                format_overrides: Default::default(),
                dto: Default::default(),
                tools: Default::default(),
                opaque_types: Default::default(),
                client_constructors: Default::default(),
                sync: None,
                citation: None,
                publish: None,
                e2e: None,
                adapters: vec![],
                trait_bridges: vec![],
                services: vec![],
                handler_contracts: vec![],
                scaffold: None,
                package_metadata: None,
                readme: None,
                custom_files: Default::default(),
                custom_modules: Default::default(),
                custom_registrations: Default::default(),
            }
        };

        let cfg1 = make_cfg(vec![a.clone(), b.clone(), c.clone()]);
        let cfg2 = make_cfg(vec![c.clone(), a.clone(), b.clone()]);
        let cfg3 = make_cfg(vec![b.clone(), c.clone(), a.clone()]);

        let h1 = compute_crate_sources_hash(&cfg1).unwrap();
        let h2 = compute_crate_sources_hash(&cfg2).unwrap();
        let h3 = compute_crate_sources_hash(&cfg3).unwrap();
        assert_eq!(h1, h2, "reordering sources must not change the hash");
        assert_eq!(h2, h3, "reordering sources must not change the hash");
    }

    #[test]
    fn file_hash_round_trip_via_inject_extract() {
        // Simulate the full generate/verify cycle:
        // 1. generate: compute hash from stripped content, inject into header
        // 2. verify: read back, extract hash, recompute from content, compare
        let sources_hash = "abc123";
        let raw = "// auto-generated by alef\nfn body() {}\n";
        let file_hash = compute_file_hash(sources_hash, raw);
        let on_disk = inject_hash_line(raw, &file_hash);

        let extracted = extract_hash(&on_disk).expect("hash line should be present");
        let recomputed = compute_file_hash(sources_hash, &on_disk);
        assert_eq!(extracted, file_hash);
        assert_eq!(recomputed, file_hash);
        assert_eq!(extracted, recomputed, "verify must reproduce the embedded hash");
    }
}
