use crate::core::config::ResolvedCrateConfig;
use crate::core::ir::{ApiSurface, TypeDef, TypeRef};
use ahash::{AHashMap, AHashSet};
use anyhow::Context as _;
use std::collections::HashMap;
use std::path::Path;
use tracing::{debug, info};

use crate::cli::cache;

use super::version::read_version;

/// Ensure required entries are in `.gitignore` — creates the file if absent.
/// Adds `.alef/` (cache) and language-specific build artifacts based on config.
pub fn ensure_gitignore(base_dir: &Path, config: &ResolvedCrateConfig) {
    use crate::core::config::Language;

    let gitignore_path = base_dir.join(".gitignore");
    let existing = std::fs::read_to_string(&gitignore_path).unwrap_or_default();
    let existing_lines: AHashSet<&str> = existing.lines().map(str::trim).collect();

    let mut entries: Vec<&str> = vec![".alef/"];

    for lang in &config.languages {
        match lang {
            Language::Python => {
                entries.extend_from_slice(&["__pycache__/", "*.so", "*.pyd", ".venv/", "*.egg-info/", "dist/"])
            }
            Language::Node => entries.extend_from_slice(&["node_modules/", "*.node"]),
            Language::Ruby => entries.extend_from_slice(&[".gems/", "vendor/bundle/"]),
            Language::Php => entries.extend_from_slice(&["vendor/"]),
            Language::Ffi => entries.push("*.h.bak"),
            Language::Go => entries.push("*.test"),
            Language::Java => entries.extend_from_slice(&["target/", "*.class"]),
            Language::Csharp => entries.extend_from_slice(&["bin/", "obj/", "*.nupkg"]),
            // pkg/ intentionally NOT gitignored — npm publish needs it for WASM artifacts
            Language::Wasm => {}
            _ => {}
        }
    }

    let mut to_add = Vec::new();
    for entry in &entries {
        if !existing_lines.contains(entry) {
            to_add.push(*entry);
        }
    }

    if to_add.is_empty() {
        return;
    }

    let separator = if existing.is_empty() || existing.ends_with('\n') {
        ""
    } else {
        "\n"
    };
    let additions = to_add.join("\n");
    let new_content = format!("{existing}{separator}{additions}\n");

    if let Err(e) = std::fs::write(&gitignore_path, new_content) {
        debug!("Could not update .gitignore: {e}");
    } else {
        debug!("Updated .gitignore with {} entries", to_add.len());
    }
}

/// Run extraction, with caching.
pub fn extract(config: &ResolvedCrateConfig, config_path: &Path, clean: bool) -> anyhow::Result<ApiSurface> {
    // Ensure .gitignore has required entries
    if let Some(parent) = config_path.parent() {
        ensure_gitignore(parent, config);
    }

    cache::validate_cache_crate_name(&config.name).context("invalid crate name for cache")?;
    let source_hash = cache::sources_hash(&config.sources).context("failed to compute sources hash")?;
    // Mix the resolved workspace version into the cache key. The IR embeds
    // `api.version`, which is read fresh from `version_from` (Cargo.toml) at
    // extract time. Sources alone don't change when the version is bumped, so
    // without this the cache would hand back stale IR and downstream stages
    // (notably READMEs) would render the previous version's badges/snippets.
    let version_for_hash = config.resolved_version().unwrap_or_default();
    let cache_key = format!("{source_hash}:{version_for_hash}");

    if !clean && cache::is_ir_cached(&config.name, &cache_key) {
        info!("Using cached IR");
        return cache::read_cached_ir(&config.name).context("failed to read cached IR");
    }

    let mut api = extract_raw(config, config_path)?;

    // Apply global filters (includes and excludes)
    api = apply_filters(api, config);

    // Inject declared opaque types from config (external crate types alef can't extract)
    inject_declared_opaque_types(&mut api, config);

    // Remove cfg-gated fields unless their feature is in [crate].features.
    // Binding crates may have different features enabled than the core crate,
    // so cfg-gated fields are only included when explicitly listed.
    strip_cfg_fields(&mut api, &config.features);

    // Remove source-declared internal/runtime items (types, enums, errors,
    // functions, methods) and fields from the polyglot binding surface before
    // unknown-type sanitization can collapse them into fake String fields.
    strip_binding_excluded(&mut api)?;

    // Replace references to types not in the API surface with String
    sanitize_unknown_types(&mut api);

    // Apply path mappings to rewrite rust_path fields before dedup so that
    // two types that had different raw paths but map to the same rewritten
    // path are correctly collapsed into one.
    apply_path_mappings(&mut api, config);

    // Deduplicate types, enums, and functions by name (after path mapping so
    // rewritten paths are used for the shortest-path preference heuristic).
    dedup_api_surface(&mut api);

    cache::write_ir_cache(&config.name, &api, &cache_key).context("failed to write IR cache")?;
    info!(
        "Extracted {} types, {} functions, {} enums",
        api.types.len(),
        api.functions.len(),
        api.enums.len()
    );

    Ok(api)
}

/// Shared raw extraction logic: parse sources, produce raw `ApiSurface`.
///
/// Groups source files by their owning crate (derived from `crates/{name}/src/` path
/// patterns) and extracts each group with the correct crate name. This ensures types
/// get accurate `rust_path` values reflecting their actual defining crate, not the
/// facade crate name from config.
fn extract_raw(config: &ResolvedCrateConfig, _config_path: &Path) -> anyhow::Result<ApiSurface> {
    info!("Extracting API surface from Rust source...");
    let version = read_version(&config.version_from)?;
    let workspace_root = config.workspace_root.as_deref();
    let default_name = &config.name;

    // Build source groups: use explicit source_crates config when available,
    // otherwise derive crate names from file paths in the flat sources list.
    let mut groups: std::collections::BTreeMap<String, Vec<&Path>> = std::collections::BTreeMap::new();
    if !config.source_crates.is_empty() {
        for sc in &config.source_crates {
            let crate_name = sc.name.replace('-', "_");
            for source in &sc.sources {
                groups.entry(crate_name.clone()).or_default().push(source.as_path());
            }
        }
    } else {
        for source in &config.sources {
            let crate_name = derive_crate_name_from_path(source, default_name);
            groups.entry(crate_name).or_default().push(source.as_path());
        }
    }

    // Extract each group with its own crate name, then merge
    let mut merged = ApiSurface {
        crate_name: default_name.to_string(),
        version: version.clone(),
        types: vec![],
        functions: vec![],
        enums: vec![],
        errors: vec![],
        excluded_type_paths: std::collections::HashMap::new(),
        excluded_trait_names: std::collections::HashSet::new(),
    };

    for (crate_name, sources) in &groups {
        let api = crate::extract::extractor::extract(sources, crate_name, &version, workspace_root)
            .with_context(|| format!("failed to extract API surface from crate {crate_name}"))?;
        merged.types.extend(api.types);
        merged.functions.extend(api.functions);
        merged.enums.extend(api.enums);
        merged.errors.extend(api.errors);
        merged.excluded_type_paths.extend(api.excluded_type_paths);
        merged.excluded_trait_names.extend(api.excluded_trait_names);
    }

    Ok(merged)
}

