use crate::core::config::workspace::ClientConstructorConfig;
use crate::core::ir::{MethodDef, ParamDef, PrimitiveType, TypeDef, TypeRef};
use heck::AsSnakeCase;
use std::collections::{HashMap, HashSet};

use super::errors::resolve_zig_error_type;
use super::functions::{optional_int_sentinel, zig_return_type};
use super::helpers::emit_cleaned_zig_doc;

fn render(template_name: &str, ctx: minijinja::Value) -> String {
    crate::backends::zig::template_env::render(template_name, ctx)
}

/// Returns true if generating the param-conversion boilerplate for `p` will
/// emit a `try` expression (heap allocation for string duplication).
/// Builder setters that take string arguments call `dupeZ`, which is fallible,
/// so the enclosing method must declare an error-union return type.
fn method_param_needs_alloc(p: &ParamDef) -> bool {
    let inner = match &p.ty {
        TypeRef::Optional(t) => t.as_ref(),
        other => other,
    };
    matches!(
        inner,
        TypeRef::String | TypeRef::Path | TypeRef::Vec(_) | TypeRef::Map(_, _) | TypeRef::Named(_)
    )
}

fn method_param_needs_from_json(p: &ParamDef, struct_names: &HashSet<String>) -> bool {
    match &p.ty {
        TypeRef::Named(n) if struct_names.contains(n) => true,
        TypeRef::Named(n) if p.optional && struct_names.contains(n) => true,
        TypeRef::Optional(inner) => matches!(inner.as_ref(), TypeRef::Named(n) if struct_names.contains(n)),
        _ => false,
    }
}

/// Map a Rust FFI type string to the corresponding Zig type.
///
/// Only the types actually used in `client_constructors` configs are handled here.
fn ffi_ty_to_zig(rust_ty: &str) -> &'static str {
    let normalized = rust_ty.trim();
    if normalized.contains("c_char") || normalized.contains("CStr") {
        return "[]const u8";
    }
    if matches!(normalized, "u8" | "uint8_t") {
        return "u8";
    }
    if matches!(normalized, "u16" | "uint16_t") {
        return "u16";
    }
    if matches!(normalized, "u32" | "uint32_t") {
        return "u32";
    }
    if matches!(normalized, "u64" | "uint64_t" | "usize") {
        return "u64";
    }
    if matches!(normalized, "i8" | "int8_t") {
        return "i8";
    }
    if matches!(normalized, "i16" | "int16_t") {
        return "i16";
    }
    if matches!(normalized, "i32" | "int32_t" | "c_int") {
        return "i32";
    }
    if matches!(normalized, "i64" | "int64_t" | "isize") {
        return "i64";
    }
    if matches!(normalized, "bool") {
        return "bool";
    }
    if matches!(normalized, "f32" | "float") {
        return "f32";
    }
    if matches!(normalized, "f64" | "double") {
        return "f64";
    }
    "*anyopaque"
}

/// Returns true if a Rust FFI type is a string/CStr pointer that needs
/// `dupeZ` conversion before passing to the C function.
fn ffi_ty_needs_dupez(rust_ty: &str) -> bool {
    let normalized = rust_ty.trim();
    normalized.contains("c_char") || normalized.contains("CStr")
}

/// Emit a top-level `pub fn create_<type_snake>(allocator, params...) !TypeName`
/// constructor that wraps the `c.{prefix}_{type_snake}_new(...)` FFI symbol.
pub(crate) fn emit_opaque_constructor(
    ty: &TypeDef,
    prefix: &str,
    ctor: &ClientConstructorConfig,
    top_level_names: &std::collections::HashSet<String>,
    out: &mut String,
) {
    let type_snake = AsSnakeCase(&ty.name).to_string();
    let upper_prefix = prefix.to_uppercase();
    let has_string_param = ctor.params.iter().any(|p| ffi_ty_needs_dupez(&p.ty));

    out.push_str(&render(
        "opaque_constructor_doc.jinja",
        minijinja::context! {
            type_name => &ty.name,
        },
    ));

    // Signature: allocator is only needed when string params require dupeZ.
    let alloc_param = if has_string_param {
        "allocator: std.mem.Allocator, "
    } else {
        ""
    };

    // Rename param names that would shadow a top-level decl (Zig 0.16+ rejects
    // shadowing of file-scope identifiers by function parameters).
    let renamed_params: Vec<String> = ctor
        .params
        .iter()
        .map(|p| {
            if top_level_names.contains(&p.name) {
                format!("{}_arg", p.name)
            } else {
                p.name.clone()
            }
        })
        .collect();

    // Build param list using renamed names.
    let params_str: String = renamed_params
        .iter()
        .zip(ctor.params.iter())
        .map(|(renamed_name, p)| format!("{}: {}", renamed_name, ffi_ty_to_zig(&p.ty)))
        .collect::<Vec<_>>()
        .join(", ");
    out.push_str(&render(
        "opaque_constructor_signature.jinja",
        minijinja::context! {
            type_snake => &type_snake,
            alloc_param => alloc_param,
            params => &params_str,
            type_name => &ty.name,
        },
    ));

    // Emit allocator-based dupeZ for string params, using renamed names.
    for (renamed_name, p) in renamed_params.iter().zip(ctor.params.iter()) {
        if ffi_ty_needs_dupez(&p.ty) {
            let c_name = format!("{}_z", p.name);
            out.push_str(&render(
                "opaque_constructor_string_param.jinja",
                minijinja::context! {
                    c_name => &c_name,
                    param_name => renamed_name,
                },
            ));
        }
    }

    // Build the C argument list, using renamed names where needed.
    let c_args: String = renamed_params
        .iter()
        .zip(ctor.params.iter())
        .map(|(renamed_name, p)| {
            if ffi_ty_needs_dupez(&p.ty) {
                format!("{}_z.ptr", p.name)
            } else {
                renamed_name.clone()
            }
        })
        .collect::<Vec<_>>()
        .join(", ");

    out.push_str(&render(
        "opaque_constructor_body.jinja",
        minijinja::context! {
            prefix => prefix,
            type_snake => &type_snake,
            c_args => &c_args,
            upper_prefix => &upper_prefix,
            type_name => &ty.name,
        },
    ));
}

