use crate::core::ir::{ErrorDef, ErrorVariant};
use ahash::AHashSet;

use crate::codegen::conversions::is_tuple_variant;

/// Generate a wildcard match pattern for an error variant.
/// Struct variants use `{ .. }`, tuple variants use `(..)`, unit variants have no suffix.
fn error_variant_wildcard_pattern(rust_path: &str, variant: &ErrorVariant) -> String {
    if variant.is_unit {
        format!("{rust_path}::{}", variant.name)
    } else if is_tuple_variant(&variant.fields) {
        format!("{rust_path}::{}(..)", variant.name)
    } else {
        format!("{rust_path}::{} {{ .. }}", variant.name)
    }
}

/// Python builtin exception names that must not be shadowed (A004 compliance).
const PYTHON_BUILTIN_EXCEPTIONS: &[&str] = &[
    "ConnectionError",
    "TimeoutError",
    "PermissionError",
    "FileNotFoundError",
    "ValueError",
    "TypeError",
    "RuntimeError",
    "OSError",
    "IOError",
    "KeyError",
    "IndexError",
    "AttributeError",
    "ImportError",
    "MemoryError",
    "OverflowError",
    "StopIteration",
    "RecursionError",
    "SystemError",
    "ReferenceError",
    "BufferError",
    "EOFError",
    "LookupError",
    "ArithmeticError",
    "AssertionError",
    "BlockingIOError",
    "BrokenPipeError",
    "ChildProcessError",
    "FileExistsError",
    "InterruptedError",
    "IsADirectoryError",
    "NotADirectoryError",
    "ProcessLookupError",
    "UnicodeError",
];

/// Compute a prefix from the error type name by stripping a trailing "Error" suffix.
/// E.g. `"CrawlError"` -> `"Crawl"`, `"MyException"` -> `"MyException"`.
fn error_base_prefix(error_name: &str) -> &str {
    error_name.strip_suffix("Error").unwrap_or(error_name)
}

/// Return the Python exception name for a variant, avoiding shadowing of Python builtins.
///
/// 1. Appends `"Error"` suffix if not already present (N818 compliance).
/// 2. If the resulting name shadows a Python builtin, prefixes it with the error type's base
///    name. E.g. for `CrawlError::Connection` -> `ConnectionError` (shadowed) -> `CrawlConnectionError`.
pub fn python_exception_name(variant_name: &str, error_name: &str) -> String {
    let candidate = if variant_name.ends_with("Error") {
        variant_name.to_string()
    } else {
        format!("{}Error", variant_name)
    };

    if PYTHON_BUILTIN_EXCEPTIONS.contains(&candidate.as_str()) {
        let prefix = error_base_prefix(error_name);
        // Avoid double-prefixing if the candidate already starts with the prefix
        if candidate.starts_with(prefix) {
            candidate
        } else {
            format!("{}{}", prefix, candidate)
        }
    } else {
        candidate
    }
}

/// Generate `pyo3::create_exception!` macros for each error variant plus the base error type.
/// Appends "Error" suffix to variant names that don't already have it (N818 compliance).
/// Prefixes names that would shadow Python builtins (A004 compliance).
pub fn gen_pyo3_error_types(error: &ErrorDef, module_name: &str, seen_exceptions: &mut AHashSet<String>) -> String {
    // Pre-compute variant names that haven't been seen yet
    let mut variant_names = Vec::new();
    for variant in &error.variants {
        let variant_name = python_exception_name(&variant.name, &error.name);
        if seen_exceptions.insert(variant_name.clone()) {
            variant_names.push(variant_name);
        }
    }

    // Check if base error hasn't been seen
    let include_base = seen_exceptions.insert(error.name.clone());

    crate::codegen::template_env::render(
        "error_gen/pyo3_error_types.jinja",
        minijinja::context! {
            variant_names => variant_names,
            module_name => module_name,
            error_name => error.name.as_str(),
            include_base => include_base,
        },
    )
}

/// Generate a `to_py_err` converter function that maps each Rust error variant to a Python exception.
/// Uses Error-suffixed names for variant exceptions (N818 compliance).
///
/// When `error.methods` is non-empty, the constructor receives a tuple
/// `(message, status_code, is_transient, error_type)` so that the `#[getter]`
/// impl generated by [`gen_pyo3_error_methods_impl`] can read them back.
pub fn gen_pyo3_error_converter(error: &ErrorDef, core_import: &str) -> String {
    let rust_path = if error.rust_path.is_empty() {
        format!("{core_import}::{}", error.name)
    } else {
        let normalized = error.rust_path.replace('-', "_");
        // Paths with more than 2 segments (e.g. `mylib_core::di::error::DependencyError`)
        // reference private internal modules that are not accessible from generated binding code.
        // Fall back to the public re-export form `{crate}::{ErrorName}` (2 segments).
        let segments: Vec<&str> = normalized.split("::").collect();
        if segments.len() > 2 {
            let crate_name = segments[0];
            let error_name = segments[segments.len() - 1];
            format!("{crate_name}::{error_name}")
        } else {
            normalized
        }
    };

    let fn_name = format!("{}_to_py_err", to_snake_case(&error.name));
    let has_methods = !error.methods.is_empty();

    // Pre-compute variants as (pattern, exc_name) tuples
    let mut variants = Vec::new();
    for variant in &error.variants {
        let pattern = error_variant_wildcard_pattern(&rust_path, variant);
        let variant_exc_name = python_exception_name(&variant.name, &error.name);
        variants.push((pattern, variant_exc_name));
    }

    crate::codegen::template_env::render(
        "error_gen/pyo3_error_converter.jinja",
        minijinja::context! {
            rust_path => rust_path.as_str(),
            fn_name => fn_name.as_str(),
            error_name => error.name.as_str(),
            variants => variants,
            has_methods => has_methods,
        },
    )
}

/// Generate `m.add(...)` registration calls for each exception type.
/// Uses Error-suffixed names for variant exceptions (N818 compliance).
/// Prefixes names that would shadow Python builtins (A004 compliance).
pub fn gen_pyo3_error_registration(error: &ErrorDef, seen_registrations: &mut AHashSet<String>) -> Vec<String> {
    let mut registrations = Vec::with_capacity(error.variants.len() + 1);

    for variant in &error.variants {
        let variant_exc_name = python_exception_name(&variant.name, &error.name);
        if seen_registrations.insert(variant_exc_name.clone()) {
            registrations.push(format!(
                "    m.add(\"{}\", m.py().get_type::<{}>())?;",
                variant_exc_name, variant_exc_name
            ));
        }
    }

    // Base exception
    if seen_registrations.insert(error.name.clone()) {
        registrations.push(format!(
            "    m.add(\"{}\", m.py().get_type::<{}>())?;",
            error.name, error.name
        ));
    }

    registrations
}

/// Return the converter function name for a given error type.
pub fn converter_fn_name(error: &ErrorDef) -> String {
    format!("{}_to_py_err", to_snake_case(&error.name))
}

/// Simple CamelCase to snake_case conversion.
fn to_snake_case(s: &str) -> String {
    let mut result = String::with_capacity(s.len() + 4);
    for (i, c) in s.chars().enumerate() {
        if c.is_uppercase() {
            if i > 0 {
                result.push('_');
            }
            result.push(c.to_ascii_lowercase());
        } else {
            result.push(c);
        }
    }
    result
}

// ---------------------------------------------------------------------------
// NAPI (Node.js) error generation
// ---------------------------------------------------------------------------

/// Generate a `JsError` enum with string constants for each error variant name.
pub fn gen_napi_error_types(error: &ErrorDef) -> String {
    // Pre-compute (const_name, variant_name) pairs
    let mut variants = Vec::new();
    let error_screaming = to_screaming_snake(&error.name);
    for variant in &error.variants {
        let variant_const = format!("{}_ERROR_{}", error_screaming, to_screaming_snake(&variant.name));
        variants.push((variant_const, variant.name.clone()));
    }

    crate::codegen::template_env::render(
        "error_gen/napi_error_types.jinja",
        minijinja::context! {
            variants => variants,
        },
    )
}

/// Generate a converter function that maps a core error to `napi::Error`.
pub fn gen_napi_error_converter(error: &ErrorDef, core_import: &str) -> String {
    let rust_path = if error.rust_path.is_empty() {
        format!("{core_import}::{}", error.name)
    } else {
        error.rust_path.replace('-', "_")
    };

    let fn_name = format!("{}_to_napi_err", to_snake_case(&error.name));

    // Pre-compute (pattern, variant_name) pairs
    let mut variants = Vec::new();
    for variant in &error.variants {
        let pattern = error_variant_wildcard_pattern(&rust_path, variant);
        variants.push((pattern, variant.name.clone()));
    }

    crate::codegen::template_env::render(
        "error_gen/napi_error_converter.jinja",
        minijinja::context! {
            rust_path => rust_path.as_str(),
            fn_name => fn_name.as_str(),
            variants => variants,
        },
    )
}

/// Return the NAPI converter function name for a given error type.
pub fn napi_converter_fn_name(error: &ErrorDef) -> String {
    format!("{}_to_napi_err", to_snake_case(&error.name))
}

// ---------------------------------------------------------------------------
// WASM (wasm-bindgen) error generation
// ---------------------------------------------------------------------------

/// Generate a converter function that maps a core error to a `JsValue` object
/// with `code` (string) and `message` (string) fields, plus a private
/// `error_code` helper that returns the variant code string.
pub fn gen_wasm_error_converter(error: &ErrorDef, core_import: &str, source_remaps: &[(&str, &str)]) -> String {
    let mut rust_path = if error.rust_path.is_empty() {
        format!("{core_import}::{}", error.name)
    } else {
        error.rust_path.replace('-', "_")
    };

    // Apply source_crate_remaps: if the path starts with a crate that's in the remap list,
    // replace it with the core_import
    for (orig_crate, target_crate) in source_remaps {
        if rust_path.starts_with(&format!("{orig_crate}::")) {
            rust_path = rust_path.replacen(&format!("{orig_crate}::"), &format!("{target_crate}::"), 1);
            break;
        }
    }

    let fn_name = format!("{}_to_js_value", to_snake_case(&error.name));
    let code_fn_name = format!("{}_error_code", to_snake_case(&error.name));

    // Pre-compute variants for error_code helper: (pattern, code) pairs
    let mut code_variants = Vec::new();
    for variant in &error.variants {
        let pattern = error_variant_wildcard_pattern(&rust_path, variant);
        let code = to_snake_case(&variant.name);
        code_variants.push((pattern, code));
    }
    let default_code = to_snake_case(&error.name);

    let code_fn = crate::codegen::template_env::render(
        "error_gen/wasm_error_code_fn.jinja",
        minijinja::context! {
            rust_path => rust_path.as_str(),
            code_fn_name => code_fn_name.as_str(),
            variants => code_variants,
            default_code => default_code.as_str(),
        },
    );

    let converter_fn = crate::codegen::template_env::render(
        "error_gen/wasm_error_converter.jinja",
        minijinja::context! {
            rust_path => rust_path.as_str(),
            fn_name => fn_name.as_str(),
            code_fn_name => code_fn_name.as_str(),
        },
    );

    format!("{}\n\n{}", code_fn, converter_fn)
}

/// Return the WASM converter function name for a given error type.
pub fn wasm_converter_fn_name(error: &ErrorDef) -> String {
    format!("{}_to_js_value", to_snake_case(&error.name))
}