/// Derive the crate name from a source file path.
///
/// Matches `crates/{name}/src/` pattern and converts hyphens to underscores.
/// Falls back to the provided default name if the pattern doesn't match.
fn derive_crate_name_from_path(path: &Path, default: &str) -> String {
    let path_str = path.to_string_lossy();
    // Match both "crates/foo-bar/src/" and "/abs/path/crates/foo-bar/src/"
    if let Some(after_crates) = path_str.split("crates/").nth(1) {
        if let Some(name) = after_crates.split('/').next() {
            if path_str.contains(&format!("crates/{name}/src/")) {
                return name.replace('-', "_");
            }
        }
    }
    default.to_string()
}

/// Inject declared opaque types from config into the API surface.
/// These are external crate types that alef can't extract but needs to generate wrappers for.
fn inject_declared_opaque_types(api: &mut ApiSurface, config: &ResolvedCrateConfig) {
    let mut sorted_opaques: Vec<_> = config.opaque_types.iter().collect();
    sorted_opaques.sort_by_key(|(name, _)| (*name).clone());
    for (name, rust_path) in sorted_opaques {
        // Only add if not already in the API surface
        if !api.types.iter().any(|t| t.name == *name) && !api.enums.iter().any(|e| e.name == *name) {
            api.types.push(crate::core::ir::TypeDef {
                name: name.clone(),
                rust_path: rust_path.clone(),
                original_rust_path: rust_path.clone(),
                fields: vec![],
                methods: vec![],
                is_opaque: true,
                is_clone: false,
                is_copy: false,
                is_trait: false,
                has_default: false,
                has_stripped_cfg_fields: false,
                is_return_type: false,
                doc: String::new(),
                cfg: None,
                serde_rename_all: None,
                has_serde: false,
                super_traits: vec![],
                binding_excluded: false,
                binding_exclusion_reason: None,
            });
            debug!("Injected declared opaque type: {name} -> {rust_path}");
        }
    }
}

/// Replace `TypeRef::Named(name)` references that don't exist in the API surface
/// with `TypeRef::String`. This handles trait objects, generic bounds, and other types
/// that were extracted but filtered out or never existed as concrete types.
fn sanitize_unknown_types(api: &mut ApiSurface) {
    let known_types: AHashSet<String> = api.types.iter().map(|t| t.name.clone()).collect();
    let known_enums: AHashSet<String> = api.enums.iter().map(|e| e.name.clone()).collect();

    // Build a set of known rust_paths for types and enums.
    // This enables disambiguation of types with the same short name but different
    // module paths (e.g., `sample_core::types::OutputFormat` vs `sample_core::OutputFormat`).
    // Normalize hyphens to underscores in paths for consistent comparison.
    let known_type_paths: AHashSet<String> = api.types.iter().map(|t| t.rust_path.replace('-', "_")).collect();
    let known_enum_paths: AHashSet<String> = api.enums.iter().map(|e| e.rust_path.replace('-', "_")).collect();

    for typ in &mut api.types {
        for field in &mut typ.fields {
            let original = extract_tuple_vec_original_type(&field.ty);
            if sanitize_type_ref(&mut field.ty, &known_types, &known_enums) {
                field.sanitized = true;
                if let Some(orig) = original {
                    field.original_type = Some(orig);
                }
            }
            // Second pass: check type_rust_path for name-collision disambiguation.
            // If a field has a type_rust_path that doesn't match any known type/enum rust_path,
            // it references a different type that happens to share the same short name
            // (e.g., crate::types::OutputFormat vs crate::core::config::OutputFormat).
            if !field.sanitized {
                if let Some(ref path) = field.type_rust_path {
                    let normalized_path = path.replace('-', "_");
                    if let TypeRef::Named(ref name) = field.ty {
                        // Only check if the name matches a known type/enum — otherwise it's
                        // already handled by the standard sanitization above.
                        if known_types.contains(name.as_str()) || known_enums.contains(name.as_str()) {
                            // Check if the full path's last segment matches any known type/enum path's last segment.
                            // This handles cases where module paths differ but the type is the same
                            // (e.g., crate::metadata::HtmlMetadata vs sample-markdown-rs::HtmlMetadata).
                            let path_type_name = normalized_path.rsplit("::").next().unwrap_or("");
                            let path_matches = known_type_paths
                                .iter()
                                .chain(known_enum_paths.iter())
                                .any(|kp| kp.rsplit("::").next().unwrap_or("") == path_type_name);
                            if !path_matches {
                                field.ty = TypeRef::String;
                                field.sanitized = true;
                            }
                        }
                    }
                    // Also check Named types inside Optional/Vec wrappers
                    if let TypeRef::Vec(ref inner) = field.ty {
                        if let TypeRef::Named(ref name) = **inner {
                            let vec_path_type = normalized_path.rsplit("::").next().unwrap_or("");
                            let vec_matches = known_type_paths
                                .iter()
                                .chain(known_enum_paths.iter())
                                .any(|kp| kp.rsplit("::").next().unwrap_or("") == vec_path_type);
                            if (known_types.contains(name.as_str()) || known_enums.contains(name.as_str()))
                                && !vec_matches
                            {
                                field.ty = TypeRef::String;
                                field.sanitized = true;
                            }
                        }
                    }
                }
            }
        }
        let type_name = typ.name.clone();
        let is_trait = typ.is_trait;
        for method in &mut typ.methods {
            // Trait method params and return types must match the original Rust trait
            // signature exactly — bridge codegen emits `impl Trait for Wrapper { fn ... }`
            // and the impl must satisfy the trait. Sanitizing these would cause
            // E0053 (incompatible type) trait coherence errors. Internal-only param
            // types are handled by per-backend JSON serialization in the bridge body.
            if is_trait {
                continue;
            }
            let mut method_sanitized = false;
            for param in &mut method.params {
                if sanitize_type_ref(&mut param.ty, &known_types, &known_enums) {
                    param.sanitized = true;
                    method_sanitized = true;
                }
            }
            // Skip sanitizing return type if it's Named(parent_type) — builder/factory pattern.
            // Methods that return their own type (e.g. with_foo(&self) -> Self) should keep
            // the Named return so codegen can delegate them correctly.
            let is_self_return = matches!(&method.return_type, TypeRef::Named(n) if n == &type_name);
            if !is_self_return && sanitize_type_ref(&mut method.return_type, &known_types, &known_enums) {
                method_sanitized = true;
            }
            if method_sanitized {
                method.sanitized = true;
            }
        }
    }
    for func in &mut api.functions {
        let mut func_sanitized = false;
        for param in &mut func.params {
            if sanitize_type_ref(&mut param.ty, &known_types, &known_enums) {
                param.sanitized = true;
                func_sanitized = true;
            }
        }
        if sanitize_type_ref(&mut func.return_type, &known_types, &known_enums) {
            func_sanitized = true;
            func.return_sanitized = true;
        }
        if func_sanitized {
            func.sanitized = true;
        }
    }
    // Sanitize enum variant fields — tuples and other unknown types in data enum
    // variants must be replaced with String, otherwise backends emit invalid code
    // (e.g., Go emitting `[](String, String)` for Vec<(String, String)>).
    for enum_def in &mut api.enums {
        for variant in &mut enum_def.variants {
            for field in &mut variant.fields {
                let original = extract_tuple_vec_original_type(&field.ty);
                if sanitize_type_ref(&mut field.ty, &known_types, &known_enums) {
                    field.sanitized = true;
                    if let Some(orig) = original {
                        field.original_type = Some(orig);
                    }
                }
            }
        }
    }
    // Sanitize error variant fields as well.
    for error_def in &mut api.errors {
        for variant in &mut error_def.variants {
            for field in &mut variant.fields {
                let original = extract_tuple_vec_original_type(&field.ty);
                if sanitize_type_ref(&mut field.ty, &known_types, &known_enums) {
                    field.sanitized = true;
                    if let Some(orig) = original {
                        field.original_type = Some(orig);
                    }
                }
            }
        }
    }
}