/// Emit a Zig struct wrapper for an opaque handle type (one with `is_opaque = true`
/// or `has_serde = false`) that has instance methods.
///
/// The emitted struct stores a `*anyopaque` handle obtained from the C FFI and
/// exposes each non-static, non-excluded method as a Zig function that dispatches
/// via `c.{prefix}_{snake_type}_{snake_method}(self._handle, ...)`.
///
/// Static methods are emitted as top-level Zig functions that call the FFI constructor,
/// e.g., `pub fn init(method: Method, path: []const u8) {TypeName}`.
pub(crate) fn emit_opaque_handle(
    ty: &TypeDef,
    prefix: &str,
    declared_errors: &[String],
    struct_names: &std::collections::HashSet<String>,
    streaming_item_types: &HashMap<String, String>,
    enum_names: &std::collections::HashSet<String>,
    out: &mut String,
) {
    // First, emit streaming struct types for any streaming methods on this type.
    // These must be declared before the opaque handle type that returns them.
    // Track emitted struct names to avoid duplicates when multiple methods return
    // the same stream type (e.g., crawl_stream and batch_crawl_stream both return CrawlEventStream).
    let type_snake = AsSnakeCase(&ty.name).to_string();
    let mut emitted_stream_structs: HashSet<String> = HashSet::new();
    for method in ty.methods.iter().filter(|m| !m.is_static) {
        if let Some(item_type) = streaming_item_types.get(&method.name) {
            let struct_name = format!("{}Stream", item_type);
            if !emitted_stream_structs.contains(&struct_name) {
                emit_streaming_struct(method, ty, prefix, &type_snake, item_type, declared_errors, out);
                out.push('\n');
                emitted_stream_structs.insert(struct_name);
            }
        }
    }

    // Emit static methods (constructors) at the top level, before the struct definition.
    for method in ty.methods.iter().filter(|m| m.is_static) {
        emit_opaque_static_method(method, ty, prefix, declared_errors, struct_names, enum_names, out);
        out.push('\n');
    }

    emit_cleaned_zig_doc(out, &ty.doc, "");
    out.push_str(&render(
        "opaque_handle_header.jinja",
        minijinja::context! {
            type_name => &ty.name,
        },
    ));
    out.push('\n');

    for method in ty.methods.iter().filter(|m| !m.is_static) {
        emit_opaque_method(
            method,
            ty,
            prefix,
            &type_snake,
            declared_errors,
            struct_names,
            streaming_item_types,
            enum_names,
            out,
        );
        out.push('\n');
    }

    // Synthetic destructor: every opaque-handle type owns a heap allocation in
    // the FFI and must be released via the matching `{prefix}_{snake}_free`
    // C symbol. Emit a `free()` method that performs that release.
    emit_opaque_free(ty, prefix, &type_snake, out);

    out.push_str("};\n");
}