/// Generate a `#[wasm_bindgen]` opaque struct for an error type together with an
/// `impl` block that exposes the whitelisted introspection methods
/// (`status_code`, `is_transient`, `error_type`) declared in `error.methods`.
///
/// The struct follows the same `pub(crate) inner: CoreType` convention used by
/// all other opaque WASM handles in the codebase.
///
/// `wasm_prefix` is the full WASM type prefix string (from `config.wasm_type_prefix()`,
/// e.g. `"Wasm"`).  The generated struct name is `{wasm_prefix}{error.name}`
/// (e.g. `WasmSampleLlmError`).
///
/// Returns an empty string when `error.methods` is empty so callers can
/// unconditionally append the result without adding noise to the output file.
pub fn gen_wasm_error_methods(error: &ErrorDef, core_import: &str, wasm_prefix: &str) -> String {
    if error.methods.is_empty() {
        return String::new();
    }

    let rust_path = if error.rust_path.is_empty() {
        format!("{core_import}::{}", error.name)
    } else {
        error.rust_path.replace('-', "_")
    };

    // The struct name mirrors the convention used for other WASM opaque handles:
    // `{wasm_type_prefix}{ErrorName}` (e.g. prefix="Wasm", name="SampleLlmError" → "WasmSampleLlmError").
    let wasm_struct_name = format!("{wasm_prefix}{}", error.name);

    let struct_def = format!(
        "/// Opaque WASM handle for [`{rust_path}`] that exposes introspection methods.\n\
         #[wasm_bindgen]\n\
         pub struct {wasm_struct_name} {{\n\
             pub(crate) inner: {rust_path},\n\
         }}"
    );

    let mut method_bodies = Vec::new();
    for method in &error.methods {
        let method_src = match method.name.as_str() {
            "status_code" => "    /// HTTP status code for this error variant.\n    \
                 #[wasm_bindgen(js_name = \"statusCode\")]\n    \
                 pub fn status_code(&self) -> u16 {\n        \
                 self.inner.status_code()\n    }"
                .to_string(),
            "is_transient" => "    /// Returns `true` if the error is transient and a retry may succeed.\n    \
                 #[wasm_bindgen(js_name = \"isTransient\")]\n    \
                 pub fn is_transient(&self) -> bool {\n        \
                 self.inner.is_transient()\n    }"
                .to_string(),
            "error_type" => "    /// Returns a machine-readable error category string.\n    \
                 #[wasm_bindgen(js_name = \"errorType\")]\n    \
                 pub fn error_type(&self) -> String {\n        \
                 self.inner.error_type().to_string()\n    }"
                .to_string(),
            other => {
                // Unrecognised whitelisted method — emit a dead-code stub so the binding
                // still compiles and the method name remains visible to reviewers.
                format!(
                    "    // TODO: emit binding for method `{other}` on `{wasm_struct_name}`\n    \
                     #[allow(dead_code)]\n    \
                     pub fn {other}(&self) {{}}"
                )
            }
        };
        method_bodies.push(method_src);
    }

    let impl_block = format!(
        "#[wasm_bindgen]\nimpl {wasm_struct_name} {{\n{}\n}}",
        method_bodies.join("\n\n")
    );

    format!("{struct_def}\n\n{impl_block}")
}

// ---------------------------------------------------------------------------
// PyO3 (Python) error methods — companion class
// ---------------------------------------------------------------------------

/// Generate a `#[pyclass]` companion struct for error introspection, exposing
/// the whitelisted methods as `#[getter]` properties.
///
/// `pyo3::create_exception!` types are zero-sized marker types that do not
/// implement `PyClass`, so `#[pymethods]` blocks cannot be added to them
/// directly. Instead we emit a separate `{ErrorName}Info` `#[pyclass]` that
/// stores the three fields and is built by a `#[pyfunction]` free function
/// which extracts the values from the exception's args tuple (indices 1–3,
/// which the converter already populates).
///
/// Returns an empty string when `error.methods` is empty.
pub fn gen_pyo3_error_methods_impl(error: &ErrorDef) -> String {
    if error.methods.is_empty() {
        return String::new();
    }

    let struct_name = format!("{}Info", error.name);
    let snake_name = to_snake_case(&error.name);
    let fn_name = format!("{snake_name}_info");

    let mut fields = Vec::new();
    let mut getters = Vec::new();

    let has_status_code = error.methods.iter().any(|m| m.name == "status_code");
    let has_is_transient = error.methods.iter().any(|m| m.name == "is_transient");
    let has_error_type = error.methods.iter().any(|m| m.name == "error_type");

    if has_status_code {
        fields.push("    pub status_code: u16,".to_string());
        getters.push(
            concat!(
                "    /// HTTP status code for this error (0 means no associated status).\n",
                "    #[getter]\n",
                "    fn status_code(&self) -> u16 {\n",
                "        self.status_code\n",
                "    }",
            )
            .to_string(),
        );
    }
    if has_is_transient {
        fields.push("    pub is_transient: bool,".to_string());
        getters.push(
            concat!(
                "    /// Returns `true` if the error is transient and a retry may succeed.\n",
                "    #[getter]\n",
                "    fn is_transient(&self) -> bool {\n",
                "        self.is_transient\n",
                "    }",
            )
            .to_string(),
        );
    }
    if has_error_type {
        fields.push("    pub error_type: String,".to_string());
        getters.push(
            concat!(
                "    /// Machine-readable error category string for matching and logging.\n",
                "    #[getter]\n",
                "    fn error_type(&self) -> String {\n",
                "        self.error_type.clone()\n",
                "    }",
            )
            .to_string(),
        );
    }
    // Emit TODO stubs for any other whitelisted methods.
    for method in &error.methods {
        match method.name.as_str() {
            "status_code" | "is_transient" | "error_type" => {}
            other => getters.push(format!(
                "    // TODO: emit getter for method `{other}` on `{struct_name}`"
            )),
        }
    }

    // The converter stores (msg, status_code, is_transient, error_type) at args indices 0-3.
    // We extract via getattr("args") which returns Option<Bound<PyAny>>.
    let mut ctor_fields = Vec::new();
    if has_status_code {
        ctor_fields.push(
            "        status_code: args\n\
             \x20           .as_ref()\n\
             \x20           .and_then(|a| a.get_item(1).ok())\n\
             \x20           .and_then(|v| v.extract::<u16>().ok())\n\
             \x20           .unwrap_or(0),",
        );
    }
    if has_is_transient {
        ctor_fields.push(
            "        is_transient: args\n\
             \x20           .as_ref()\n\
             \x20           .and_then(|a| a.get_item(2).ok())\n\
             \x20           .and_then(|v| v.extract::<bool>().ok())\n\
             \x20           .unwrap_or(false),",
        );
    }
    if has_error_type {
        ctor_fields.push(
            "        error_type: args\n\
             \x20           .as_ref()\n\
             \x20           .and_then(|a| a.get_item(3).ok())\n\
             \x20           .and_then(|v| v.extract::<String>().ok())\n\
             \x20           .unwrap_or_default(),",
        );
    }

    let struct_def = format!(
        "#[pyclass(name = \"{struct_name}\")]\npub struct {struct_name} {{\n{}\n}}",
        fields.join("\n")
    );

    let impl_block = format!("#[pymethods]\nimpl {struct_name} {{\n{}\n}}", getters.join("\n\n"));

    let free_fn = format!(
        "/// Build a `{struct_name}` from any exception raised by the `{error_name}` hierarchy.\n\
         ///\n\
         /// The converter stores `(message, status_code, is_transient, error_type)` in the\n\
         /// exception args tuple; this function extracts those values at indices 1–3.\n\
         #[pyfunction]\n\
         pub fn {fn_name}(err: pyo3::Bound<'_, pyo3::types::PyAny>) -> {struct_name} {{\n\
             let args = err.getattr(\"args\").ok();\n\
             {struct_name} {{\n\
         {ctor}\n\
             }}\n\
         }}",
        error_name = error.name,
        ctor = ctor_fields.join("\n"),
    );

    format!("{struct_def}\n\n{impl_block}\n\n{free_fn}")
}

/// Returns `true` when the error has whitelisted introspection methods that
/// require extended constructor args `(msg, status_code, is_transient, error_type)`.
pub fn pyo3_error_has_methods(error: &ErrorDef) -> bool {
    !error.methods.is_empty()
}

/// Return the name of the companion info struct for an error type.
pub fn pyo3_error_info_struct_name(error: &ErrorDef) -> String {
    format!("{}Info", error.name)
}

/// Return the name of the free function that builds the companion info struct.
pub fn pyo3_error_info_fn_name(error: &ErrorDef) -> String {
    format!("{}_info", to_snake_case(&error.name))
}

// ---------------------------------------------------------------------------
// NAPI (Node.js) error methods class
// ---------------------------------------------------------------------------

/// Generate a `#[napi]` class struct for the error type that stores the
/// whitelisted introspection method values as fields and exposes them as methods.
///
/// Returns an empty string when `error.methods` is empty.
pub fn gen_napi_error_class(error: &ErrorDef, core_import: &str) -> String {
    if error.methods.is_empty() {
        return String::new();
    }

    let rust_path = if error.rust_path.is_empty() {
        format!("{core_import}::{}", error.name)
    } else {
        error.rust_path.replace('-', "_")
    };

    let struct_name = format!("Js{}Info", error.name);

    let mut fields = Vec::new();
    let mut methods = Vec::new();
    let mut ctor_assignments = Vec::new();

    for method in &error.methods {
        match method.name.as_str() {
            "status_code" => {
                fields.push("    pub status_code: u16,".to_string());
                methods.push(
                    concat!(
                        "    /// HTTP status code for this error (0 means no associated status).\n",
                        "    #[napi(js_name = \"statusCode\")]\n",
                        "    pub fn status_code(&self) -> u16 {\n",
                        "        self.status_code\n",
                        "    }",
                    )
                    .to_string(),
                );
                ctor_assignments.push("        status_code: e.status_code(),".to_string());
            }
            "is_transient" => {
                fields.push("    pub is_transient: bool,".to_string());
                methods.push(
                    concat!(
                        "    /// Returns `true` if the error is transient and a retry may succeed.\n",
                        "    #[napi(js_name = \"isTransient\")]\n",
                        "    pub fn is_transient(&self) -> bool {\n",
                        "        self.is_transient\n",
                        "    }",
                    )
                    .to_string(),
                );
                ctor_assignments.push("        is_transient: e.is_transient(),".to_string());
            }
            "error_type" => {
                fields.push("    pub error_type: String,".to_string());
                methods.push(
                    concat!(
                        "    /// Machine-readable error category string for matching and logging.\n",
                        "    #[napi(js_name = \"errorType\")]\n",
                        "    pub fn error_type(&self) -> String {\n",
                        "        self.error_type.clone()\n",
                        "    }",
                    )
                    .to_string(),
                );
                ctor_assignments.push("        error_type: e.error_type().to_string(),".to_string());
            }
            other => {
                methods.push(format!("    // TODO: emit #[napi] method `{other}` on `{struct_name}`"));
            }
        }
    }

    let struct_def = format!("#[napi]\npub struct {struct_name} {{\n{}\n}}", fields.join("\n"));

    let from_fn = format!(
        "#[allow(dead_code)]\nfn {snake_name}_info(e: &{rust_path}) -> {struct_name} {{\n    {struct_name} {{\n{}\n    }}\n}}",
        ctor_assignments.join("\n"),
        snake_name = to_snake_case(&error.name),
    );

    let impl_block = format!("#[napi]\nimpl {struct_name} {{\n{}\n}}", methods.join("\n\n"));

    format!("{struct_def}\n\n{from_fn}\n\n{impl_block}")
}

// ---------------------------------------------------------------------------
// Magnus (Ruby) error methods struct
// ---------------------------------------------------------------------------