fn strip_binding_excluded(api: &mut ApiSurface) -> anyhow::Result<()> {
    // --- Item-level exclusions: types, enums, errors, functions ---

    // Capture rust_paths of excluded types/enums/errors before removal so that
    // trait-bridge codegen can still reference them by qualified path.
    for typ in &api.types {
        if typ.binding_excluded {
            let reason = typ
                .binding_exclusion_reason
                .as_deref()
                .unwrap_or("source binding exclusion");
            info!("Stripping excluded type: {} ({})", typ.name, reason);
            api.excluded_type_paths
                .insert(typ.name.clone(), typ.rust_path.replace('-', "_"));
            // Preserve trait-ness across the strip so trait-bridge codegen can tell
            // an excluded trait (`&dyn Trait` → non-bridgeable, skip the method) from
            // an excluded struct/enum (`&InternalDocument` → reference by qualified path).
            if typ.is_trait {
                api.excluded_trait_names.insert(typ.name.clone());
            }
        }
    }
    for enm in &api.enums {
        if enm.binding_excluded {
            let reason = enm
                .binding_exclusion_reason
                .as_deref()
                .unwrap_or("source binding exclusion");
            info!("Stripping excluded enum: {} ({})", enm.name, reason);
            api.excluded_type_paths
                .insert(enm.name.clone(), enm.rust_path.replace('-', "_"));
        }
    }
    for err in &api.errors {
        if err.binding_excluded {
            let reason = err
                .binding_exclusion_reason
                .as_deref()
                .unwrap_or("source binding exclusion");
            info!("Stripping excluded error: {} ({})", err.name, reason);
            api.excluded_type_paths
                .insert(err.name.clone(), err.rust_path.replace('-', "_"));
        }
    }

    api.types.retain(|t| !t.binding_excluded);
    api.enums.retain(|e| !e.binding_excluded);
    api.errors.retain(|e| !e.binding_excluded);

    for func in &api.functions {
        if func.binding_excluded {
            let reason = func
                .binding_exclusion_reason
                .as_deref()
                .unwrap_or("source binding exclusion");
            info!("Stripping excluded function: {} ({})", func.name, reason);
        }
    }
    api.functions.retain(|f| !f.binding_excluded);

    // --- Method-level exclusions on retained types ---
    for typ in &mut api.types {
        let excluded_methods: Vec<String> = typ
            .methods
            .iter()
            .filter(|m| m.binding_excluded)
            .map(|m| {
                let reason = m
                    .binding_exclusion_reason
                    .as_deref()
                    .unwrap_or("source binding exclusion");
                format!("{}.{} ({})", typ.name, m.name, reason)
            })
            .collect();
        if !excluded_methods.is_empty() {
            info!("Stripping excluded methods: {}", excluded_methods.join(", "));
        }
        typ.methods.retain(|m| !m.binding_excluded);
    }

    // --- Field-level exclusions ---
    // Keep excluded fields in IR so conversion generators can still initialize the
    // core field (usually with Default::default()) while public binding DTOs hide it.
    for typ in &api.types {
        let excluded: Vec<_> = typ
            .fields
            .iter()
            .filter(|field| field.binding_excluded)
            .map(|field| {
                let reason = field
                    .binding_exclusion_reason
                    .as_deref()
                    .unwrap_or("source binding exclusion");
                format!("{}.{} ({reason})", typ.name, field.name)
            })
            .collect();
        if !excluded.is_empty() {
            info!("Hiding binding-excluded fields: {}", excluded.join(", "));
        }
    }

    for enum_def in &mut api.enums {
        for variant in &mut enum_def.variants {
            variant.fields.retain(|field| !field.binding_excluded);
        }
    }

    for error_def in &mut api.errors {
        for variant in &mut error_def.variants {
            variant.fields.retain(|field| !field.binding_excluded);
        }
    }

    Ok(())
}