/// Emit a static method (constructor) on an opaque handle type.
///
/// The FFI backend emits static constructors like `{prefix}_route_builder_new(method: i32, path: *const c_char)`
/// where enum parameters are passed as i32 discriminants. This function emits the Zig wrapper as a top-level
/// function that marshals enum parameters to i32 using `@intFromEnum()` and calls the C FFI symbol.
fn emit_opaque_static_method(
    method: &MethodDef,
    ty: &TypeDef,
    prefix: &str,
    _declared_errors: &[String],
    struct_names: &std::collections::HashSet<String>,
    enum_names: &std::collections::HashSet<String>,
    out: &mut String,
) {
    emit_cleaned_zig_doc(out, &method.doc, "");

    let method_snake = AsSnakeCase(&method.name).to_string();
    let type_snake = AsSnakeCase(&ty.name).to_string();
    let upper_prefix = prefix.to_uppercase();

    // Build parameter list with Zig-idiomatic types (not raw C types).
    let mut param_parts: Vec<String> = Vec::new();
    for p in &method.params {
        let ty_str = param_zig_type_with_enums(&p.ty, p.optional, struct_names, enum_names);
        param_parts.push(format!("{}: {}", p.name, ty_str));
    }
    let params_str = param_parts.join(", ");

    // Check if any parameter needs heap allocation (try expression).
    let body_needs_try = method.params.iter().any(|p| {
        matches!(
            &p.ty,
            TypeRef::String | TypeRef::Path | TypeRef::Vec(_) | TypeRef::Map(_, _) | TypeRef::Named(_)
        )
    });
    let body_needs_invalid_json = method
        .params
        .iter()
        .any(|p| method_param_needs_from_json(p, struct_names));

    // Static constructors return the opaque type; if body uses try, wrap in error union.
    let return_ty = if body_needs_try || body_needs_invalid_json {
        let err_set = if body_needs_invalid_json {
            "error{OutOfMemory,InvalidJson}"
        } else {
            "error{OutOfMemory}"
        };
        format!("{err_set}!{}", ty.name)
    } else {
        ty.name.clone()
    };

    out.push_str(&render(
        "opaque_static_signature.jinja",
        minijinja::context! {
            method_snake => &method_snake,
            type_snake => &type_snake,
            params => &params_str,
            return_ty => &return_ty,
        },
    ));

    // Emit param conversions (string dupeZ, enum intFromEnum, struct JSON handles).
    for p in &method.params {
        emit_static_method_param_conversion(p, prefix, struct_names, enum_names, out);
    }

    // Build C argument list: converted params.
    let mut c_args: Vec<String> = Vec::new();
    for p in &method.params {
        c_args.extend(static_method_c_arg_names(p, struct_names, enum_names));
    }
    let c_call = format!(
        "c.{prefix}_{type_snake}_{method_snake}({args})",
        args = c_args.join(", ")
    );

    out.push_str(&render(
        "opaque_static_body.jinja",
        minijinja::context! {
            c_call => &c_call,
            upper_prefix => &upper_prefix,
            type_name => &ty.name,
        },
    ));
}

/// Zig type for a method parameter, including enum marshalling (for static methods).
fn param_zig_type_with_enums(
    ty: &TypeRef,
    optional: bool,
    struct_names: &std::collections::HashSet<String>,
    enum_names: &std::collections::HashSet<String>,
) -> String {
    let inner = match ty {
        TypeRef::Named(name) if enum_names.contains(name) => {
            // Enums are passed as the enum type, not as i32
            name.clone()
        }
        TypeRef::String | TypeRef::Path | TypeRef::Bytes | TypeRef::Vec(_) | TypeRef::Map(_, _) => {
            "[]const u8".to_string()
        }
        TypeRef::Named(name) if struct_names.contains(name) => "[]const u8".to_string(),
        TypeRef::Optional(inner) => {
            let inner_str = param_zig_type_with_enums(inner, false, struct_names, enum_names);
            return format!("?{inner_str}");
        }
        other => super::types::zig_field_type(other, false),
    };
    if optional { format!("?{inner}") } else { inner }
}

/// Emit allocation/conversion lines for a static method parameter before the C call.
fn emit_static_method_param_conversion(
    p: &ParamDef,
    prefix: &str,
    struct_names: &std::collections::HashSet<String>,
    enum_names: &std::collections::HashSet<String>,
    out: &mut String,
) {
    let name = &p.name;

    // Enums: convert to i32 using @intFromEnum
    if let TypeRef::Named(type_name) = &p.ty {
        if enum_names.contains(type_name) {
            out.push_str(&render(
                "opaque_param_enum_i32.jinja",
                minijinja::context! {
                    indent => "    ",
                    name => name,
                },
            ));
            return;
        }
    }

    // String/Path: dupeZ to NUL-terminated pointer
    if matches!(&p.ty, TypeRef::String | TypeRef::Path) {
        out.push_str(&render(
            "opaque_param_dupez.jinja",
            minijinja::context! {
                indent => "    ",
                name => name,
            },
        ));
        return;
    }

    // Optional String/Path
    if p.optional
        && matches!(
            &p.ty,
            TypeRef::Optional(inner)
                if matches!(inner.as_ref(), TypeRef::String | TypeRef::Path)
        )
    {
        out.push_str(&render(
            "opaque_param_optional_dupez.jinja",
            minijinja::context! {
                indent => "    ",
                name => name,
                capture => "s",
            },
        ));
        return;
    }

    // Named struct: JSON serialize
    if let TypeRef::Named(n) = &p.ty {
        if struct_names.contains(n) {
            let snake = AsSnakeCase(n).to_string();
            out.push_str(&render(
                "opaque_param_named_from_json.jinja",
                minijinja::context! {
                    indent => "    ",
                    name => name,
                    prefix => prefix,
                    snake => &snake,
                    json_error_return => "return error.InvalidJson;",
                },
            ));
            return;
        }
    }

    // Optional Named struct
    if let TypeRef::Optional(inner) = &p.ty {
        if let TypeRef::Named(n) = inner.as_ref() {
            if struct_names.contains(n) {
                let snake = AsSnakeCase(n).to_string();
                out.push_str(&render(
                    "opaque_param_optional_named_from_json.jinja",
                    minijinja::context! {
                        indent => "    ",
                        name => name,
                        prefix => prefix,
                        snake => &snake,
                        json_error_return => "return error.InvalidJson;",
                    },
                ));
            }
        }
    }
}