/// Generate a Magnus-wrapped Rust struct that stores the whitelisted error
/// introspection method return values and exposes them as Ruby instance methods.
///
/// Returns an empty string when `error.methods` is empty.
pub fn gen_magnus_error_methods_struct(error: &ErrorDef, core_import: &str) -> String {
    if error.methods.is_empty() {
        return String::new();
    }

    let rust_path = if error.rust_path.is_empty() {
        format!("{core_import}::{}", error.name)
    } else {
        error.rust_path.replace('-', "_")
    };

    let struct_name = format!("{}Info", error.name);

    let mut fields = Vec::new();
    let mut methods = Vec::new();
    let mut ctor_assignments = Vec::new();

    for method in &error.methods {
        match method.name.as_str() {
            "status_code" => {
                fields.push("    status_code: u16,".to_string());
                methods.push(
                    concat!(
                        "    /// HTTP status code for this error (0 means no associated status).\n",
                        "    pub fn status_code(&self) -> u16 {\n",
                        "        self.status_code\n",
                        "    }",
                    )
                    .to_string(),
                );
                ctor_assignments.push("        status_code: e.status_code(),".to_string());
            }
            "is_transient" => {
                fields.push("    is_transient: bool,".to_string());
                methods.push(
                    concat!(
                        "    /// Returns `true` if the error is transient and a retry may succeed.\n",
                        "    pub fn transient(&self) -> bool {\n",
                        "        self.is_transient\n",
                        "    }",
                    )
                    .to_string(),
                );
                ctor_assignments.push("        is_transient: e.is_transient(),".to_string());
            }
            "error_type" => {
                fields.push("    error_type: String,".to_string());
                methods.push(
                    concat!(
                        "    /// Machine-readable error category string for matching and logging.\n",
                        "    pub fn error_type(&self) -> String {\n",
                        "        self.error_type.clone()\n",
                        "    }",
                    )
                    .to_string(),
                );
                ctor_assignments.push("        error_type: e.error_type().to_string(),".to_string());
            }
            other => {
                methods.push(format!("    // TODO: emit method `{other}` on `{struct_name}`"));
            }
        }
    }

    let struct_def = format!(
        "#[magnus::wrap(class = \"{struct_name}\", free_immediately, size)]\npub struct {struct_name} {{\n{}\n}}",
        fields.join("\n")
    );

    let from_fn = format!(
        "#[allow(dead_code)]\nfn {snake_name}_info(e: &{rust_path}) -> {struct_name} {{\n    {struct_name} {{\n{}\n    }}\n}}",
        ctor_assignments.join("\n"),
        snake_name = to_snake_case(&error.name),
    );

    let impl_block = format!("impl {struct_name} {{\n{}\n}}", methods.join("\n\n"));

    format!("{struct_def}\n\n{from_fn}\n\n{impl_block}")
}

/// Returns the `define_class` + `define_method` registration lines for the error info struct.
pub fn magnus_error_methods_registrations(error: &ErrorDef) -> Vec<String> {
    if error.methods.is_empty() {
        return Vec::new();
    }
    let struct_name = format!("{}Info", error.name);
    let snake = to_snake_case(&error.name);
    let class_var = format!("{snake}_info_class");
    let mut lines = Vec::new();
    lines.push(format!(
        "    let {class_var} = module.define_class(\"{struct_name}\", ruby.class_object())?;"
    ));
    for method in &error.methods {
        let (ruby_name, rust_fn) = if method.name == "is_transient" {
            ("transient?".to_string(), "transient".to_string())
        } else {
            (method.name.clone(), method.name.clone())
        };
        lines.push(format!(
            "    {class_var}.define_method(\"{ruby_name}\", magnus::method!({struct_name}::{rust_fn}, 0))?;"
        ));
    }
    lines
}

// ---------------------------------------------------------------------------
// PHP (ext-php-rs) error generation
// ---------------------------------------------------------------------------

/// Generate a converter function that maps a core error to `PhpException`.
pub fn gen_php_error_converter(error: &ErrorDef, core_import: &str) -> String {
    let rust_path = if error.rust_path.is_empty() {
        format!("{core_import}::{}", error.name)
    } else {
        error.rust_path.replace('-', "_")
    };

    let fn_name = format!("{}_to_php_err", to_snake_case(&error.name));

    // Pre-compute (pattern, variant_name) pairs
    let mut variants = Vec::new();
    for variant in &error.variants {
        let pattern = error_variant_wildcard_pattern(&rust_path, variant);
        variants.push((pattern, variant.name.clone()));
    }

    crate::codegen::template_env::render(
        "error_gen/php_error_converter.jinja",
        minijinja::context! {
            rust_path => rust_path.as_str(),
            fn_name => fn_name.as_str(),
            variants => variants,
        },
    )
}

/// Return the PHP converter function name for a given error type.
pub fn php_converter_fn_name(error: &ErrorDef) -> String {
    format!("{}_to_php_err", to_snake_case(&error.name))
}

/// Generate a `#[php_class]` + `#[php_impl]` block for the error type, storing
/// the whitelisted introspection method return values as Rust fields exposed via
/// `#[php_method]`.
///
/// Returns an empty string when `error.methods` is empty.
pub fn gen_php_error_methods_impl(error: &ErrorDef, core_import: &str) -> String {
    if error.methods.is_empty() {
        return String::new();
    }

    let rust_path = if error.rust_path.is_empty() {
        format!("{core_import}::{}", error.name)
    } else {
        error.rust_path.replace('-', "_")
    };

    let struct_name = format!("{}Info", error.name);

    let mut fields = Vec::new();
    let mut methods = Vec::new();
    let mut ctor_assignments = Vec::new();

    for method in &error.methods {
        match method.name.as_str() {
            "status_code" => {
                fields.push("    pub status_code: u16,".to_string());
                methods.push(
                    concat!(
                        "    /// HTTP status code for this error (0 means no associated status).\n",
                        "    pub fn status_code(&self) -> u16 {\n",
                        "        self.status_code\n",
                        "    }",
                    )
                    .to_string(),
                );
                ctor_assignments.push("        status_code: e.status_code(),".to_string());
            }
            "is_transient" => {
                fields.push("    pub is_transient: bool,".to_string());
                methods.push(
                    concat!(
                        "    /// Returns `true` if the error is transient and a retry may succeed.\n",
                        "    pub fn is_transient(&self) -> bool {\n",
                        "        self.is_transient\n",
                        "    }",
                    )
                    .to_string(),
                );
                ctor_assignments.push("        is_transient: e.is_transient(),".to_string());
            }
            "error_type" => {
                fields.push("    pub error_type: String,".to_string());
                methods.push(
                    concat!(
                        "    /// Machine-readable error category string for matching and logging.\n",
                        "    pub fn error_type(&self) -> String {\n",
                        "        self.error_type.clone()\n",
                        "    }",
                    )
                    .to_string(),
                );
                ctor_assignments.push("        error_type: e.error_type().to_string(),".to_string());
            }
            other => {
                methods.push(format!("    // TODO: emit method for `{other}` on `{struct_name}`"));
            }
        }
    }

    let struct_def = format!("#[php_class]\npub struct {struct_name} {{\n{}\n}}", fields.join("\n"));

    let from_fn = format!(
        "#[allow(dead_code)]\nfn {snake_name}_info(e: &{rust_path}) -> {struct_name} {{\n    {struct_name} {{\n{}\n    }}\n}}",
        ctor_assignments.join("\n"),
        snake_name = to_snake_case(&error.name),
    );

    let impl_block = format!("#[php_impl]\nimpl {struct_name} {{\n{}\n}}", methods.join("\n\n"));

    format!("{struct_def}\n\n{from_fn}\n\n{impl_block}")
}

// ---------------------------------------------------------------------------
// Magnus (Ruby) error generation
// ---------------------------------------------------------------------------

/// Generate a converter function that maps a core error to `magnus::Error`.
pub fn gen_magnus_error_converter(error: &ErrorDef, core_import: &str) -> String {
    let rust_path = if error.rust_path.is_empty() {
        format!("{core_import}::{}", error.name)
    } else {
        error.rust_path.replace('-', "_")
    };

    let fn_name = format!("{}_to_magnus_err", to_snake_case(&error.name));

    crate::codegen::template_env::render(
        "error_gen/magnus_error_converter.jinja",
        minijinja::context! {
            rust_path => rust_path.as_str(),
            fn_name => fn_name.as_str(),
        },
    )
}

/// Return the Magnus converter function name for a given error type.
pub fn magnus_converter_fn_name(error: &ErrorDef) -> String {
    format!("{}_to_magnus_err", to_snake_case(&error.name))
}

// ---------------------------------------------------------------------------
// Rustler (Elixir) error generation
// ---------------------------------------------------------------------------

/// Generate a converter function that maps a core error to a Rustler error tuple `{:error, reason}`.
pub fn gen_rustler_error_converter(error: &ErrorDef, core_import: &str) -> String {
    let rust_path = if error.rust_path.is_empty() {
        format!("{core_import}::{}", error.name)
    } else {
        error.rust_path.replace('-', "_")
    };

    let fn_name = format!("{}_to_rustler_err", to_snake_case(&error.name));

    crate::codegen::template_env::render(
        "error_gen/rustler_error_converter.jinja",
        minijinja::context! {
            rust_path => rust_path.as_str(),
            fn_name => fn_name.as_str(),
        },
    )
}

/// Return the Rustler converter function name for a given error type.
pub fn rustler_converter_fn_name(error: &ErrorDef) -> String {
    format!("{}_to_rustler_err", to_snake_case(&error.name))
}

// ---------------------------------------------------------------------------
// FFI (C) error code generation
// ---------------------------------------------------------------------------

/// Generate a C enum of error codes plus an error-message function declaration.
///
/// Produces a `typedef enum` with `PREFIX_ERROR_NONE = 0` followed by one entry
/// per variant, plus a function that returns the default message for a given code.
pub fn gen_ffi_error_codes(error: &ErrorDef) -> String {
    let prefix = to_screaming_snake(&error.name);
    let prefix_lower = to_snake_case(&error.name);

    // Pre-compute (variant_screaming, index) pairs
    let mut variant_variants = Vec::new();
    for (i, variant) in error.variants.iter().enumerate() {
        let variant_screaming = to_screaming_snake(&variant.name);
        variant_variants.push((variant_screaming, (i + 1).to_string()));
    }

    crate::codegen::template_env::render(
        "error_gen/ffi_error_codes.jinja",
        minijinja::context! {
            error_name => error.name.as_str(),
            prefix => prefix.as_str(),
            prefix_lower => prefix_lower.as_str(),
            variant_variants => variant_variants,
        },
    )
}

/// Generate `#[no_mangle] extern "C"` helper functions for the whitelisted
/// introspection methods (`status_code`, `is_transient`, `error_type`) declared
/// in `error.methods`.
///
/// Each function follows the opaque-pointer convention: accepts a
/// `*const {rust_path}` (null-checked before dereference) and returns the
/// method's value. For `error_type` an additional `*_error_type_free` companion
/// is emitted so callers can release the `CString`-allocated memory.
///
/// Returns an empty string when `error.methods` is empty.
pub fn gen_ffi_error_methods(error: &ErrorDef, core_import: &str, api_prefix: &str) -> String {
    if error.methods.is_empty() {
        return String::new();
    }

    let rust_path = if error.rust_path.is_empty() {
        format!("{core_import}::{}", error.name)
    } else {
        error.rust_path.replace('-', "_")
    };

    let error_snake = to_snake_case(&error.name);
    let mut items: Vec<String> = Vec::new();

    for method in &error.methods {
        match method.name.as_str() {
            "status_code" => {
                let fn_name = format!("{api_prefix}_{error_snake}_status_code");
                items.push(format!(
                    "/// Return the HTTP status code for the error pointed to by `err`.\n\
                     /// Returns `0` if `err` is null.\n\
                     #[no_mangle]\n\
                     pub unsafe extern \"C\" fn {fn_name}(err: *const {rust_path}) -> u16 {{\n\
                         // SAFETY: caller guarantees `err` points to a live `{rust_path}` value\n\
                         // allocated by this library, or is null.\n\
                         if err.is_null() {{\n\
                             return 0;\n\
                         }}\n\
                         (*err).status_code()\n\
                     }}"
                ));
            }
            "is_transient" => {
                let fn_name = format!("{api_prefix}_{error_snake}_is_transient");
                items.push(format!(
                    "/// Return whether the error pointed to by `err` is transient.\n\
                     /// Returns `false` if `err` is null.\n\
                     #[no_mangle]\n\
                     pub unsafe extern \"C\" fn {fn_name}(err: *const {rust_path}) -> bool {{\n\
                         // SAFETY: caller guarantees `err` points to a live `{rust_path}` value\n\
                         // allocated by this library, or is null.\n\
                         if err.is_null() {{\n\
                             return false;\n\
                         }}\n\
                         (*err).is_transient()\n\
                     }}"
                ));
            }
            "error_type" => {
                let fn_name = format!("{api_prefix}_{error_snake}_error_type");
                let free_fn_name = format!("{fn_name}_free");
                items.push(format!(
                    "/// Return the machine-readable error category string for the error pointed\n\
                     /// to by `err` as a heap-allocated, NUL-terminated C string.\n\
                     /// The caller must free the returned pointer with `{free_fn_name}`.\n\
                     /// Returns a null pointer if `err` is null.\n\
                     #[no_mangle]\n\
                     pub unsafe extern \"C\" fn {fn_name}(err: *const {rust_path}) -> *mut std::ffi::c_char {{\n\
                         // SAFETY: caller guarantees `err` points to a live `{rust_path}` value\n\
                         // allocated by this library, or is null.\n\
                         if err.is_null() {{\n\
                             return std::ptr::null_mut();\n\
                         }}\n\
                         let s = (*err).error_type();\n\
                         // SAFETY: `error_type()` returns a `'static str` containing no NUL bytes.\n\
                         std::ffi::CString::new(s)\n\
                             .map(|c| c.into_raw())\n\
                             .unwrap_or(std::ptr::null_mut())\n\
                     }}\n\n\
                     /// Free a string previously returned by `{fn_name}`.\n\
                     /// Passing a null pointer is a no-op.\n\
                     #[no_mangle]\n\
                     pub unsafe extern \"C\" fn {free_fn_name}(ptr: *mut std::ffi::c_char) {{\n\
                         // SAFETY: `ptr` was allocated by `CString::into_raw` inside\n\
                         // `{fn_name}` and is now being reclaimed by the matching\n\
                         // `CString::from_raw`.  Passing null is explicitly allowed.\n\
                         if !ptr.is_null() {{\n\
                             drop(std::ffi::CString::from_raw(ptr));\n\
                         }}\n\
                     }}"
                ));
            }
            other => {
                // Unknown whitelisted method — emit a comment so it is visible in review.
                items.push(format!(
                    "// TODO: emit FFI helper for method `{other}` on `{rust_path}`"
                ));
            }
        }
    }

    items.join("\n\n")
}