/// If `ty` is `Vec<(...)>` or `Option<Vec<(...)>>` — a Vec whose inner element is a tuple
/// type name — return a human-readable string capturing the original shape before sanitization
/// (e.g. `"Vec<(String, String)>"`).  Returns `None` for all other shapes.
///
/// This is called *before* `sanitize_type_ref` rewrites the inner `Named("(String, String)")`
/// to `String`, so backends can store this string in `FieldDef::original_type` and later emit
/// language-native pair types instead of a plain list.
fn extract_tuple_vec_original_type(ty: &TypeRef) -> Option<String> {
    fn inner_tuple_name(ty: &TypeRef) -> Option<String> {
        if let TypeRef::Vec(inner) = ty {
            if let TypeRef::Named(name) = inner.as_ref() {
                if name.trim_start().starts_with('(') {
                    return Some(format!("Vec<{name}>"));
                }
            }
        }
        None
    }
    /// Detect fixed-size tuple-array strings like `[(u32, u32); 4]`.
    ///
    /// The extractor emits these as `TypeRef::Named("[(u32, u32); 4]")` because there is no
    /// dedicated IR variant for fixed-size arrays.  We capture the string before sanitization
    /// so the wasm backend can reconstruct the type via `serde_wasm_bindgen::from_value`.
    fn fixed_tuple_array_name(name: &str) -> Option<String> {
        let s = name.trim();
        if s.starts_with("[(") && s.contains(");") {
            Some(s.to_string())
        } else {
            None
        }
    }
    match ty {
        TypeRef::Vec(_) => inner_tuple_name(ty),
        TypeRef::Optional(inner) => inner_tuple_name(inner),
        // Fixed-size tuple arrays arrive as Named("[(T, U); N]") from the extractor.
        TypeRef::Named(name) => fixed_tuple_array_name(name),
        _ => None,
    }
}

/// Returns true if the type was sanitized (changed from original).
fn sanitize_type_ref(ty: &mut TypeRef, known_types: &AHashSet<String>, known_enums: &AHashSet<String>) -> bool {
    match ty {
        TypeRef::Named(name) if !known_types.contains(name.as_str()) && !known_enums.contains(name.as_str()) => {
            // Detect homogeneous numeric tuple types such as `(u32, u32)` that serde serializes
            // as JSON arrays.  Map them to Vec<ElemType> so backends emit array types (e.g.
            // `[]uint32` in Go) rather than falling back to `string`.  This preserves round-trip
            // JSON compatibility: `null | [800, 600]` unmarshals correctly into `*[]uint32`.
            if let Some(elem_ty) = parse_homogeneous_tuple(name) {
                *ty = TypeRef::Vec(Box::new(elem_ty));
                return true; // Sanitized — the core type is a tuple, not a Vec
            }
            *ty = TypeRef::String;
            true
        }
        TypeRef::Optional(inner) | TypeRef::Vec(inner) => sanitize_type_ref(inner, known_types, known_enums),
        TypeRef::Map(k, v) => {
            // Sanitize inner key and value types (e.g. Named("str") → String) so
            // backends receive clean Map(String, Json) rather than Map(Named("str"), Json).
            // However, the Map *structure itself* is always valid — all backends have explicit
            // TypeRef::Map handling — so do NOT propagate sanitized=true to the caller.
            // If we returned true here, the field would be flagged as sanitized and the
            // conversion codegen would fall through to the Debug-format fallback
            // (format!("{:?}", val.field)), which is wrong for every backend.
            sanitize_type_ref(k, known_types, known_enums);
            sanitize_type_ref(v, known_types, known_enums);
            false
        }
        _ => false,
    }
}

/// Parse a homogeneous numeric tuple type string such as `"(u32,u32)"` or `"(u64, u64)"`.
///
/// Returns `Some(TypeRef)` for the element type when all comma-separated elements inside the
/// parentheses are the same primitive type.  Returns `None` for heterogeneous tuples, non-tuple
/// strings, or unsupported element types.
///
/// This lets `sanitize_type_ref` map `Option<(u32, u32)>` → `Optional(Vec(Primitive(U32)))`
/// instead of falling back to `String`, preserving JSON array round-trip compatibility.
fn parse_homogeneous_tuple(name: &str) -> Option<TypeRef> {
    use crate::core::ir::PrimitiveType;
    let name = name.trim();
    let inner = name.strip_prefix('(')?.strip_suffix(')')?;
    let parts: Vec<&str> = inner.split(',').map(str::trim).collect();
    if parts.is_empty() {
        return None;
    }
    let first = parts[0];
    if !parts.iter().all(|p| *p == first) {
        return None;
    }
    // Homogeneous String tuples (e.g. `(String, String)`) serialize as JSON arrays of strings,
    // so map them to Vec<String> like numeric homogeneous tuples.
    if first == "String" {
        return Some(TypeRef::String);
    }
    let prim = match first {
        "u8" => PrimitiveType::U8,
        "u16" => PrimitiveType::U16,
        "u32" => PrimitiveType::U32,
        "u64" => PrimitiveType::U64,
        "i8" => PrimitiveType::I8,
        "i16" => PrimitiveType::I16,
        "i32" => PrimitiveType::I32,
        "i64" => PrimitiveType::I64,
        "f32" => PrimitiveType::F32,
        "f64" => PrimitiveType::F64,
        "usize" => PrimitiveType::Usize,
        "isize" => PrimitiveType::Isize,
        _ => return None,
    };
    Some(TypeRef::Primitive(prim))
}

/// Deduplicate API surface items by name to prevent conflicting definitions.
/// This resolves:
/// 1. Type-enum collisions: If a name exists in both types and enums, keep only the enum
/// 2. Remove fields with `#[cfg(...)]` conditions from all types.
///
/// Binding crates may have different feature sets than the core crate,
/// so including cfg-gated fields causes compilation errors.
fn strip_cfg_fields(api: &mut ApiSurface, enabled_features: &[String]) {
    for typ in &mut api.types {
        let original_count = typ.fields.len();
        let cfg_count = typ.fields.iter().filter(|f| f.cfg.is_some()).count();
        // Retain non-cfg fields and cfg fields whose feature condition is satisfied
        // by the source crate. Per-binding feature filtering happens later in codegen,
        // which evaluates `field.cfg` against each binding's effective feature set —
        // so we keep the cfg attribute on retained fields rather than clearing it.
        typ.fields.retain(|f| match &f.cfg {
            None => true,
            Some(cfg_str) => cfg_condition_enabled(cfg_str, enabled_features),
        });
        // Mark if any cfg fields were actually stripped (not enabled).
        if cfg_count > 0 && typ.fields.len() < original_count {
            typ.has_stripped_cfg_fields = true;
        }
    }
}