/// Build the C argument name(s) for a static method parameter.
fn static_method_c_arg_names(
    p: &ParamDef,
    struct_names: &std::collections::HashSet<String>,
    enum_names: &std::collections::HashSet<String>,
) -> Vec<String> {
    // Enums: pass the i32 discriminant
    if let TypeRef::Named(type_name) = &p.ty {
        if enum_names.contains(type_name) {
            return vec![format!("{}_i32", p.name)];
        }
    }

    // Optional Named struct: pass the conditional handle
    let optional_named: Option<&str> = match &p.ty {
        TypeRef::Optional(inner) => match inner.as_ref() {
            TypeRef::Named(n) if struct_names.contains(n) => Some(n.as_str()),
            _ => None,
        },
        TypeRef::Named(n) if p.optional && struct_names.contains(n) => Some(n.as_str()),
        _ => None,
    };
    if optional_named.is_some() {
        return vec![format!("{}_handle", p.name)];
    }

    // Named struct: pass the handle
    if let TypeRef::Named(n) = &p.ty {
        if struct_names.contains(n.as_str()) {
            return vec![format!("{}_handle", p.name)];
        }
    }

    // Optional String/Path: pass conditional pointer
    if p.optional
        && matches!(
            &p.ty,
            TypeRef::Optional(inner)
                if matches!(inner.as_ref(), TypeRef::String | TypeRef::Path)
        )
    {
        return vec![format!("if ({0}_z) |z| z.ptr else null", p.name)];
    }

    // String/Path/Vec/Map: pass the NUL-terminated pointer
    if matches!(
        &p.ty,
        TypeRef::String | TypeRef::Path | TypeRef::Vec(_) | TypeRef::Map(_, _)
    ) {
        return vec![format!("{}_z.ptr", p.name)];
    }

    // Bytes: pass pointer + length
    if matches!(p.ty, TypeRef::Bytes) {
        return vec![format!("{}.ptr", p.name), format!("{}.len", p.name)];
    }

    // Default: pass the parameter directly
    vec![p.name.clone()]
}

/// Emit a `free()` method that releases the underlying FFI handle by calling
/// `c.{prefix}_{snake_type}_free(self._handle)`. The C destructor is generated
/// by the FFI crate for every opaque handle type.
fn emit_opaque_free(ty: &TypeDef, prefix: &str, type_snake: &str, out: &mut String) {
    let upper_prefix = prefix.to_uppercase();
    out.push_str(&render(
        "opaque_free_method.jinja",
        minijinja::context! {
            type_name => &ty.name,
            prefix => prefix,
            type_snake => type_snake,
            upper_prefix => &upper_prefix,
        },
    ));
}

/// Emit a Zig struct type for a streaming iterator.
///
/// The struct holds a stream handle and provides `next()` and `deinit()` methods
/// to incrementally consume chunks without eagerly collecting them all into memory.
fn emit_streaming_struct(
    method: &MethodDef,
    _ty: &TypeDef,
    prefix: &str,
    type_snake: &str,
    item_type: &str,
    declared_errors: &[String],
    out: &mut String,
) {
    let method_snake = AsSnakeCase(&method.name).to_string();
    let item_snake = AsSnakeCase(item_type).to_string();
    let upper_prefix = prefix.to_uppercase();

    // Struct name: `CrawlEventStream` (ItemType + "Stream")
    let struct_name = format!("{}Stream", item_type);

    // Error type for the stream's next() method
    let zig_error_type = method
        .error_type
        .as_ref()
        .map(|e| resolve_zig_error_type(e, declared_errors))
        .unwrap_or_else(|| "anyerror".to_string());

    out.push_str(&render(
        "opaque_stream_struct.jinja",
        minijinja::context! {
            item_type => item_type,
            struct_name => &struct_name,
            upper_prefix => &upper_prefix,
            zig_error_type => &zig_error_type,
            prefix => prefix,
            type_snake => type_snake,
            method_snake => &method_snake,
            item_snake => &item_snake,
        },
    ));
}

/// Emit a streaming method on an opaque handle wrapper struct.
///
/// Streaming methods use the iterator-handle pattern (`_start` / `_next` / `_free`)
/// and return a struct type that provides `next()` and `deinit()` methods for
/// incremental, backpressure-aware consumption. Callers can cancel by dropping
/// the struct early without draining the entire stream.
fn emit_opaque_streaming_method(
    method: &MethodDef,
    ty: &TypeDef,
    prefix: &str,
    type_snake: &str,
    item_type: &str,
    declared_errors: &[String],
    out: &mut String,
) {
    emit_cleaned_zig_doc(out, &method.doc, "    ");

    let method_snake = AsSnakeCase(&method.name).to_string();
    let struct_name = format!("{}Stream", item_type);
    let upper_prefix = prefix.to_uppercase();

    // Streaming methods take a single JSON request parameter.
    // The error type comes from the method's error_type annotation.
    let zig_error_type = method
        .error_type
        .as_ref()
        .map(|e| resolve_zig_error_type(e, declared_errors))
        .unwrap_or_else(|| "anyerror".to_string());

    // The Zig wrapper signature: self + one JSON request slice → struct
    let req_param = method.params.first().map(|p| p.name.as_str()).unwrap_or("req");

    // Build the request handle.
    let req_param_lower = req_param.to_lowercase();
    // Derive the request type from the first param's type.
    let req_type_snake = if let Some(p) = method.params.first() {
        if let TypeRef::Named(n) = &p.ty {
            AsSnakeCase(n).to_string()
        } else {
            "chat_completion_request".to_string()
        }
    } else {
        "chat_completion_request".to_string()
    };

    // Start the stream.
    let c_handle_cast = format!(
        "@as(*c.{upper_prefix}{type_name}, @ptrCast(self._handle))",
        type_name = ty.name
    );
    out.push_str(&render(
        "opaque_stream_method.jinja",
        minijinja::context! {
            method_name => &method.name,
            type_name => &ty.name,
            req_param => req_param,
            zig_error_type => &zig_error_type,
            struct_name => &struct_name,
            req_param_lower => &req_param_lower,
            prefix => prefix,
            req_type_snake => &req_type_snake,
            type_snake => type_snake,
            method_snake => &method_snake,
            c_handle_cast => &c_handle_cast,
        },
    ));
}