// ---------------------------------------------------------------------------
// Go error type generation
// ---------------------------------------------------------------------------

/// Generate Go sentinel errors and a structured error type for an `ErrorDef`.
///
/// `pkg_name` is the Go package name (e.g. `"samplellm"`). When the error struct
/// name starts with the package name (case-insensitively), the package-name
/// prefix is stripped to avoid the revive `exported` stutter lint error
/// (e.g. `SampleLlmError` in package `samplellm` → exported as `Error`).
pub fn gen_go_error_types(error: &ErrorDef, pkg_name: &str) -> String {
    let sentinels = gen_go_sentinel_errors(std::slice::from_ref(error));
    let structured = gen_go_error_struct(error, pkg_name);
    format!("{}\n\n{}", sentinels, structured)
}

/// Generate a single consolidated `var (...)` block of Go sentinel errors
/// across multiple `ErrorDef`s.
///
/// When the same variant name appears in more than one `ErrorDef` (e.g. both
/// `GraphQLError` and `SchemaError` define `ValidationError`), the colliding
/// const names are disambiguated by prefixing with the parent error type's
/// stripped base name. For example, `GraphQLError::ValidationError` and
/// `SchemaError::ValidationError` become `ErrGraphQLValidationError` and
/// `ErrSchemaValidationError`. Variant names that are unique across all
/// errors are emitted as plain `Err{Variant}` consts.
pub fn gen_go_sentinel_errors(errors: &[ErrorDef]) -> String {
    if errors.is_empty() {
        return String::new();
    }
    let mut variant_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
    for err in errors {
        for v in &err.variants {
            *variant_counts.entry(v.name.as_str()).or_insert(0) += 1;
        }
    }
    let mut seen = std::collections::HashSet::new();
    let mut sentinels = Vec::new();
    for err in errors {
        let parent_base = error_base_prefix(&err.name);
        for variant in &err.variants {
            let collides = variant_counts.get(variant.name.as_str()).copied().unwrap_or(0) > 1;
            let const_name = if collides {
                format!("Err{}{}", parent_base, variant.name)
            } else {
                format!("Err{}", variant.name)
            };
            if !seen.insert(const_name.clone()) {
                continue;
            }
            let msg = variant_display_message(variant);
            sentinels.push((const_name, msg));
        }
    }

    crate::codegen::template_env::render(
        "error_gen/go_sentinel_errors.jinja",
        minijinja::context! {
            sentinels => sentinels,
        },
    )
}

/// Generate the structured error type (struct + Error() method) for a single
/// error definition. Sentinel errors are emitted separately by
/// [`gen_go_sentinel_errors`].
///
/// When `error.methods` is non-empty, each whitelisted introspection method
/// produces an exported struct field of the matching Go type plus a receiver
/// method that returns that field.
pub fn gen_go_error_struct(error: &ErrorDef, pkg_name: &str) -> String {
    let go_type_name = strip_package_prefix(&error.name, pkg_name);

    // Build per-method info for the template.
    // Each entry: { field_name, go_type, method_name, doc }
    // field_name: PascalCase exported field (e.g. StatusCode)
    // go_type:    Go type string (uint16 / bool / string)
    // method_name: exported Go method name (e.g. StatusCode)
    let methods: Vec<serde_json::Value> = error
        .methods
        .iter()
        .map(|m| {
            let go_type = typeref_to_go_type(&m.return_type);
            let method_name = to_pascal_case(&m.name);
            // Collapse multi-line rustdoc to a single-line summary so the template's
            // `// {{ method_name }} returns {{ doc }}.` does not emit unprefixed
            // continuation lines (markdown body, fenced code, `# Examples`) which
            // Go rejects as syntax errors.
            let doc_summary = if m.doc.is_empty() {
                String::new()
            } else {
                let first = crate::codegen::doc_emission::doc_first_paragraph_joined(&m.doc);
                first.trim_end_matches('.').trim_end().to_string()
            };
            serde_json::json!({
                "field_name": method_name,
                "go_type": go_type,
                "method_name": method_name,
                "doc": doc_summary,
            })
        })
        .collect();
    let has_methods = !methods.is_empty();

    crate::codegen::template_env::render(
        "error_gen/go_error_struct.jinja",
        minijinja::context! {
            go_type_name => go_type_name.as_str(),
            methods => methods,
            has_methods => has_methods,
        },
    )
}

/// Map an IR `TypeRef` to a Go type string for error introspection method returns.
/// Only the primitive subset needed for the whitelisted methods is handled;
/// everything else falls back to `string`.
fn typeref_to_go_type(ty: &crate::core::ir::TypeRef) -> &'static str {
    use crate::core::ir::{PrimitiveType, TypeRef};
    match ty {
        TypeRef::Primitive(PrimitiveType::Bool) => "bool",
        TypeRef::Primitive(PrimitiveType::U8) => "uint8",
        TypeRef::Primitive(PrimitiveType::U16) => "uint16",
        TypeRef::Primitive(PrimitiveType::U32) => "uint32",
        TypeRef::Primitive(PrimitiveType::U64) => "uint64",
        TypeRef::Primitive(PrimitiveType::I8) => "int8",
        TypeRef::Primitive(PrimitiveType::I16) => "int16",
        TypeRef::Primitive(PrimitiveType::I32) => "int32",
        TypeRef::Primitive(PrimitiveType::I64) => "int64",
        TypeRef::Primitive(PrimitiveType::F32) => "float32",
        TypeRef::Primitive(PrimitiveType::F64) => "float64",
        TypeRef::String => "string",
        _ => "string",
    }
}

/// Convert a snake_case or camelCase name to PascalCase.
fn to_pascal_case(s: &str) -> String {
    s.split('_')
        .map(|word| {
            let mut chars = word.chars();
            match chars.next() {
                None => String::new(),
                Some(first) => first.to_uppercase().to_string() + chars.as_str(),
            }
        })
        .collect()
}

/// Strip the package-name prefix from a type name to avoid revive's stutter lint.
///
/// Revive reports `exported: type name will be used as pkg.PkgFoo by other packages,
/// and that stutters` when a type name begins with the package name. This function
/// removes the prefix when it matches (case-insensitively) so that the exported name
/// does not repeat the package name.
///
/// Examples:
/// - `("SampleLlmError", "samplellm")` → `"Error"` (lowercased `samplellm` is a prefix
///   of lowercased `samplellmerror`)
/// - `("ConversionError", "converter")` → `"ConversionError"` (no match)
fn strip_package_prefix(type_name: &str, pkg_name: &str) -> String {
    let type_lower = type_name.to_lowercase();
    let pkg_lower = pkg_name.to_lowercase();
    if type_lower.starts_with(&pkg_lower) && type_lower.len() > pkg_lower.len() {
        // Retain the original casing for the suffix part.
        type_name[pkg_lower.len()..].to_string()
    } else {
        type_name.to_string()
    }
}

// ---------------------------------------------------------------------------
// Java error type generation
// ---------------------------------------------------------------------------

/// Generate Java exception sub-classes for each error variant.
///
/// Returns a `Vec` of `(class_name, file_content)` tuples: the base exception
/// class followed by one per-variant exception.  The caller writes each to a
/// separate `.java` file.
///
/// When `error.methods` is non-empty, the base exception class gains private
/// final fields, an extended constructor, and public getter methods for each
/// whitelisted introspection method.  Variant classes delegate via `super(…)`.
pub fn gen_java_error_types(error: &ErrorDef, package: &str) -> Vec<(String, String)> {
    let mut files = Vec::with_capacity(error.variants.len() + 1);

    // Base exception class
    let base_name = format!("{}Exception", error.name);
    let doc_lines: Vec<&str> = error.doc.lines().collect();

    // Build per-method info for the template.
    // Each entry: { field_name, java_type, getter_name, doc }
    let method_infos: Vec<serde_json::Value> = error
        .methods
        .iter()
        .map(|m| {
            let java_type = typeref_to_java_type(&m.return_type);
            let getter_name = java_getter_name(&m.name);
            let field_name = java_field_name(&m.name);
            let default_value = java_default_value(&m.return_type);
            serde_json::json!({
                "field_name": field_name,
                "java_type": java_type,
                "getter_name": getter_name,
                "default_value": default_value,
                "doc": m.doc,
            })
        })
        .collect();
    let has_methods = !method_infos.is_empty();

    let base = crate::codegen::template_env::render(
        "error_gen/java_error_base.jinja",
        minijinja::context! {
            package => package,
            base_name => base_name.as_str(),
            doc => !error.doc.is_empty(),
            doc_lines => doc_lines,
            methods => method_infos,
            has_methods => has_methods,
        },
    );
    files.push((base_name.clone(), base));

    // Per-variant exception classes
    for variant in &error.variants {
        let class_name = format!("{}Exception", variant.name);
        let doc_lines: Vec<&str> = variant.doc.lines().collect();

        let content = crate::codegen::template_env::render(
            "error_gen/java_error_variant.jinja",
            minijinja::context! {
                package => package,
                class_name => class_name.as_str(),
                base_name => base_name.as_str(),
                doc => !variant.doc.is_empty(),
                doc_lines => doc_lines,
                has_methods => has_methods,
            },
        );
        files.push((class_name, content));
    }

    files
}

/// Map an IR `TypeRef` to a Java type string for error introspection getters.
fn typeref_to_java_type(ty: &crate::core::ir::TypeRef) -> &'static str {
    use crate::core::ir::{PrimitiveType, TypeRef};
    match ty {
        TypeRef::Primitive(PrimitiveType::Bool) => "boolean",
        TypeRef::Primitive(
            PrimitiveType::U8
            | PrimitiveType::I8
            | PrimitiveType::I16
            | PrimitiveType::U16
            | PrimitiveType::I32
            | PrimitiveType::U32,
        ) => "int",
        TypeRef::Primitive(PrimitiveType::I64 | PrimitiveType::U64) => "long",
        TypeRef::Primitive(PrimitiveType::F32) => "float",
        TypeRef::Primitive(PrimitiveType::F64) => "double",
        TypeRef::String => "String",
        _ => "String",
    }
}

/// Convert a snake_case method name to a Java getter name.
/// E.g. `status_code` → `getStatusCode`, `is_transient` → `isTransient`.
fn java_getter_name(snake: &str) -> String {
    if let Some(rest) = snake.strip_prefix("is_") {
        // is_transient → isTransient
        let pascal = to_pascal_case(rest);
        format!("is{pascal}")
    } else {
        // status_code → getStatusCode, error_type → getErrorType
        let pascal = to_pascal_case(snake);
        format!("get{pascal}")
    }
}

/// Convert a snake_case method name to a Java field name (camelCase).
/// E.g. `status_code` → `statusCode`, `is_transient` → `isTransient`.
fn java_field_name(snake: &str) -> String {
    let parts: Vec<&str> = snake.split('_').collect();
    if parts.is_empty() {
        return snake.to_string();
    }
    let mut out = parts[0].to_string();
    for part in &parts[1..] {
        let mut chars = part.chars();
        match chars.next() {
            None => {}
            Some(first) => {
                out.push_str(&first.to_uppercase().to_string());
                out.push_str(chars.as_str());
            }
        }
    }
    out
}