/// Evaluate a `#[cfg(...)]` condition string against a set of enabled features.
///
/// Handles:
/// - `feature = "name"` — single feature check
/// - `any(feature = "a", feature = "b", ...)` — any feature enabled
/// - `all(feature = "a", feature = "b", ...)` — all features enabled
///
/// Defaults to `false` (strip the field) for unrecognized patterns.
fn cfg_condition_enabled(cfg_str: &str, enabled_features: &[String]) -> bool {
    // Normalize: trim outer whitespace and collapse spaces adjacent to punctuation.
    // proc-macro2's `to_string()` inserts spaces between tokens, so
    // `any(feature = "a")` becomes `any (feature = "a")`.
    // We normalise by removing spaces before `(` and around `=`.
    let normalized: String = {
        let t = cfg_str.trim();
        // Remove spaces before `(`: `any (` → `any(`
        let t = t.replace(" (", "(");
        // Remove spaces around `=`: `feature = "a"` stays (already fine), but
        // in case of `feature ="a"` or `feature= "a"` etc.
        // The proc-macro2 representation is `feature = "a"`, which after
        // `strip_prefix("feature = \"")` works correctly, so we only need the `any (` fix.
        t
    };
    let cfg_str = normalized.as_str();

    // Simple: `feature = "name"`
    if let Some(feature) = cfg_str.strip_prefix("feature = \"").and_then(|s| s.strip_suffix('"')) {
        return enabled_features.iter().any(|ef| ef == feature);
    }
    // `any(...)` — enabled if at least one condition matches
    if let Some(inner) = cfg_str.strip_prefix("any(").and_then(|s| s.strip_suffix(')')) {
        return parse_cfg_list(inner)
            .iter()
            .any(|cond| cfg_condition_enabled(cond, enabled_features));
    }
    // `all(...)` — enabled if all conditions match
    if let Some(inner) = cfg_str.strip_prefix("all(").and_then(|s| s.strip_suffix(')')) {
        return parse_cfg_list(inner)
            .iter()
            .all(|cond| cfg_condition_enabled(cond, enabled_features));
    }
    // `not(...)` — invert the inner condition
    if let Some(inner) = cfg_str.strip_prefix("not(").and_then(|s| s.strip_suffix(')')) {
        return !cfg_condition_enabled(inner.trim(), enabled_features);
    }
    // Unknown pattern — strip the field (conservative)
    false
}

/// Split a comma-separated list of cfg conditions, respecting nested parentheses.
fn parse_cfg_list(s: &str) -> Vec<String> {
    let mut result = Vec::new();
    let mut depth = 0usize;
    let mut current = String::new();
    for ch in s.chars() {
        match ch {
            '(' => {
                depth += 1;
                current.push(ch);
            }
            ')' => {
                depth = depth.saturating_sub(1);
                current.push(ch);
            }
            ',' if depth == 0 => {
                let trimmed = current.trim().to_string();
                if !trimmed.is_empty() {
                    result.push(trimmed);
                }
                current.clear();
            }
            _ => current.push(ch),
        }
    }
    let trimmed = current.trim().to_string();
    if !trimmed.is_empty() {
        result.push(trimmed);
    }
    result
}

/// 2. Duplicate types: Keep only the first occurrence of each type name
/// 3. Duplicate enums: Keep only the first occurrence of each enum name
/// 4. Duplicate functions: Keep only the first occurrence of each function name
fn dedup_api_surface(api: &mut ApiSurface) {
    // Remove types that collide with enums (enums win)
    let enum_names: AHashSet<String> = api.enums.iter().map(|e| e.name.clone()).collect();
    api.types.retain(|t| !enum_names.contains(&t.name));

    // Remove types that collide with errors (errors win).
    // This catches the case where extract_impl_block previously created an opaque TypeDef
    // for a thiserror error enum that also had inherent impl methods.
    let error_names: AHashSet<String> = api.errors.iter().map(|e| e.name.clone()).collect();
    api.types.retain(|t| !error_names.contains(&t.name));

    // Dedup types by name — prefer shorter rust_path (closer to crate root).
    // This handles name collisions like sample_core::Table vs sample_core::extraction::docx::parser::Table.
    {
        let mut best: AHashMap<String, usize> = AHashMap::new();
        for (i, t) in api.types.iter().enumerate() {
            best.entry(t.name.clone())
                .and_modify(|prev_i| {
                    if api.types[i].rust_path.len() < api.types[*prev_i].rust_path.len() {
                        *prev_i = i;
                    }
                })
                .or_insert(i);
        }
        let keep: AHashSet<usize> = best.values().copied().collect();
        let mut idx = 0;
        api.types.retain(|_| {
            let k = keep.contains(&idx);
            idx += 1;
            k
        });
    }

    // Dedup enums by name — prefer shorter rust_path.
    {
        let mut best: AHashMap<String, usize> = AHashMap::new();
        for (i, e) in api.enums.iter().enumerate() {
            best.entry(e.name.clone())
                .and_modify(|prev_i| {
                    if api.enums[i].rust_path.len() < api.enums[*prev_i].rust_path.len() {
                        *prev_i = i;
                    }
                })
                .or_insert(i);
        }
        let keep: AHashSet<usize> = best.values().copied().collect();
        let mut idx = 0;
        api.enums.retain(|_| {
            let k = keep.contains(&idx);
            idx += 1;
            k
        });
    }

    // Dedup functions by name — prefer shorter rust_path (closer to crate root).
    // This resolves C2: when the same function name exists at multiple definition
    // sites (e.g. sample_core::utils::clean_extracted_text and
    // sample_core::text::quality::clean_extracted_text), prefer the one re-exported
    // nearest to the crate root, which is the one users call via module = sample_core.
    {
        let mut best: AHashMap<String, usize> = AHashMap::new();
        for (i, f) in api.functions.iter().enumerate() {
            best.entry(f.name.clone())
                .and_modify(|prev_i| {
                    if api.functions[i].rust_path.len() < api.functions[*prev_i].rust_path.len() {
                        *prev_i = i;
                    }
                })
                .or_insert(i);
        }
        let keep: AHashSet<usize> = best.values().copied().collect();
        let mut idx = 0;
        api.functions.retain(|_| {
            let k = keep.contains(&idx);
            idx += 1;
            k
        });
    }

    // Dedup errors by name (keep first)
    let mut seen_errors: AHashSet<String> = AHashSet::new();
    api.errors.retain(|e| seen_errors.insert(e.name.clone()));
}

/// Returns true if `name` (short) or `rust_path` (fully qualified) matches any entry in
/// `exclude_list`.
///
/// Entries that contain `::` are treated as fully-qualified Rust paths and matched against
/// `rust_path` (with `-` normalized to `_`). Plain entries (no `::`) are matched against
/// `name` only. This allows precise disambiguation when two types share the same short name
/// but live in different modules, e.g.:
///
/// ```toml
/// [crates.exclude]
/// types = [
///   "sample_core::core::config::formats::OutputFormat",  # internal; matched by rust_path
///   "OutputFormat",                                    # would match by name (all variants)
/// ]
/// ```
fn is_type_excluded(name: &str, rust_path: &str, exclude_list: &[String]) -> bool {
    exclude_list.iter().any(|entry| {
        if entry.contains("::") {
            // Fully-qualified path: match against rust_path (normalise hyphens to underscores).
            let normalised = rust_path.replace('-', "_");
            normalised == entry.as_str()
        } else {
            // Short name: match against the simple type name.
            name == entry.as_str()
        }
    })
}