/// Emit a single method on an opaque handle wrapper struct.
#[allow(clippy::too_many_arguments)]
fn emit_opaque_method(
    method: &MethodDef,
    ty: &TypeDef,
    prefix: &str,
    type_snake: &str,
    declared_errors: &[String],
    struct_names: &std::collections::HashSet<String>,
    streaming_item_types: &HashMap<String, String>,
    enum_names: &std::collections::HashSet<String>,
    out: &mut String,
) {
    // Note: async Rust methods are exposed as synchronous C functions via
    // `tokio::runtime::block_on` in the FFI layer. The zig backend calls
    // the synchronous C symbol directly — `is_async` is intentionally not
    // checked here to avoid skipping callable methods.

    // Streaming methods use the iterator-handle pattern (_start/_next/_free)
    // rather than the callback-based C symbol. Detect them early and delegate.
    if let Some(item_type) = streaming_item_types.get(&method.name) {
        emit_opaque_streaming_method(method, ty, prefix, type_snake, item_type, declared_errors, out);
        return;
    }

    emit_cleaned_zig_doc(out, &method.doc, "    ");

    let method_snake = AsSnakeCase(&method.name).to_string();

    // Z2 fix: Zig 0.16+ forbids a function parameter from having the same name
    // as the enclosing function. Builder setters like `pub fn visitor(..., visitor: ...)` hit
    // this. Rename the offending parameter to `value` so the declaration is unambiguous.
    // The rename is applied to a local clone so the rest of the emit logic is unaffected.
    let renamed_params: Vec<ParamDef> = method
        .params
        .iter()
        .map(|p| {
            if p.name == method.name {
                let mut p2 = p.clone();
                p2.name = "value".to_string();
                p2
            } else {
                p.clone()
            }
        })
        .collect();
    let effective_params: &[ParamDef] = &renamed_params;

    // Build parameter list: `self: *{TypeName}` followed by method params.
    // All struct-typed (non-opaque) params become `[]const u8` (JSON).
    // String/Path params become `[]const u8`.
    // Enum params become their enum type.
    // Optional variants add `?` prefix.
    let mut param_parts: Vec<String> = Vec::new();
    param_parts.push(format!("self: *{}", ty.name));
    for p in effective_params {
        let ty_str = param_zig_type_instance(&p.ty, p.optional, struct_names, enum_names);
        param_parts.push(format!("{}: {}", p.name, ty_str));
    }
    let params_str = param_parts.join(", ");

    let zig_error_type = method
        .error_type
        .as_ref()
        .map(|e| resolve_zig_error_type(e, declared_errors));

    // Z4 fix: when any parameter requires a heap allocation (String/Path/Vec/Map/Named),
    // the body will emit `try dupeZ(...)`. Zig requires the function to return an error
    // union for `try` to be legal. Wrap the return type in `error{OutOfMemory}!T` when
    // no explicit error type is set but the body uses allocation.
    let body_needs_try = effective_params.iter().any(method_param_needs_alloc)
        || matches!(
            &method.return_type,
            TypeRef::String | TypeRef::Path | TypeRef::Json | TypeRef::Bytes | TypeRef::Vec(_) | TypeRef::Map(_, _)
        )
        || matches!(&method.return_type, TypeRef::Named(name) if struct_names.contains(name));
    let body_needs_invalid_json = effective_params
        .iter()
        .any(|p| method_param_needs_from_json(p, struct_names));

    let ret_ty_inner = zig_return_type(&method.return_type, struct_names);
    let return_ty = if let Some(ref err_ty) = zig_error_type {
        format!("({}||error{{OutOfMemory}})!{}", err_ty, ret_ty_inner)
    } else if body_needs_try || body_needs_invalid_json {
        let err_set = if body_needs_invalid_json {
            "error{OutOfMemory,InvalidJson}"
        } else {
            "error{OutOfMemory}"
        };
        format!("{err_set}!{}", ret_ty_inner)
    } else {
        ret_ty_inner
    };

    out.push_str(&render(
        "opaque_method_signature.jinja",
        minijinja::context! {
            method_name => &method.name,
            params => &params_str,
            return_ty => &return_ty,
        },
    ));

    // Emit param conversions (string alloc, struct JSON handle creation, enum conversion).
    let json_error_return = zig_error_type
        .as_ref()
        .map_or("return error.InvalidJson;".to_string(), |err| {
            format!("return _first_error({err});")
        });
    for p in effective_params {
        emit_method_param_conversion(p, prefix, struct_names, enum_names, &json_error_return, out);
    }

    // Detect Bytes return: the C FFI uses a multi-out-parameter convention
    // (`uint8_t **out_ptr, uintptr_t *out_len, uintptr_t *out_cap`) and
    // returns `int32_t` status. Caller passes pointers to local storage and
    // reads the buffer back after the call.
    let returns_bytes = matches!(method.return_type, TypeRef::Bytes);
    if returns_bytes {
        out.push_str(&render("opaque_bytes_out_vars.jinja", minijinja::context! {}));
    }

    // Build C argument list: handle pointer, then converted params, then
    // (for Bytes returns) the three out-param pointers.
    let upper_prefix = prefix.to_uppercase();
    let c_handle = format!(
        "@as(*c.{upper_prefix}{type_name}, @ptrCast(self._handle))",
        type_name = ty.name,
    );
    let mut c_args: Vec<String> = vec![c_handle];
    for p in effective_params {
        c_args.extend(method_c_arg_names(p, struct_names, enum_names));
    }
    if returns_bytes {
        c_args.push("&_out_ptr".to_string());
        c_args.push("&_out_len".to_string());
        c_args.push("&_out_cap".to_string());
    }
    let c_call = format!(
        "c.{prefix}_{type_snake}_{method_snake}({args})",
        args = c_args.join(", ")
    );

    if let Some(ref err_ty) = zig_error_type {
        // For Unit/Bytes returns there is no `_result` pointer to inspect, so we
        // consult `{prefix}_last_error_code()`. For pointer-returning methods we
        // gate on `_result == null`; the thread-local `last_error_code` may be
        // stale from an earlier failed call that the host never cleared, so
        // checking it on a successful (non-null) result trips false positives.
        let result_is_pointer = !(matches!(method.return_type, TypeRef::Unit) || returns_bytes);
        if !result_is_pointer {
            // Discard status / unit return — error state is queried via
            // `{prefix}_last_error_code()`.
            out.push_str(&render(
                "opaque_method_call_discard.jinja",
                minijinja::context! {
                    c_call => &c_call,
                },
            ));
        } else {
            out.push_str(&render(
                "opaque_method_call_result.jinja",
                minijinja::context! {
                    c_call => &c_call,
                },
            ));
        }
        if result_is_pointer {
            out.push_str("        if (_result == null) {\n");
            out.push_str(&format!("            return _first_error({err_ty});\n"));
            out.push_str("        }\n");
        } else {
            out.push_str(&render(
                "opaque_method_error_check.jinja",
                minijinja::context! {
                    prefix => prefix,
                    error_type => err_ty,
                },
            ));
        }

        // Free params after error check.
        for p in effective_params {
            emit_method_param_free(p, prefix, struct_names, out);
        }

        if returns_bytes {
            // Copy the FFI-owned buffer into a Zig-owned heap allocation, then
            // release the FFI buffer via `{prefix}_free_bytes`.
            out.push_str(&render(
                "opaque_bytes_return.jinja",
                minijinja::context! {
                    prefix => prefix,
                },
            ));
        } else if !matches!(method.return_type, TypeRef::Unit) {
            let ret_expr = method_unwrap_return_expr("_result", &method.return_type, prefix, struct_names);
            out.push_str(&render(
                "opaque_method_return.jinja",
                minijinja::context! {
                    ret_expr => &ret_expr,
                },
            ));
        }
    } else {
        // Infallible method (or method using only error{OutOfMemory} from alloc).
        for p in effective_params {
            emit_method_param_free(p, prefix, struct_names, out);
        }
        if returns_bytes {
            out.push_str(&render(
                "opaque_method_call_discard.jinja",
                minijinja::context! {
                    c_call => &c_call,
                },
            ));
            out.push_str(&render(
                "opaque_bytes_return.jinja",
                minijinja::context! {
                    prefix => prefix,
                },
            ));
        } else if matches!(method.return_type, TypeRef::Unit) {
            out.push_str(&render(
                "opaque_method_unit_call.jinja",
                minijinja::context! {
                    c_call => &c_call,
                },
            ));
        } else {
            out.push_str(&render(
                "opaque_method_call_result.jinja",
                minijinja::context! {
                    c_call => &c_call,
                },
            ));
            let ret_expr = method_unwrap_return_expr("_result", &method.return_type, prefix, struct_names);
            out.push_str(&render(
                "opaque_method_return.jinja",
                minijinja::context! {
                    ret_expr => &ret_expr,
                },
            ));
        }
    }

    out.push_str("    }\n");
}