/// Return the Java zero-value literal for a type (used in the no-args default constructor).
fn java_default_value(ty: &crate::core::ir::TypeRef) -> &'static str {
    use crate::core::ir::{PrimitiveType, TypeRef};
    match ty {
        TypeRef::Primitive(PrimitiveType::Bool) => "false",
        TypeRef::String => "\"\"",
        _ => "0",
    }
}

// ---------------------------------------------------------------------------
// C# error type generation
// ---------------------------------------------------------------------------

/// Generate C# exception sub-classes for each error variant.
///
/// Returns a `Vec` of `(class_name, file_content)` tuples: the base exception
/// class followed by one per-variant exception.  The caller writes each to a
/// separate `.cs` file.
///
/// `fallback_class` is the name of the generic library exception class (e.g.
/// `SampleLanguagePackException`) that the base error class should extend so that
/// callers can `catch` the general library exception and catch all typed errors.
///
/// When `error.methods` is non-empty, the base exception class gains get-only
/// properties for each whitelisted introspection method.  Variant classes
/// delegate via `base(…)` and inherit the properties.
pub fn gen_csharp_error_types(
    error: &ErrorDef,
    namespace: &str,
    fallback_class: Option<&str>,
) -> Vec<(String, String)> {
    let mut files = Vec::with_capacity(error.variants.len() + 1);

    let base_name = format!("{}Exception", error.name);
    // Inherit from the generic library exception when provided so that
    // `Assert.ThrowsAny<LibException>()` catches typed errors too.
    let base_parent = fallback_class.unwrap_or("Exception");
    // Sanitise rustdoc-style markup so the resulting C# XML doc parses cleanly.
    // The base error doc routinely contains `# Examples`, ```ignore code fences,
    // `Self::error_code`, `Result<T, E>` and other Rust idioms that Roslyn rejects
    // when leaked verbatim into `<summary>`.
    let sanitized_error_doc = crate::codegen::doc_emission::sanitize_rust_idioms(
        &error.doc,
        crate::codegen::doc_emission::DocTarget::CSharpDoc,
    );
    let error_doc_lines: Vec<&str> = sanitized_error_doc.lines().collect();
    let error_has_doc = !sanitized_error_doc.trim().is_empty();

    // Build per-method info for the template.
    // Each entry: { prop_name, cs_type, param_name, doc }
    let method_infos: Vec<serde_json::Value> = error
        .methods
        .iter()
        .map(|m| {
            let cs_type = typeref_to_csharp_type(&m.return_type);
            let prop_name = to_pascal_case(&m.name);
            let param_name = java_field_name(&m.name); // camelCase ctor parameter
            let default_value = csharp_default_value(&m.return_type);
            // Per-method docs are emitted inline as a single-line `<summary>`,
            // so collapse multi-line sanitised output to its first paragraph.
            let sanitized_method_doc = crate::codegen::doc_emission::sanitize_rust_idioms(
                &m.doc,
                crate::codegen::doc_emission::DocTarget::CSharpDoc,
            );
            let inline_doc = sanitized_method_doc
                .lines()
                .map(str::trim)
                .filter(|l| !l.is_empty())
                .collect::<Vec<_>>()
                .join(" ");
            serde_json::json!({
                "prop_name": prop_name,
                "cs_type": cs_type,
                "param_name": param_name,
                "default_value": default_value,
                "doc": inline_doc,
            })
        })
        .collect();
    let has_methods = !method_infos.is_empty();

    // Base exception class
    {
        let out = crate::codegen::template_env::render(
            "error_gen/csharp_error_base.jinja",
            minijinja::context! {
                namespace => namespace,
                base_name => base_name.as_str(),
                base_parent => base_parent,
                doc => error_has_doc,
                doc_lines => error_doc_lines,
                methods => method_infos,
                has_methods => has_methods,
            },
        );
        files.push((base_name.clone(), out));
    }

    // Per-variant exception classes
    for variant in &error.variants {
        let class_name = format!("{}Exception", variant.name);
        let sanitized_variant_doc = crate::codegen::doc_emission::sanitize_rust_idioms(
            &variant.doc,
            crate::codegen::doc_emission::DocTarget::CSharpDoc,
        );
        let variant_doc_lines: Vec<&str> = sanitized_variant_doc.lines().collect();
        let variant_has_doc = !sanitized_variant_doc.trim().is_empty();

        let out = crate::codegen::template_env::render(
            "error_gen/csharp_error_variant.jinja",
            minijinja::context! {
                namespace => namespace,
                class_name => class_name.as_str(),
                base_name => base_name.as_str(),
                doc => variant_has_doc,
                doc_lines => variant_doc_lines,
                has_methods => has_methods,
            },
        );
        files.push((class_name, out));
    }

    files
}

/// Map an IR `TypeRef` to a C# type string for error introspection properties.
fn typeref_to_csharp_type(ty: &crate::core::ir::TypeRef) -> &'static str {
    use crate::core::ir::{PrimitiveType, TypeRef};
    match ty {
        TypeRef::Primitive(PrimitiveType::Bool) => "bool",
        TypeRef::Primitive(PrimitiveType::U8) => "byte",
        TypeRef::Primitive(PrimitiveType::I8) => "sbyte",
        TypeRef::Primitive(PrimitiveType::I16) => "short",
        TypeRef::Primitive(PrimitiveType::U16) => "ushort",
        TypeRef::Primitive(PrimitiveType::I32) => "int",
        TypeRef::Primitive(PrimitiveType::U32) => "uint",
        TypeRef::Primitive(PrimitiveType::I64) => "long",
        TypeRef::Primitive(PrimitiveType::U64) => "ulong",
        TypeRef::Primitive(PrimitiveType::F32) => "float",
        TypeRef::Primitive(PrimitiveType::F64) => "double",
        TypeRef::String => "string",
        _ => "string",
    }
}

/// Return the C# zero-value literal for a type (used in the default constructor).
fn csharp_default_value(ty: &crate::core::ir::TypeRef) -> &'static str {
    use crate::core::ir::{PrimitiveType, TypeRef};
    match ty {
        TypeRef::Primitive(PrimitiveType::Bool) => "false",
        TypeRef::String => "string.Empty",
        _ => "0",
    }
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Convert CamelCase to SCREAMING_SNAKE_CASE.
fn to_screaming_snake(s: &str) -> String {
    let mut result = String::with_capacity(s.len() + 4);
    for (i, c) in s.chars().enumerate() {
        if c.is_uppercase() {
            if i > 0 {
                result.push('_');
            }
            result.push(c.to_ascii_uppercase());
        } else {
            result.push(c.to_ascii_uppercase());
        }
    }
    result
}

/// Well-known acronyms recognised by the doc/error renderers.
///
/// When emitting human-readable Display strings (e.g. for Go sentinel
/// `errors.New("...")`), variant names like `IoError` must render as
/// "IO error" — not "iO error" (the result of naive `lowercase first
/// character` after `to_snake_case`).
const TECHNICAL_ACRONYMS: &[&str] = &[
    "API", "ASCII", "CPU", "CSS", "CSV", "DNS", "EOF", "FFI", "FTP", "GID", "GPU", "GUI", "HTML", "HTTP", "HTTPS",
    "ID", "IO", "IP", "JSON", "JWT", "LDAP", "MFA", "MIME", "OCR", "OS", "PDF", "PID", "PNG", "QPS", "RAM", "RGB",
    "RPC", "RTF", "SDK", "SLA", "SMTP", "SQL", "SSH", "SSL", "SVG", "TCP", "TLS", "TOML", "TTL", "UDP", "UI", "UID",
    "URI", "URL", "UTF8", "UUID", "VM", "XML", "XMPP", "XSRF", "XSS", "YAML", "ZIP",
];

/// Strip `thiserror`-style `{name}` placeholders from a Display template
/// without leaving stray punctuation.
///
/// Examples:
///
/// - `"OCR error: {message}"`           → `"OCR error"`
/// - `"plugin error in '{plugin_name}'"` → `"plugin error"`
/// - `"timed out after {elapsed_ms}ms (limit: {limit_ms}ms)"` → `"timed out"`
/// - `"I/O error: {0}"`                  → `"I/O error"`
///
/// Used by `variant_display_message` and binding error renderers
/// (Dart, Go, …) so the literal placeholder string never reaches
/// the runtime.
pub fn strip_thiserror_placeholders(template: &str) -> String {
    // Remove every `{...}` segment.
    let mut without_placeholders = String::with_capacity(template.len());
    let mut depth = 0u32;
    for ch in template.chars() {
        match ch {
            '{' => depth = depth.saturating_add(1),
            '}' => depth = depth.saturating_sub(1),
            other if depth == 0 => without_placeholders.push(other),
            _ => {}
        }
    }
    // Remove orphaned punctuation/whitespace immediately around the holes
    // (collapse runs of whitespace, drop trailing `:`/quote runs, drop
    // `(...)` shells that wrapped only placeholders).
    let mut compacted = String::with_capacity(without_placeholders.len());
    let mut last_was_space = false;
    for ch in without_placeholders.chars() {
        if ch.is_whitespace() {
            if !last_was_space && !compacted.is_empty() {
                compacted.push(' ');
            }
            last_was_space = true;
        } else {
            compacted.push(ch);
            last_was_space = false;
        }
    }
    // Trim trailing punctuation that only made sense before a placeholder.
    let trimmed = compacted
        .trim()
        .trim_end_matches([':', ',', '-', ';', '(', '\'', '"', ' '])
        .trim();
    // If we left e.g. `"limit: ms ms"` artefacts behind, collapse stray
    // empty parens / paired quotes.
    let cleaned = trimmed
        .replace("()", "")
        .replace("''", "")
        .replace("\"\"", "")
        .replace("  ", " ");
    cleaned.trim().to_string()
}

/// Convert a PascalCase variant name into a human readable phrase that
/// preserves canonical acronyms.
///
/// Examples:
/// - `"IoError"`           → `"IO error"`
/// - `"OcrError"`          → `"OCR error"`
/// - `"PdfParse"`          → `"PDF parse"`
/// - `"HttpRequestFailed"` → `"HTTP request failed"`
/// - `"Other"`             → `"other"`
pub fn acronym_aware_snake_phrase(variant_name: &str) -> String {
    if variant_name.is_empty() {
        return String::new();
    }
    // Split into PascalCase words (each word starts with an uppercase letter).
    let bytes = variant_name.as_bytes();
    let mut words: Vec<&str> = Vec::new();
    let mut start = 0usize;
    for i in 1..bytes.len() {
        if bytes[i].is_ascii_uppercase() {
            words.push(&variant_name[start..i]);
            start = i;
        }
    }
    words.push(&variant_name[start..]);

    let mut rendered: Vec<String> = Vec::with_capacity(words.len());
    for word in &words {
        let upper = word.to_ascii_uppercase();
        if TECHNICAL_ACRONYMS.contains(&upper.as_str()) {
            rendered.push(upper);
        } else {
            rendered.push(word.to_ascii_lowercase());
        }
    }
    rendered.join(" ")
}