fn apply_filters(mut api: ApiSurface, config: &ResolvedCrateConfig) -> ApiSurface {
    let exclude = &config.exclude;
    let include = &config.include;

    // Apply includes first (whitelist), expanding to transitively referenced types.
    //
    // The expansion seeds from BOTH `include.types` and the parameter/return types
    // of `include.functions`. Without the function seed, wrapper return types like
    // `BatchScrapeResults` (declared alongside the function that returns them) are
    // silently dropped when the user lists only the per-element type in `include.types`
    // — codegen then sees `return_type = String` after `sanitize_unknown_types` collapses
    // the unknown Named reference, and every binding facade emits the wrong signature.
    //
    // Including types reachable from included functions is the conservative fix: the
    // user already opted into the function via `include.functions`, so its public
    // signature (return type + params) is implicitly part of the binding surface.
    if !include.types.is_empty() {
        let expanded = expand_include_list(&api, &include.types, &include.functions);
        api.types.retain(|t| expanded.contains(&t.name));
        api.enums.retain(|e| expanded.contains(&e.name));
        // Errors are NOT filtered by include list — they're always extracted
        // when [generate] errors = true (controlled by the generation layer, not include)
    }
    if !include.functions.is_empty() {
        api.functions.retain(|f| include.functions.contains(&f.name));
    }

    // Then apply excludes (blacklist).
    // Entries containing `::` are matched against rust_path (fully-qualified); others by name.
    //
    // Capture rust_paths of excluded types BEFORE dropping them, so trait_bridge
    // codegen can still reference them by qualified path when they appear in trait
    // method signatures (preserves `impl Trait for Wrapper { fn render(&self,
    // doc: &sample_core::types::internal::InternalDocument) }`).
    for typ in &api.types {
        if is_type_excluded(&typ.name, &typ.rust_path, &exclude.types) {
            api.excluded_type_paths
                .insert(typ.name.clone(), typ.rust_path.replace('-', "_"));
        }
    }
    for enm in &api.enums {
        if is_type_excluded(&enm.name, &enm.rust_path, &exclude.types) {
            api.excluded_type_paths
                .insert(enm.name.clone(), enm.rust_path.replace('-', "_"));
        }
    }

    api.types
        .retain(|t| !is_type_excluded(&t.name, &t.rust_path, &exclude.types));
    api.functions.retain(|f| !exclude.functions.contains(&f.name));
    api.enums
        .retain(|e| !is_type_excluded(&e.name, &e.rust_path, &exclude.types));
    api.errors
        .retain(|e| !is_type_excluded(&e.name, &e.rust_path, &exclude.types));

    // Apply method-level excludes: "TypeName.method_name"
    if !exclude.methods.is_empty() {
        for typ in &mut api.types {
            typ.methods.retain(|m| {
                let key = format!("{}.{}", typ.name, m.name);
                !exclude.methods.contains(&key)
            });
        }
    }

    api
}

/// Expand the include list by transitively discovering all types referenced by fields,
/// method parameters, and return types of the included types, plus the signatures
/// (return type and params) of `include_functions`.
fn expand_include_list(api: &ApiSurface, include_types: &[String], include_functions: &[String]) -> AHashSet<String> {
    let mut needed: AHashSet<String> = include_types.iter().cloned().collect();
    let mut changed = true;

    // Build a map of all available types for lookup
    let all_types: AHashMap<String, &TypeDef> = api.types.iter().map(|t| (t.name.clone(), t)).collect();
    let all_enums: AHashSet<String> = api.enums.iter().map(|e| e.name.clone()).collect();

    // Seed `needed` with type references from the signatures of included functions
    // before the fixed-point loop. The user has explicitly opted into these functions
    // via `include.functions`, so the types they expose at their public boundary must
    // survive the include-list filter — otherwise the function's return type gets
    // sanitized away to `String` later in the pipeline (regression for sample-crawler's
    // `BatchScrapeResults` / `BatchCrawlResults` wrapper structs).
    let include_function_set: AHashSet<&str> = include_functions.iter().map(String::as_str).collect();
    if !include_function_set.is_empty() {
        for func in &api.functions {
            if !include_function_set.contains(func.name.as_str()) {
                continue;
            }
            collect_named_types(&func.return_type, &mut needed, &all_types, &all_enums, &mut changed);
            for param in &func.params {
                collect_named_types(&param.ty, &mut needed, &all_types, &all_enums, &mut changed);
            }
        }
    }

    while changed {
        changed = false;
        let current: Vec<String> = needed.iter().cloned().collect();
        for type_name in &current {
            if let Some(typ) = all_types.get(type_name) {
                for field in &typ.fields {
                    collect_named_types(&field.ty, &mut needed, &all_types, &all_enums, &mut changed);
                }
                for method in &typ.methods {
                    collect_named_types(&method.return_type, &mut needed, &all_types, &all_enums, &mut changed);
                    for param in &method.params {
                        collect_named_types(&param.ty, &mut needed, &all_types, &all_enums, &mut changed);
                    }
                }
            }
        }
    }
    needed
}

/// Recursively collect all named type references from a TypeRef into the needed set.
fn collect_named_types(
    ty: &TypeRef,
    needed: &mut AHashSet<String>,
    all_types: &AHashMap<String, &TypeDef>,
    all_enums: &AHashSet<String>,
    changed: &mut bool,
) {
    match ty {
        TypeRef::Named(name)
            if (all_types.contains_key(name) || all_enums.contains(name)) && needed.insert(name.clone()) =>
        {
            *changed = true;
        }
        TypeRef::Optional(inner) | TypeRef::Vec(inner) => {
            collect_named_types(inner, needed, all_types, all_enums, changed);
        }
        TypeRef::Map(k, v) => {
            collect_named_types(k, needed, all_types, all_enums, changed);
            collect_named_types(v, needed, all_types, all_enums, changed);
        }
        _ => {}
    }
}

/// Rewrite a rust_path using path_mappings.
/// Matches the longest prefix first.
fn rewrite_path(path: &str, mappings: &HashMap<String, String>) -> String {
    let mut sorted: Vec<_> = mappings.iter().collect();
    sorted.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
    for (from, to) in sorted {
        if path.starts_with(from.as_str()) {
            return format!("{}{}", to, &path[from.len()..]);
        }
    }
    path.to_string()
}