/// Zig type for a method parameter (same rules as function params).
/// Zig type for a method parameter, including enum marshalling (for instance methods).
/// Note: Instance methods can have enum parameters; enums are passed as their enum type.
fn param_zig_type_instance(
    ty: &TypeRef,
    optional: bool,
    struct_names: &std::collections::HashSet<String>,
    enum_names: &std::collections::HashSet<String>,
) -> String {
    let inner = match ty {
        TypeRef::Named(name) if enum_names.contains(name) => {
            // Enums are passed as the enum type
            name.clone()
        }
        TypeRef::String | TypeRef::Path | TypeRef::Bytes | TypeRef::Vec(_) | TypeRef::Map(_, _) => {
            "[]const u8".to_string()
        }
        TypeRef::Named(name) if struct_names.contains(name) => "[]const u8".to_string(),
        TypeRef::Optional(inner) => {
            let inner_str = param_zig_type_instance(inner, false, struct_names, enum_names);
            return format!("?{inner_str}");
        }
        other => super::types::zig_field_type(other, false),
    };
    if optional { format!("?{inner}") } else { inner }
}

/// Emit allocation/conversion lines for a method parameter before the C call.
fn emit_method_param_conversion(
    p: &crate::core::ir::ParamDef,
    prefix: &str,
    struct_names: &std::collections::HashSet<String>,
    enum_names: &std::collections::HashSet<String>,
    json_error_return: &str,
    out: &mut String,
) {
    let name = &p.name;

    // Enums: convert to i32 using @intFromEnum
    if let TypeRef::Named(type_name) = &p.ty {
        if enum_names.contains(type_name) {
            out.push_str(&render(
                "opaque_param_enum_i32.jinja",
                minijinja::context! {
                    indent => "        ",
                    name => name,
                },
            ));
            return;
        }
    }

    let is_optional_string = p.optional
        || matches!(
            &p.ty,
            TypeRef::Optional(inner)
                if matches!(inner.as_ref(), TypeRef::String | TypeRef::Path)
        );

    // Optional string: conditional allocPrintSentinel.
    if is_optional_string
        && matches!(
            match &p.ty {
                TypeRef::Optional(i) => i.as_ref(),
                other => other,
            },
            TypeRef::String | TypeRef::Path
        )
    {
        out.push_str(&render(
            "opaque_param_optional_dupez.jinja",
            minijinja::context! {
                indent => "        ",
                name => name,
                capture => "s",
            },
        ));
        return;
    }

    // `Option<NamedStruct>` may surface as `TypeRef::Optional(Named(_))` OR
    // as `TypeRef::Named(_)` with `p.optional = true` depending on how the
    // IR builder normalised the Rust source. Handle both shapes uniformly
    // before falling through to the non-optional path.
    let optional_named: Option<&str> = match &p.ty {
        TypeRef::Optional(inner) => match inner.as_ref() {
            TypeRef::Named(n) if struct_names.contains(n) => Some(n.as_str()),
            _ => None,
        },
        TypeRef::Named(n) if p.optional && struct_names.contains(n) => Some(n.as_str()),
        _ => None,
    };
    if let Some(n) = optional_named {
        let snake = AsSnakeCase(n).to_string();
        out.push_str(&render(
            "opaque_param_optional_named_from_json.jinja",
            minijinja::context! {
                indent => "        ",
                name => name,
                prefix => prefix,
                snake => &snake,
                json_error_return => json_error_return,
            },
        ));
        return;
    }

    match &p.ty {
        TypeRef::String | TypeRef::Path => {
            out.push_str(&render(
                "opaque_param_dupez.jinja",
                minijinja::context! {
                    indent => "        ",
                    name => name,
                },
            ));
        }
        TypeRef::Vec(_) | TypeRef::Map(_, _) => {
            out.push_str(&render(
                "opaque_param_dupez.jinja",
                minijinja::context! {
                    indent => "        ",
                    name => name,
                },
            ));
        }
        TypeRef::Named(n) if struct_names.contains(n) => {
            let snake = AsSnakeCase(n).to_string();
            out.push_str(&render(
                "opaque_param_named_from_json.jinja",
                minijinja::context! {
                    indent => "        ",
                    name => name,
                    prefix => prefix,
                    snake => &snake,
                    json_error_return => json_error_return,
                },
            ));
        }
        TypeRef::Optional(inner) => {
            if let TypeRef::Vec(_) | TypeRef::Map(_, _) = inner.as_ref() {
                out.push_str(&render(
                    "opaque_param_optional_dupez.jinja",
                    minijinja::context! {
                        indent => "        ",
                        name => name,
                        capture => "v",
                    },
                ));
            }
        }
        _ => {}
    }
}