/// Generate a human-readable message for an error variant.
///
/// Uses the `message_template` if present, otherwise falls back to a
/// space-separated version of the variant name (e.g. "ParseError" -> "parse error").
fn variant_display_message(variant: &ErrorVariant) -> String {
    if let Some(tmpl) = &variant.message_template {
        let stripped = strip_thiserror_placeholders(tmpl);
        if stripped.is_empty() {
            return acronym_aware_snake_phrase(&variant.name);
        }
        // Preserve canonical acronyms but lowercase the first regular word so
        // Go's `lowercase first char` convention does not corrupt `IO` → `iO`.
        // Heuristic: if the first whitespace-delimited token is *not* already
        // a known acronym, downcase its first character.
        let mut tokens = stripped.splitn(2, ' ');
        let head = tokens.next().unwrap_or("").to_string();
        let tail = tokens.next().unwrap_or("");
        let head_upper = head.to_ascii_uppercase();
        let head_rendered = if TECHNICAL_ACRONYMS.contains(&head_upper.as_str()) {
            head_upper
        } else {
            let mut chars = head.chars();
            match chars.next() {
                Some(c) => c.to_lowercase().to_string() + chars.as_str(),
                None => head,
            }
        };
        if tail.is_empty() {
            head_rendered
        } else {
            format!("{} {}", head_rendered, tail)
        }
    } else {
        acronym_aware_snake_phrase(&variant.name)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::ir::{ErrorDef, ErrorVariant};

    use crate::core::ir::{CoreWrapper, FieldDef, TypeRef};

    /// Helper to create a tuple-style field (e.g. `_0: String`).
    fn tuple_field(index: usize) -> FieldDef {
        FieldDef {
            name: format!("_{index}"),
            ty: TypeRef::String,
            optional: false,
            default: None,
            doc: String::new(),
            sanitized: false,
            is_boxed: false,
            type_rust_path: None,
            cfg: None,
            typed_default: None,
            core_wrapper: CoreWrapper::None,
            vec_inner_core_wrapper: CoreWrapper::None,
            newtype_wrapper: None,
            serde_rename: None,
            serde_flatten: false,
            binding_excluded: false,
            binding_exclusion_reason: None,
            original_type: None,
        }
    }

    /// Helper to create a named struct field.
    fn named_field(name: &str) -> FieldDef {
        FieldDef {
            name: name.to_string(),
            ty: TypeRef::String,
            optional: false,
            default: None,
            doc: String::new(),
            sanitized: false,
            is_boxed: false,
            type_rust_path: None,
            cfg: None,
            typed_default: None,
            core_wrapper: CoreWrapper::None,
            vec_inner_core_wrapper: CoreWrapper::None,
            newtype_wrapper: None,
            serde_rename: None,
            serde_flatten: false,
            binding_excluded: false,
            binding_exclusion_reason: None,
            original_type: None,
        }
    }

    fn sample_error() -> ErrorDef {
        ErrorDef {
            name: "ConversionError".to_string(),
            rust_path: "sample_markdown_rs::ConversionError".to_string(),
            original_rust_path: String::new(),
            variants: vec![
                ErrorVariant {
                    name: "ParseError".to_string(),
                    message_template: Some("HTML parsing error: {0}".to_string()),
                    fields: vec![tuple_field(0)],
                    has_source: false,
                    has_from: false,
                    is_unit: false,
                    doc: String::new(),
                },
                ErrorVariant {
                    name: "IoError".to_string(),
                    message_template: Some("I/O error: {0}".to_string()),
                    fields: vec![tuple_field(0)],
                    has_source: false,
                    has_from: true,
                    is_unit: false,
                    doc: String::new(),
                },
                ErrorVariant {
                    name: "Other".to_string(),
                    message_template: Some("Conversion error: {0}".to_string()),
                    fields: vec![tuple_field(0)],
                    has_source: false,
                    has_from: false,
                    is_unit: false,
                    doc: String::new(),
                },
            ],
            doc: "Error type for conversion operations.".to_string(),
            methods: vec![],
            binding_excluded: false,
            binding_exclusion_reason: None,
        }
    }

    #[test]
    fn test_gen_error_types() {
        let error = sample_error();
        let output = gen_pyo3_error_types(&error, "_module", &mut AHashSet::new());
        assert!(output.contains("pyo3::create_exception!(_module, ParseError, pyo3::exceptions::PyException);"));
        assert!(output.contains("pyo3::create_exception!(_module, IoError, pyo3::exceptions::PyException);"));
        assert!(output.contains("pyo3::create_exception!(_module, OtherError, pyo3::exceptions::PyException);"));
        assert!(output.contains("pyo3::create_exception!(_module, ConversionError, pyo3::exceptions::PyException);"));
    }

    #[test]
    fn test_gen_error_converter() {
        let error = sample_error();
        let output = gen_pyo3_error_converter(&error, "sample_markdown_rs");
        assert!(
            output.contains("fn conversion_error_to_py_err(e: sample_markdown_rs::ConversionError) -> pyo3::PyErr {")
        );
        assert!(output.contains("sample_markdown_rs::ConversionError::ParseError(..) => ParseError::new_err(msg),"));
        assert!(output.contains("sample_markdown_rs::ConversionError::IoError(..) => IoError::new_err(msg),"));
    }

    #[test]
    fn test_gen_error_registration() {
        let error = sample_error();
        let regs = gen_pyo3_error_registration(&error, &mut AHashSet::new());
        assert_eq!(regs.len(), 4); // 3 variants + 1 base
        assert!(regs[0].contains("\"ParseError\""));
        assert!(regs[3].contains("\"ConversionError\""));
    }

    #[test]
    fn test_unit_variant_pattern() {
        let error = ErrorDef {
            name: "MyError".to_string(),
            rust_path: "my_crate::MyError".to_string(),
            original_rust_path: String::new(),
            variants: vec![ErrorVariant {
                name: "NotFound".to_string(),
                message_template: Some("not found".to_string()),
                fields: vec![],
                has_source: false,
                has_from: false,
                is_unit: true,
                doc: String::new(),
            }],
            doc: String::new(),
            methods: vec![],
            binding_excluded: false,
            binding_exclusion_reason: None,
        };
        let output = gen_pyo3_error_converter(&error, "my_crate");
        assert!(output.contains("my_crate::MyError::NotFound => NotFoundError::new_err(msg),"));
        // Ensure no (..) for unit variants
        assert!(!output.contains("NotFound(..)"));
    }

    #[test]
    fn test_struct_variant_pattern() {
        let error = ErrorDef {
            name: "MyError".to_string(),
            rust_path: "my_crate::MyError".to_string(),
            original_rust_path: String::new(),
            variants: vec![ErrorVariant {
                name: "Parsing".to_string(),
                message_template: Some("parsing error: {message}".to_string()),
                fields: vec![named_field("message")],
                has_source: false,
                has_from: false,
                is_unit: false,
                doc: String::new(),
            }],
            doc: String::new(),
            methods: vec![],
            binding_excluded: false,
            binding_exclusion_reason: None,
        };
        let output = gen_pyo3_error_converter(&error, "my_crate");
        assert!(
            output.contains("my_crate::MyError::Parsing { .. } => ParsingError::new_err(msg),"),
            "Struct variants must use {{ .. }} pattern, got:\n{output}"
        );
        // Ensure no (..) for struct variants
        assert!(!output.contains("Parsing(..)"));
    }

    // -----------------------------------------------------------------------
    // NAPI tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_gen_napi_error_types() {
        let error = sample_error();
        let output = gen_napi_error_types(&error);
        assert!(output.contains("CONVERSION_ERROR_ERROR_PARSE_ERROR"));
        assert!(output.contains("CONVERSION_ERROR_ERROR_IO_ERROR"));
        assert!(output.contains("CONVERSION_ERROR_ERROR_OTHER"));
    }

    #[test]
    fn test_gen_napi_error_converter() {
        let error = sample_error();
        let output = gen_napi_error_converter(&error, "sample_markdown_rs");
        assert!(
            output.contains("fn conversion_error_to_napi_err(e: sample_markdown_rs::ConversionError) -> napi::Error {")
        );
        assert!(output.contains("napi::Error::new(napi::Status::GenericFailure,"));
        assert!(output.contains("[ParseError]"));
        assert!(output.contains("[IoError]"));
        assert!(output.contains("#[allow(dead_code)]"));
    }

    #[test]
    fn test_napi_unit_variant() {
        let error = ErrorDef {
            name: "MyError".to_string(),
            rust_path: "my_crate::MyError".to_string(),
            original_rust_path: String::new(),
            variants: vec![ErrorVariant {
                name: "NotFound".to_string(),
                message_template: None,
                fields: vec![],
                has_source: false,
                has_from: false,
                is_unit: true,
                doc: String::new(),
            }],
            doc: String::new(),
            methods: vec![],
            binding_excluded: false,
            binding_exclusion_reason: None,
        };
        let output = gen_napi_error_converter(&error, "my_crate");
        assert!(output.contains("my_crate::MyError::NotFound =>"));
        assert!(!output.contains("NotFound(..)"));
    }

    // -----------------------------------------------------------------------
    // WASM tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_gen_wasm_error_converter() {
        let error = sample_error();
        let output = gen_wasm_error_converter(&error, "sample_markdown_rs", &[]);
        // Main converter function signature
        assert!(output.contains(
            "fn conversion_error_to_js_value(e: sample_markdown_rs::ConversionError) -> wasm_bindgen::JsValue {"
        ));
        // Structured object with code + message
        assert!(output.contains("js_sys::Object::new()"));
        assert!(output.contains("js_sys::Reflect::set(&obj, &\"code\".into(), &code.into()).ok()"));
        assert!(output.contains("js_sys::Reflect::set(&obj, &\"message\".into(), &message.into()).ok()"));
        assert!(output.contains("obj.into()"));
        // error_code helper
        assert!(
            output
                .contains("fn conversion_error_error_code(e: &sample_markdown_rs::ConversionError) -> &'static str {")
        );
        assert!(output.contains("\"parse_error\""));
        assert!(output.contains("\"io_error\""));
        assert!(output.contains("\"other\""));
        assert!(output.contains("#[allow(dead_code)]"));
    }

    // -----------------------------------------------------------------------
    // PHP tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_gen_php_error_converter() {
        let error = sample_error();
        let output = gen_php_error_converter(&error, "sample_markdown_rs");
        assert!(output.contains("fn conversion_error_to_php_err(e: sample_markdown_rs::ConversionError) -> ext_php_rs::exception::PhpException {"));
        assert!(output.contains("PhpException::default(format!(\"[ParseError] {}\", msg))"));
        assert!(output.contains("#[allow(dead_code)]"));
    }

    // -----------------------------------------------------------------------
    // Magnus tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_gen_magnus_error_converter() {
        let error = sample_error();
        let output = gen_magnus_error_converter(&error, "sample_markdown_rs");
        assert!(
            output.contains(
                "fn conversion_error_to_magnus_err(e: sample_markdown_rs::ConversionError) -> magnus::Error {"
            )
        );
        assert!(
            output.contains(
                "magnus::Error::new(unsafe { magnus::Ruby::get_unchecked() }.exception_runtime_error(), msg)"
            )
        );
        assert!(output.contains("#[allow(dead_code)]"));
    }

    // -----------------------------------------------------------------------
    // Rustler tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_gen_rustler_error_converter() {
        let error = sample_error();
        let output = gen_rustler_error_converter(&error, "sample_markdown_rs");
        assert!(
            output.contains("fn conversion_error_to_rustler_err(e: sample_markdown_rs::ConversionError) -> String {")
        );
        assert!(output.contains("e.to_string()"));
        assert!(output.contains("#[allow(dead_code)]"));
    }

    // -----------------------------------------------------------------------
    // Go error struct with methods tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_gen_go_error_struct_with_methods() {
        let error = error_with_methods();
        let output = gen_go_error_struct(&error, "samplellm");
        // Stutter-stripped: "SampleLlm" prefix of "SampleLlmError" stripped for "samplellm" pkg
        assert!(output.contains("type Error struct {"), "struct def: {output}");
        // Fields are emitted directly on the struct — no accessor methods (avoids
        // field/method name collision that go vet rejects).
        assert!(output.contains("StatusCode uint16"), "StatusCode field: {output}");
        assert!(output.contains("IsTransient bool"), "IsTransient field: {output}");
        assert!(output.contains("ErrorType string"), "ErrorType field: {output}");
        // Accessor methods must NOT be emitted — the struct fields are the accessors.
        assert!(
            !output.contains("func (e Error) StatusCode()"),
            "no StatusCode accessor: {output}"
        );
        assert!(
            !output.contains("func (e Error) IsTransient()"),
            "no IsTransient accessor: {output}"
        );
        assert!(
            !output.contains("func (e Error) ErrorType()"),
            "no ErrorType accessor: {output}"
        );
    }

    #[test]
    fn test_gen_go_error_struct_no_field_method_collision() {
        // Any property whose PascalCase name would collide as both a struct field and
        // a method must produce only the field — go vet rejects the combination.
        use crate::core::ir::{ErrorDef, ErrorVariant, PrimitiveType, TypeRef};
        let error = ErrorDef {
            name: "ApiError".to_string(),
            rust_path: String::new(),
            original_rust_path: String::new(),
            doc: String::new(),
            variants: vec![ErrorVariant {
                name: "Network".to_string(),
                message_template: None,
                fields: vec![],
                has_source: false,
                has_from: false,
                is_unit: true,
                doc: String::new(),
            }],
            methods: vec![
                sample_method("retry_count", TypeRef::Primitive(PrimitiveType::U32)),
                sample_method("permanent", TypeRef::Primitive(PrimitiveType::Bool)),
            ],
            binding_excluded: false,
            binding_exclusion_reason: None,
        };
        let output = gen_go_error_struct(&error, "mypkg");
        // Fields must be present.
        assert!(output.contains("RetryCount uint32"), "RetryCount field: {output}");
        assert!(output.contains("Permanent bool"), "Permanent field: {output}");
        // Accessor methods must NOT be emitted — field name == method name would be
        // a go vet error.
        assert!(
            !output.contains("func (e ApiError) RetryCount()"),
            "no RetryCount accessor: {output}"
        );
        assert!(
            !output.contains("func (e ApiError) Permanent()"),
            "no Permanent accessor: {output}"
        );
    }

    #[test]
    fn test_gen_go_error_struct_no_methods() {
        let error = sample_error(); // methods: vec![]
        let output = gen_go_error_struct(&error, "mylib");
        assert!(output.contains("type ConversionError struct {"), "{output}");
        assert!(!output.contains("StatusCode"), "{output}");
        assert!(!output.contains("IsTransient"), "{output}");
    }

    // -----------------------------------------------------------------------
    // Java error types with methods tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_gen_java_error_types_with_methods() {
        let error = error_with_methods();
        let files = gen_java_error_types(&error, "dev.sample_crate.samplellm");
        assert_eq!(files.len(), 1); // base only, no variants
        let base = &files[0].1;
        assert!(
            base.contains("private final int statusCode;"),
            "statusCode field: {base}"
        );
        assert!(
            base.contains("private final boolean isTransient;"),
            "isTransient field: {base}"
        );
        assert!(
            base.contains("private final String errorType;"),
            "errorType field: {base}"
        );
        assert!(
            base.contains("public int getStatusCode()"),
            "getStatusCode getter: {base}"
        );
        assert!(
            base.contains("public boolean isTransient()"),
            "isTransient getter: {base}"
        );
        assert!(
            base.contains("public String getErrorType()"),
            "getErrorType getter: {base}"
        );
        // Simple no-args constructor still present
        assert!(
            base.contains("public SampleLlmErrorException(final String message)"),
            "simple ctor: {base}"
        );
        // Full constructor with introspection params
        assert!(
            base.contains("public SampleLlmErrorException(final String message, final int statusCode, final boolean isTransient, final String errorType)"),
            "full ctor: {base}"
        );
    }

    #[test]
    fn test_gen_java_error_types_no_methods() {
        let error = sample_error(); // methods: vec![]
        let files = gen_java_error_types(&error, "dev.sample_crate.test");
        let base = &files[0].1;
        assert!(!base.contains("private final"), "no fields when no methods: {base}");
        assert!(
            base.contains("public ConversionErrorException(final String message)"),
            "{base}"
        );
    }

    // -----------------------------------------------------------------------
    // C# error types with methods tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_gen_csharp_error_types_with_methods() {
        let error = error_with_methods();
        let files = gen_csharp_error_types(&error, "SampleCrate.SampleLlm", None);
        assert_eq!(files.len(), 1); // base only, no variants
        let base = &files[0].1;
        assert!(
            base.contains("public ushort StatusCode { get; }"),
            "StatusCode prop: {base}"
        );
        assert!(
            base.contains("public bool IsTransient { get; }"),
            "IsTransient prop: {base}"
        );
        assert!(
            base.contains("public string ErrorType { get; }"),
            "ErrorType prop: {base}"
        );
        // Simple constructor (with defaults)
        assert!(
            base.contains("public SampleLlmErrorException(string message) : base(message)"),
            "simple ctor: {base}"
        );
        // Full constructor
        assert!(
            base.contains("public SampleLlmErrorException(string message, ushort statusCode, bool isTransient, string errorType) : base(message)"),
            "full ctor: {base}"
        );
    }

    #[test]
    fn test_gen_csharp_error_types_no_methods() {
        let error = sample_error(); // methods: vec![]
        let files = gen_csharp_error_types(&error, "SampleCrate.Test", None);
        let base = &files[0].1;
        assert!(!base.contains("{ get; }"), "no properties when no methods: {base}");
        assert!(
            base.contains("public ConversionErrorException(string message) : base(message) { }"),
            "{base}"
        );
    }

    /// Regression: the GraphQLErrorException base doc previously leaked raw rustdoc
    /// (`# Examples` heading, ```ignore code fence containing `Self::error_code`,
    /// `Result<T, E>`, intra-doc links) into the `<summary>` element, causing
    /// CS1002/CS1519 Roslyn errors. The sanitizer must strip all of that.
    #[test]
    fn test_gen_csharp_error_types_strips_rust_idioms_in_doc() {
        let mut error = error_with_methods();
        error.name = "GraphQLError".to_string();
        error.doc = "Errors that can occur during GraphQL operations\n\n\
            These errors are compatible with async-graphql error handling.\n"
            .to_string();
        // Mirror the real `status_code()` rustdoc from sample_project-graphql: it has a
        // `# Examples` section with a ```ignore fence referencing `Self::error_code`,
        // `Result<T, E>`, intra-doc links, and a `::` path separator — everything
        // that previously leaked into a one-line `<summary>` attribute.
        error.methods[0].doc = "Convert error to HTTP status code\n\n\
            Public alias for the same codes returned by [`Self::error_code`].\n\n\
            # Examples\n\n\
            ```ignore\n\
            use sample_router_graphql::error::GraphQLError;\n\
            let error = GraphQLError::AuthenticationError(\"Invalid token\".to_string());\n\
            assert_eq!(error.status_code(), 401);\n\
            ```\n"
            .to_string();
        let files = gen_csharp_error_types(&error, "SampleRouter", None);
        let base = &files[0].1;
        // Per-method `<summary>` is single-line — must not contain raw fence markers,
        // intra-doc square brackets, `::`, or unescaped `<`/`>`.
        assert!(
            !base.contains("```"),
            "code fence markers must not leak into <summary>: {base}"
        );
        assert!(!base.contains("# Examples"), "section heading must be stripped: {base}");
        assert!(
            !base.contains("Self::error_code"),
            "Self::method must be normalised: {base}"
        );
        assert!(!base.contains("[`"), "intra-doc link brackets must be stripped: {base}");
        assert!(
            !base.contains("GraphQLError::AuthenticationError"),
            "rust path inside fence must be dropped: {base}"
        );
        // The first line of prose survives.
        assert!(
            base.contains("Convert error to HTTP status code"),
            "first prose line survives: {base}"
        );
        // The base error doc survives sanitised.
        assert!(
            base.contains("Errors that can occur during GraphQL operations"),
            "base error prose survives: {base}"
        );
    }

    // -----------------------------------------------------------------------
    // Helper tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_to_screaming_snake() {
        assert_eq!(to_screaming_snake("ConversionError"), "CONVERSION_ERROR");
        assert_eq!(to_screaming_snake("IoError"), "IO_ERROR");
        assert_eq!(to_screaming_snake("Other"), "OTHER");
    }

    #[test]
    fn test_strip_thiserror_placeholders_struct_field() {
        assert_eq!(strip_thiserror_placeholders("OCR error: {message}"), "OCR error");
        assert_eq!(
            strip_thiserror_placeholders("plugin error in '{plugin_name}': {message}"),
            "plugin error in"
        );
        // Multi-placeholder strings retain the surrounding prose verbatim
        // (minus the holes). Critical contract: no `{` / `}` survives.
        let result = strip_thiserror_placeholders("extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)");
        assert!(!result.contains('{'), "no braces: {result}");
        assert!(!result.contains('}'), "no braces: {result}");
        assert!(result.starts_with("extraction timed out after"), "{result}");
    }

    #[test]
    fn test_strip_thiserror_placeholders_positional() {
        assert_eq!(strip_thiserror_placeholders("I/O error: {0}"), "I/O error");
        assert_eq!(strip_thiserror_placeholders("Parse error: {0}"), "Parse error");
    }

    #[test]
    fn test_strip_thiserror_placeholders_no_placeholder() {
        assert_eq!(strip_thiserror_placeholders("not found"), "not found");
        assert_eq!(strip_thiserror_placeholders("lock poisoned"), "lock poisoned");
    }

    #[test]
    fn test_acronym_aware_snake_phrase_recognizes_acronyms() {
        assert_eq!(acronym_aware_snake_phrase("IoError"), "IO error");
        assert_eq!(acronym_aware_snake_phrase("OcrError"), "OCR error");
        assert_eq!(acronym_aware_snake_phrase("PdfParse"), "PDF parse");
        assert_eq!(acronym_aware_snake_phrase("HttpRequestFailed"), "HTTP request failed");
        assert_eq!(acronym_aware_snake_phrase("UrlInvalid"), "URL invalid");
    }

    #[test]
    fn test_acronym_aware_snake_phrase_plain_words() {
        assert_eq!(acronym_aware_snake_phrase("Other"), "other");
        assert_eq!(acronym_aware_snake_phrase("ParseError"), "parse error");
        assert_eq!(acronym_aware_snake_phrase("LockPoisoned"), "lock poisoned");
    }

    #[test]
    fn test_variant_display_message_acronym_first_word() {
        let variant = ErrorVariant {
            name: "Io".to_string(),
            message_template: Some("I/O error: {0}".to_string()),
            fields: vec![tuple_field(0)],
            has_source: false,
            has_from: false,
            is_unit: false,
            doc: String::new(),
        };
        // Template "I/O error: {0}" → strip → "I/O error" → first token "I/O" not an acronym (with `/`),
        // so falls back to lowercase first char → "i/O error". Acceptable: at least no `{0}` leak.
        let msg = variant_display_message(&variant);
        assert!(!msg.contains('{'), "no placeholders allowed: {msg}");
    }

    #[test]
    fn test_variant_display_message_no_template_uses_acronyms() {
        let variant = ErrorVariant {
            name: "IoError".to_string(),
            message_template: None,
            fields: vec![],
            has_source: false,
            has_from: false,
            is_unit: false,
            doc: String::new(),
        };
        assert_eq!(variant_display_message(&variant), "IO error");
    }

    #[test]
    fn test_variant_display_message_struct_template_no_leak() {
        let variant = ErrorVariant {
            name: "Ocr".to_string(),
            message_template: Some("OCR error: {message}".to_string()),
            fields: vec![named_field("message")],
            has_source: false,
            has_from: false,
            is_unit: false,
            doc: String::new(),
        };
        let msg = variant_display_message(&variant);
        assert_eq!(msg, "OCR error", "must not leak {{message}} placeholder: {msg}");
    }

    #[test]
    fn test_go_sentinels_no_placeholder_leak() {
        let error = ErrorDef {
            name: "SampleCrateError".to_string(),
            rust_path: "sample_crate::SampleCrateError".to_string(),
            original_rust_path: String::new(),
            variants: vec![
                ErrorVariant {
                    name: "Io".to_string(),
                    message_template: Some("IO error: {message}".to_string()),
                    fields: vec![named_field("message")],
                    has_source: false,
                    has_from: false,
                    is_unit: false,
                    doc: String::new(),
                },
                ErrorVariant {
                    name: "Ocr".to_string(),
                    message_template: Some("OCR error: {message}".to_string()),
                    fields: vec![named_field("message")],
                    has_source: false,
                    has_from: false,
                    is_unit: false,
                    doc: String::new(),
                },
                ErrorVariant {
                    name: "Timeout".to_string(),
                    message_template: Some(
                        "extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)".to_string(),
                    ),
                    fields: vec![named_field("elapsed_ms"), named_field("limit_ms")],
                    has_source: false,
                    has_from: false,
                    is_unit: false,
                    doc: String::new(),
                },
            ],
            doc: String::new(),
            methods: vec![],
            binding_excluded: false,
            binding_exclusion_reason: None,
        };
        let output = gen_go_sentinel_errors(std::slice::from_ref(&error));
        assert!(
            !output.contains('{'),
            "Go sentinels must not contain raw placeholders:\n{output}"
        );
        assert!(
            output.contains("ErrIo = errors.New(\"IO error\")"),
            "expected acronym-preserving Io sentinel, got:\n{output}"
        );
        assert!(
            output.contains("var (\n\t// ErrIo is returned when IO error.\n\tErrIo = errors.New(\"IO error\")\n"),
            "Go sentinel comments must be emitted on separate lines, got:\n{output}"
        );
        assert!(
            output.contains("ErrOcr = errors.New(\"OCR error\")"),
            "expected acronym-preserving Ocr sentinel, got:\n{output}"
        );
        assert!(
            output.contains("ErrTimeout = errors.New(\"extraction timed out after"),
            "expected timeout sentinel to start with the prose, got:\n{output}"
        );
    }

    // -----------------------------------------------------------------------
    // FFI (C) tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_gen_ffi_error_codes() {
        let error = sample_error();
        let output = gen_ffi_error_codes(&error);
        assert!(output.contains("CONVERSION_ERROR_NONE = 0"));
        assert!(output.contains("CONVERSION_ERROR_PARSE_ERROR = 1"));
        assert!(output.contains("CONVERSION_ERROR_IO_ERROR = 2"));
        assert!(output.contains("CONVERSION_ERROR_OTHER = 3"));
        assert!(output.contains("conversion_error_t;"));
        assert!(output.contains("conversion_error_error_message(conversion_error_t code)"));
    }

    // -----------------------------------------------------------------------
    // Go tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_gen_go_error_types() {
        let error = sample_error();
        // Package name that does NOT match the error prefix — type name stays unchanged.
        let output = gen_go_error_types(&error, "mylib");
        assert!(output.contains("ErrParseError = errors.New("));
        assert!(output.contains("ErrIoError = errors.New("));
        assert!(output.contains("ErrOther = errors.New("));
        assert!(output.contains("type ConversionError struct {"));
        assert!(output.contains("Code    string"));
        assert!(output.contains("func (e ConversionError) Error() string"));
        // Each sentinel error var should have a doc comment.
        assert!(output.contains("// ErrParseError is returned when"));
        assert!(output.contains("// ErrIoError is returned when"));
        assert!(output.contains("// ErrOther is returned when"));
    }

    #[test]
    fn test_gen_go_error_types_stutter_strip() {
        let error = sample_error();
        // "conversion" package — "ConversionError" starts with "conversion" (case-insensitive)
        // so the exported Go type should be "Error", not "ConversionError".
        let output = gen_go_error_types(&error, "conversion");
        assert!(
            output.contains("type Error struct {"),
            "expected stutter strip, got:\n{output}"
        );
        assert!(
            output.contains("func (e Error) Error() string"),
            "expected stutter strip, got:\n{output}"
        );
        // Sentinel vars are unaffected by stutter stripping.
        assert!(output.contains("ErrParseError = errors.New("));
    }

    // -----------------------------------------------------------------------
    // Java tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_gen_java_error_types() {
        let error = sample_error();
        let files = gen_java_error_types(&error, "dev.sample_crate.test");
        // base + 3 variants
        assert_eq!(files.len(), 4);
        // Base class
        assert_eq!(files[0].0, "ConversionErrorException");
        assert!(
            files[0]
                .1
                .contains("public class ConversionErrorException extends Exception")
        );
        assert!(files[0].1.contains("package dev.sample_crate.test;"));
        // Variant classes
        assert_eq!(files[1].0, "ParseErrorException");
        assert!(
            files[1]
                .1
                .contains("public class ParseErrorException extends ConversionErrorException")
        );
        assert_eq!(files[2].0, "IoErrorException");
        assert_eq!(files[3].0, "OtherException");
    }

    // -----------------------------------------------------------------------
    // C# tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_gen_csharp_error_types() {
        let error = sample_error();
        // Without fallback class: base inherits from Exception.
        let files = gen_csharp_error_types(&error, "SampleCrate.Test", None);
        assert_eq!(files.len(), 4);
        assert_eq!(files[0].0, "ConversionErrorException");
        assert!(files[0].1.contains("public class ConversionErrorException : Exception"));
        assert!(files[0].1.contains("namespace SampleCrate.Test;"));
        assert_eq!(files[1].0, "ParseErrorException");
        assert!(
            files[1]
                .1
                .contains("public class ParseErrorException : ConversionErrorException")
        );
        assert_eq!(files[2].0, "IoErrorException");
        assert_eq!(files[3].0, "OtherException");
    }

    #[test]
    fn test_gen_csharp_error_types_with_fallback() {
        let error = sample_error();
        // With fallback class: base inherits from the generic library exception.
        let files = gen_csharp_error_types(&error, "SampleCrate.Test", Some("TestLibException"));
        assert_eq!(files.len(), 4);
        assert!(
            files[0]
                .1
                .contains("public class ConversionErrorException : TestLibException")
        );
        // Variant classes still inherit from the base error class, not from the fallback directly.
        assert!(
            files[1]
                .1
                .contains("public class ParseErrorException : ConversionErrorException")
        );
    }

    // -----------------------------------------------------------------------
    // python_exception_name tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_python_exception_name_no_conflict() {
        // "ParseError" already ends with "Error" and is not a builtin
        assert_eq!(python_exception_name("ParseError", "ConversionError"), "ParseError");
        // "Other" gets "Error" suffix, "OtherError" is not a builtin
        assert_eq!(python_exception_name("Other", "ConversionError"), "OtherError");
    }

    #[test]
    fn test_python_exception_name_shadows_builtin() {
        // "Connection" -> "ConnectionError" shadows builtin -> prefix with "Crawl"
        assert_eq!(
            python_exception_name("Connection", "CrawlError"),
            "CrawlConnectionError"
        );
        // "Timeout" -> "TimeoutError" shadows builtin -> prefix with "Crawl"
        assert_eq!(python_exception_name("Timeout", "CrawlError"), "CrawlTimeoutError");
        // "ConnectionError" already ends with "Error", still shadows -> prefix
        assert_eq!(
            python_exception_name("ConnectionError", "CrawlError"),
            "CrawlConnectionError"
        );
    }

    #[test]
    fn test_python_exception_name_no_double_prefix() {
        // If variant is already prefixed with the error base, don't double-prefix
        assert_eq!(
            python_exception_name("CrawlConnectionError", "CrawlError"),
            "CrawlConnectionError"
        );
    }

    // -----------------------------------------------------------------------
    // WASM error methods tests
    // -----------------------------------------------------------------------

    fn sample_method(name: &str, return_type: TypeRef) -> crate::core::ir::MethodDef {
        crate::core::ir::MethodDef {
            name: name.to_string(),
            params: vec![],
            return_type,
            is_async: false,
            is_static: false,
            error_type: None,
            doc: String::new(),
            receiver: Some(crate::core::ir::ReceiverKind::Ref),
            sanitized: false,
            trait_source: None,
            returns_ref: false,
            returns_cow: false,
            return_newtype_wrapper: None,
            has_default_impl: false,
            binding_excluded: false,
            binding_exclusion_reason: None,
        }
    }

    fn error_with_methods() -> ErrorDef {
        ErrorDef {
            name: "SampleLlmError".to_string(),
            rust_path: "sample_llm::error::SampleLlmError".to_string(),
            original_rust_path: String::new(),
            variants: vec![],
            doc: String::new(),
            methods: vec![
                sample_method("status_code", TypeRef::Primitive(crate::core::ir::PrimitiveType::U16)),
                sample_method("is_transient", TypeRef::Primitive(crate::core::ir::PrimitiveType::Bool)),
                sample_method("error_type", TypeRef::String),
            ],
            binding_excluded: false,
            binding_exclusion_reason: None,
        }
    }

    #[test]
    fn test_gen_wasm_error_methods_empty_when_no_methods() {
        let error = sample_error(); // methods: vec![]
        let output = gen_wasm_error_methods(&error, "sample_markdown_rs", "");
        assert!(output.is_empty(), "should produce no output when methods is empty");
    }

    #[test]
    fn test_gen_wasm_error_methods_struct_and_impl() {
        let error = error_with_methods();
        // wasm_prefix is the full type prefix, e.g. "Wasm" — the struct name is
        // {wasm_prefix}{ErrorName} = "WasmSampleLlmError".
        let output = gen_wasm_error_methods(&error, "sample_llm", "Wasm");
        // Struct definition
        assert!(
            output.contains("pub struct WasmSampleLlmError"),
            "must emit opaque struct: {output}"
        );
        assert!(
            output.contains("pub(crate) inner: sample_llm::error::SampleLlmError"),
            "{output}"
        );
        // Impl block
        assert!(output.contains("#[wasm_bindgen]\nimpl WasmSampleLlmError"), "{output}");
        // Methods with camelCase js_name
        assert!(output.contains("js_name = \"statusCode\""), "{output}");
        assert!(output.contains("pub fn status_code(&self) -> u16"), "{output}");
        assert!(output.contains("self.inner.status_code()"), "{output}");
        assert!(output.contains("js_name = \"isTransient\""), "{output}");
        assert!(output.contains("pub fn is_transient(&self) -> bool"), "{output}");
        assert!(output.contains("self.inner.is_transient()"), "{output}");
        assert!(output.contains("js_name = \"errorType\""), "{output}");
        assert!(output.contains("pub fn error_type(&self) -> String"), "{output}");
        assert!(output.contains("self.inner.error_type().to_string()"), "{output}");
    }

    // -----------------------------------------------------------------------
    // FFI error methods tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_gen_ffi_error_methods_empty_when_no_methods() {
        let error = sample_error(); // methods: vec![]
        let output = gen_ffi_error_methods(&error, "sample_markdown_rs", "sample_markup");
        assert!(output.is_empty(), "should produce no output when methods is empty");
    }

    #[test]
    fn test_gen_ffi_error_methods_status_code() {
        let error = error_with_methods();
        let output = gen_ffi_error_methods(&error, "sample_llm", "samplellm");
        assert!(
            output.contains("pub unsafe extern \"C\" fn samplellm_sample_llm_error_status_code("),
            "must emit status_code fn: {output}"
        );
        assert!(
            output.contains("err: *const sample_llm::error::SampleLlmError"),
            "{output}"
        );
        assert!(output.contains("-> u16"), "{output}");
        assert!(output.contains("(*err).status_code()"), "{output}");
        assert!(output.contains("if err.is_null()"), "{output}");
        assert!(output.contains("return 0;"), "{output}");
    }

    #[test]
    fn test_gen_ffi_error_methods_is_transient() {
        let error = error_with_methods();
        let output = gen_ffi_error_methods(&error, "sample_llm", "samplellm");
        assert!(
            output.contains("pub unsafe extern \"C\" fn samplellm_sample_llm_error_is_transient("),
            "must emit is_transient fn: {output}"
        );
        assert!(output.contains("-> bool"), "{output}");
        assert!(output.contains("(*err).is_transient()"), "{output}");
        assert!(output.contains("return false;"), "{output}");
    }

    #[test]
    fn test_gen_ffi_error_methods_error_type_with_free() {
        let error = error_with_methods();
        let output = gen_ffi_error_methods(&error, "sample_llm", "samplellm");
        assert!(
            output.contains("pub unsafe extern \"C\" fn samplellm_sample_llm_error_error_type("),
            "must emit error_type fn: {output}"
        );
        assert!(output.contains("-> *mut std::ffi::c_char"), "{output}");
        assert!(output.contains("(*err).error_type()"), "{output}");
        assert!(output.contains("CString::new(s)"), "{output}");
        assert!(output.contains(".into_raw()"), "{output}");
        assert!(output.contains("return std::ptr::null_mut();"), "{output}");
        // free companion
        assert!(
            output.contains("pub unsafe extern \"C\" fn samplellm_sample_llm_error_error_type_free("),
            "must emit _free companion: {output}"
        );
        assert!(output.contains("drop(std::ffi::CString::from_raw(ptr))"), "{output}");
    }

    #[test]
    fn test_gen_ffi_error_methods_safety_comments() {
        let error = error_with_methods();
        let output = gen_ffi_error_methods(&error, "sample_llm", "samplellm");
        assert!(output.contains("// SAFETY:"), "must include SAFETY comments: {output}");
    }
}