/// Apply path_mappings to rewrite all rust_path fields in the API surface.
///
/// Uses [`ResolvedCrateConfig::effective_path_mappings`] which merges auto-derived mappings
/// (from `auto_path_mappings`) with explicit `path_mappings` entries.
fn apply_path_mappings(api: &mut ApiSurface, config: &ResolvedCrateConfig) {
    let mappings = config.effective_path_mappings();
    if mappings.is_empty() {
        return;
    }
    for typ in &mut api.types {
        if typ.original_rust_path.is_empty() {
            typ.original_rust_path = typ.rust_path.clone();
        }
        typ.rust_path = rewrite_path(&typ.rust_path, &mappings);
        // Also rewrite type_rust_path on fields so that field-level path mismatch
        // checks compare against the same (post-mapping) crate root.
        for field in &mut typ.fields {
            if let Some(ref mut path) = field.type_rust_path {
                *path = rewrite_path(path, &mappings);
            }
        }
    }
    for func in &mut api.functions {
        if func.original_rust_path.is_empty() {
            func.original_rust_path = func.rust_path.clone();
        }
        func.rust_path = rewrite_path(&func.rust_path, &mappings);
    }
    for enum_def in &mut api.enums {
        if enum_def.original_rust_path.is_empty() {
            enum_def.original_rust_path = enum_def.rust_path.clone();
        }
        enum_def.rust_path = rewrite_path(&enum_def.rust_path, &mappings);
    }
    for error_def in &mut api.errors {
        if error_def.original_rust_path.is_empty() {
            error_def.original_rust_path = error_def.rust_path.clone();
        }
        error_def.rust_path = rewrite_path(&error_def.rust_path, &mappings);
    }
}

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

    /// sanitize_type_ref must resolve Map inner types (e.g. Named("str") → String)
    /// but must NOT mark the Map itself as sanitized. Returning sanitized=true for a
    /// Map causes downstream backends to fall through to the Debug-format fallback
    /// (`format!("{:?}", val.field)`) instead of emitting the correct HashMap/JsValue
    /// conversion. Regression for AHashMap<Cow<'static, str>, serde_json::Value>.
    #[test]
    fn sanitize_map_with_cow_key_preserves_map_structure_and_returns_not_sanitized() {
        let known_types = AHashSet::default();
        let known_enums = AHashSet::default();
        // "str" is NOT in known_types — it represents the inner type of Cow<'static, str>.
        // The key starts as Named("str") which the type_resolver emits for Cow<'static, str>.

        let mut ty = TypeRef::Map(Box::new(TypeRef::Named("str".into())), Box::new(TypeRef::Json));

        let sanitized = sanitize_type_ref(&mut ty, &known_types, &known_enums);

        // The Map must be preserved — NOT converted to String.
        assert!(
            matches!(&ty, TypeRef::Map(k, v)
                if matches!(k.as_ref(), TypeRef::String)
                && matches!(v.as_ref(), TypeRef::Json)),
            "expected Map(String, Json) but got {ty:?}"
        );

        // sanitize_type_ref must return false: the Map structure is valid and backends
        // have explicit Map handling. Returning true would mark field.sanitized=true
        // and trigger the wrong conversion code path in all backends.
        assert!(
            !sanitized,
            "sanitize_type_ref returned sanitized=true for Map — this triggers the Debug-format fallback"
        );

        // Verify the symmetric case: Map(String, Json) with already-resolved key
        // should also return false (key is already String, no unknown Named types).
        let _ = known_types;
        let mut ty2 = TypeRef::Map(Box::new(TypeRef::String), Box::new(TypeRef::Json));
        let sanitized2 = sanitize_type_ref(&mut ty2, &AHashSet::default(), &AHashSet::default());
        assert!(!sanitized2, "Map(String, Json) should not be sanitized");
        assert!(
            matches!(&ty2, TypeRef::Map(k, v)
                if matches!(k.as_ref(), TypeRef::String)
                && matches!(v.as_ref(), TypeRef::Json)),
            "Map(String, Json) must not be mutated when already clean"
        );
    }

    /// Map(String, String) — the old case that was already handled correctly downstream —
    /// must also return sanitized=false after this fix. Backends must handle it via the
    /// normal (non-sanitized) Map conversion path.
    #[test]
    fn sanitize_map_with_both_string_types_returns_not_sanitized() {
        let mut ty = TypeRef::Map(Box::new(TypeRef::String), Box::new(TypeRef::String));
        let sanitized = sanitize_type_ref(&mut ty, &AHashSet::default(), &AHashSet::default());
        assert!(!sanitized);
        assert!(matches!(
            &ty,
            TypeRef::Map(k, v)
                if matches!(k.as_ref(), TypeRef::String) && matches!(v.as_ref(), TypeRef::String)
        ));
    }

    /// Primitive field (not a Map) with unknown Named inner type still gets sanitized=true.
    /// This ensures we didn't break non-Map sanitization.
    #[test]
    fn sanitize_named_unknown_type_returns_sanitized_true() {
        let mut ty = TypeRef::Named("UnknownForeignType".into());
        let sanitized = sanitize_type_ref(&mut ty, &AHashSet::default(), &AHashSet::default());
        assert!(sanitized);
        assert!(matches!(ty, TypeRef::String));
    }

    /// Vec<Named("unknown")> should still return sanitized=true (inner Named replaced with String).
    #[test]
    fn sanitize_vec_with_unknown_named_returns_sanitized_true() {
        let mut ty = TypeRef::Vec(Box::new(TypeRef::Named("MyForeignStruct".into())));
        let sanitized = sanitize_type_ref(&mut ty, &AHashSet::default(), &AHashSet::default());
        assert!(sanitized);
        assert!(matches!(
            &ty,
            TypeRef::Vec(inner) if matches!(inner.as_ref(), TypeRef::String)
        ));
    }

    // ---------------------------------------------------------------------------
    // is_type_excluded — fully-qualified path matching
    // ---------------------------------------------------------------------------

    /// Plain (no-`::`) entries match by short name only.
    #[test]
    fn is_type_excluded_plain_entry_matches_by_name() {
        let exclude = vec!["OutputFormat".to_string()];

        // Short name hit
        assert!(
            is_type_excluded("OutputFormat", "kreuzberg::types::OutputFormat", &exclude),
            "plain entry must match when name matches"
        );

        // Different name — no match
        assert!(
            !is_type_excluded("SomethingElse", "kreuzberg::types::SomethingElse", &exclude),
            "plain entry must not match when name differs"
        );
    }

    /// Fully-qualified entries match only the specific rust_path, not any type
    /// that merely shares the same short name.
    ///
    /// Regression: sample_core::core::config::formats::OutputFormat must be excluded
    /// while sample_core::types::OutputFormat is retained.
    #[test]
    fn is_type_excluded_qualified_entry_matches_rust_path_not_name() {
        let exclude = vec!["kreuzberg::core::config::formats::OutputFormat".to_string()];

        // The internal variant — must be excluded.
        assert!(
            is_type_excluded(
                "OutputFormat",
                "kreuzberg::core::config::formats::OutputFormat",
                &exclude
            ),
            "qualified entry must match the exact rust_path"
        );

        // The public variant that shares the same short name — must NOT be excluded.
        assert!(
            !is_type_excluded("OutputFormat", "kreuzberg::types::OutputFormat", &exclude),
            "qualified entry must NOT match a different rust_path with the same short name"
        );
    }

    /// Hyphens in rust_path are normalised to underscores before comparison, matching
    /// the convention used throughout alef's path mapping layer.
    #[test]
    fn is_type_excluded_normalises_hyphens_in_rust_path() {
        let exclude = vec!["my_crate::some_module::Foo".to_string()];

        // rust_path with hyphen in crate name — normalised to underscore before compare.
        assert!(
            is_type_excluded("Foo", "my-crate::some_module::Foo", &exclude),
            "hyphens in rust_path should be normalised to underscores"
        );
    }

    // ---------------------------------------------------------------------------
    // expand_include_list — function-signature seeding
    // ---------------------------------------------------------------------------

    fn make_typedef(name: &str) -> crate::core::ir::TypeDef {
        crate::core::ir::TypeDef {
            name: name.to_string(),
            rust_path: format!("my_crate::{name}"),
            original_rust_path: String::new(),
            fields: vec![],
            methods: vec![],
            is_opaque: false,
            is_clone: false,
            is_copy: false,
            is_trait: false,
            has_default: false,
            has_stripped_cfg_fields: false,
            is_return_type: false,
            doc: String::new(),
            cfg: None,
            serde_rename_all: None,
            has_serde: false,
            super_traits: vec![],
            binding_excluded: false,
            binding_exclusion_reason: None,
        }
    }

    fn make_funcdef(name: &str, return_type: TypeRef, param_types: Vec<TypeRef>) -> crate::core::ir::FunctionDef {
        crate::core::ir::FunctionDef {
            name: name.to_string(),
            rust_path: format!("my_crate::{name}"),
            original_rust_path: String::new(),
            params: param_types
                .into_iter()
                .enumerate()
                .map(|(i, ty)| crate::core::ir::ParamDef {
                    name: format!("arg{i}"),
                    ty,
                    optional: false,
                    default: None,
                    sanitized: false,
                    typed_default: None,
                    is_ref: false,
                    is_mut: false,
                    newtype_wrapper: None,
                    original_type: None,
                    map_is_ahash: false,
                    map_key_is_cow: false,
                })
                .collect(),
            return_type,
            is_async: false,
            error_type: None,
            doc: String::new(),
            cfg: None,
            sanitized: false,
            return_sanitized: false,
            returns_ref: false,
            returns_cow: false,
            return_newtype_wrapper: None,
            binding_excluded: false,
            binding_exclusion_reason: None,
        }
    }

    fn surface_with(types: Vec<crate::core::ir::TypeDef>, functions: Vec<crate::core::ir::FunctionDef>) -> ApiSurface {
        ApiSurface {
            crate_name: "my_crate".into(),
            version: "0.1.0".into(),
            types,
            functions,
            enums: vec![],
            errors: vec![],
            excluded_type_paths: std::collections::HashMap::new(),
            excluded_trait_names: std::collections::HashSet::new(),
        }
    }

    /// Regression for sample-crawler's `BatchScrapeResults` bug: a function listed in
    /// `[crates.include].functions` returns a wrapper struct that is NOT in
    /// `[crates.include].types`. Before the fix, the include filter dropped the
    /// wrapper struct (it was unreachable from the included types), and the later
    /// `sanitize_unknown_types` pass collapsed the function's `return_type` to
    /// `String`, breaking every binding facade.
    ///
    /// After the fix, `expand_include_list` seeds itself from included functions'
    /// signatures so the wrapper is retained.
    #[test]
    fn expand_include_list_seeds_from_included_function_signatures() {
        let surface = surface_with(
            vec![
                make_typedef("BatchScrapeResult"),
                make_typedef("BatchScrapeResults"),
                make_typedef("UnusedType"),
            ],
            vec![make_funcdef(
                "batch_scrape",
                TypeRef::Named("BatchScrapeResults".into()),
                vec![TypeRef::Vec(Box::new(TypeRef::String))],
            )],
        );

        let include_types = vec!["BatchScrapeResult".to_string()];
        let include_functions = vec!["batch_scrape".to_string()];

        let expanded = expand_include_list(&surface, &include_types, &include_functions);

        assert!(
            expanded.contains("BatchScrapeResult"),
            "per-element type explicitly listed must be present; got: {expanded:?}"
        );
        assert!(
            expanded.contains("BatchScrapeResults"),
            "wrapper return type of included function must be auto-included; got: {expanded:?}"
        );
        assert!(
            !expanded.contains("UnusedType"),
            "unrelated type must not be pulled in; got: {expanded:?}"
        );
    }

    /// Function parameter types must also be retained — a function listed in
    /// `include.functions` that accepts a custom config struct must keep that
    /// struct in the surface even if the user forgot to list it under
    /// `include.types`.
    #[test]
    fn expand_include_list_seeds_from_included_function_param_types() {
        let surface = surface_with(
            vec![make_typedef("CrawlConfig"), make_typedef("EngineHandle")],
            vec![make_funcdef(
                "create_engine",
                TypeRef::Named("EngineHandle".into()),
                vec![TypeRef::Optional(Box::new(TypeRef::Named("CrawlConfig".into())))],
            )],
        );

        let include_types = vec!["EngineHandle".to_string()];
        let include_functions = vec!["create_engine".to_string()];

        let expanded = expand_include_list(&surface, &include_types, &include_functions);

        assert!(
            expanded.contains("CrawlConfig"),
            "param type referenced through Optional must be retained; got: {expanded:?}"
        );
    }

    /// When no functions are in the include list, behaviour is unchanged —
    /// expansion stays anchored to `include_types` only.
    #[test]
    fn expand_include_list_with_empty_functions_matches_legacy_behaviour() {
        let surface = surface_with(
            vec![make_typedef("Kept"), make_typedef("Dropped")],
            vec![make_funcdef("do_thing", TypeRef::Named("Dropped".into()), vec![])],
        );

        let include_types = vec!["Kept".to_string()];
        let include_functions: Vec<String> = vec![];

        let expanded = expand_include_list(&surface, &include_types, &include_functions);
        assert!(expanded.contains("Kept"));
        assert!(
            !expanded.contains("Dropped"),
            "function not in include.functions must not pull in its return type; got: {expanded:?}"
        );
    }
}