/// Free allocations made in `emit_method_param_conversion`.
fn emit_method_param_free(
    p: &crate::core::ir::ParamDef,
    _prefix: &str,
    struct_names: &std::collections::HashSet<String>,
    _out: &mut String,
) {
    let name = &p.name;
    let _ = name;
    let is_optional_string = p.optional
        || matches!(
            &p.ty,
            TypeRef::Optional(inner)
                if matches!(inner.as_ref(), TypeRef::String | TypeRef::Path)
        );

    // String/Path/Vec/Map and optional-String/Path are freed via `defer` emitted
    // immediately after the dupeZ/allocPrintSentinel call in emit_method_param_conversion.
    // Only struct handles require an explicit post-call C FFI free here.

    if is_optional_string
        && matches!(
            match &p.ty {
                TypeRef::Optional(i) => i.as_ref(),
                other => other,
            },
            TypeRef::String | TypeRef::Path
        )
    {
        // Freed by defer in emit_method_param_conversion.
        return;
    }

    // Mirror the conversion logic: an Optional<NamedStruct> may be encoded
    // as `TypeRef::Optional(Named(_))` or `Named(_)` + `p.optional`.
    let optional_named: Option<&str> = match &p.ty {
        TypeRef::Optional(inner) => match inner.as_ref() {
            TypeRef::Named(n) if struct_names.contains(n) => Some(n.as_str()),
            _ => None,
        },
        TypeRef::Named(n) if p.optional && struct_names.contains(n) => Some(n.as_str()),
        _ => None,
    };
    if let Some(n) = optional_named {
        let _ = n;
        return;
    }

    match &p.ty {
        // String/Path/Vec/Map: freed by defer in emit_method_param_conversion.
        TypeRef::String | TypeRef::Path | TypeRef::Vec(_) | TypeRef::Map(_, _) => {}
        TypeRef::Named(n) if struct_names.contains(n) => {
            let _ = n;
        }
        // Optional Vec/Map: freed by defer in emit_method_param_conversion.
        TypeRef::Optional(inner) => {
            let _ = inner; // suppress unused variable warning
        }
        _ => {}
    }
}

/// Build the C argument name(s) for a method parameter.
fn method_c_arg_names(
    p: &crate::core::ir::ParamDef,
    struct_names: &std::collections::HashSet<String>,
    enum_names: &std::collections::HashSet<String>,
) -> Vec<String> {
    // Enums: pass the i32 discriminant
    if let TypeRef::Named(type_name) = &p.ty {
        if enum_names.contains(type_name) {
            return vec![format!("{}_i32", p.name)];
        }
    }

    // `Option<NamedStruct>` parameters use a conditional handle: `null` when
    // the caller passed `null`, otherwise the FFI handle produced via
    // `_from_json`. Check the optional form first so non-optional Named
    // params don't shadow it. The optional may be encoded as `Optional(Named)`
    // or as `Named` + `p.optional = true`.
    let optional_named: Option<&str> = match &p.ty {
        TypeRef::Optional(inner) => match inner.as_ref() {
            TypeRef::Named(n) if struct_names.contains(n) => Some(n.as_str()),
            _ => None,
        },
        TypeRef::Named(n) if p.optional && struct_names.contains(n) => Some(n.as_str()),
        _ => None,
    };
    if optional_named.is_some() {
        return vec![format!("{}_handle", p.name)];
    }
    if let TypeRef::Named(n) = &p.ty {
        if struct_names.contains(n.as_str()) {
            return vec![format!("{}_handle", p.name)];
        }
    }
    let is_optional_string = p.optional
        || matches!(
            &p.ty,
            TypeRef::Optional(inner)
                if matches!(inner.as_ref(), TypeRef::String | TypeRef::Path)
        );
    if is_optional_string
        && matches!(
            match &p.ty {
                TypeRef::Optional(i) => i.as_ref(),
                other => other,
            },
            TypeRef::String | TypeRef::Path
        )
    {
        return vec![format!("if ({0}_z) |z| z.ptr else null", p.name)];
    }
    // Optional integer primitive: substitute the max-value sentinel for None.
    // The Rust FFI layer uses `<Type>::MAX` as the sentinel for Option<T>.
    // Handle both TypeRef::Optional(Primitive) and p.optional + TypeRef::Primitive.
    {
        let prim_opt: Option<&PrimitiveType> = match &p.ty {
            TypeRef::Optional(inner) => {
                if let TypeRef::Primitive(prim) = inner.as_ref() {
                    Some(prim)
                } else {
                    None
                }
            }
            TypeRef::Primitive(prim) if p.optional => Some(prim),
            _ => None,
        };
        if let Some(prim) = prim_opt {
            if let Some(sentinel) = optional_int_sentinel(prim) {
                return vec![format!("if ({name}) |v| v else {sentinel}", name = p.name)];
            }
        }
    }
    match &p.ty {
        TypeRef::String | TypeRef::Path | TypeRef::Vec(_) | TypeRef::Map(_, _) => {
            vec![format!("{}_z", p.name)]
        }
        TypeRef::Bytes => {
            vec![format!("{}.ptr", p.name), format!("{}.len", p.name)]
        }
        _ => vec![p.name.clone()],
    }
}

/// Produce the Zig return expression for an opaque method result.
fn method_unwrap_return_expr(
    raw: &str,
    ty: &TypeRef,
    prefix: &str,
    struct_names: &std::collections::HashSet<String>,
) -> String {
    match ty {
        TypeRef::String | TypeRef::Path | TypeRef::Json | TypeRef::Vec(_) | TypeRef::Map(_, _) => {
            format!(
                "blk: {{\n            const slice = std.mem.span({raw});\n            const owned = try std.heap.c_allocator.dupe(u8, slice);\n            c.{prefix}_free_string({raw});\n            break :blk owned;\n        }}"
            )
        }
        TypeRef::Named(name) if struct_names.contains(name) => {
            let snake = AsSnakeCase(name).to_string();
            format!(
                "blk: {{\n            const _json_ptr = c.{prefix}_{snake}_to_json({raw});\n            const _json_slice = std.mem.span(_json_ptr);\n            const owned = try std.heap.c_allocator.dupe(u8, _json_slice);\n            c.{prefix}_free_string(_json_ptr);\n            c.{prefix}_{snake}_free({raw});\n            break :blk owned;\n        }}"
            )
        }
        TypeRef::Named(name) => {
            // Opaque handle type (no serde): unwrap the nullable C pointer (guaranteed
            // non-null after the error-code check above) and wrap in the Zig struct.
            format!("{name}{{ ._handle = {raw}.? }}")
        }
        _ => raw.to_string(),
    }
}
