/// Generate visitor/callback FFI bindings.
///
/// This module produces the `#[repr(C)]` callback struct, an opaque `Visitor`
/// handle that bridges C function pointers into the Rust `HtmlVisitor` trait,
/// and the three public FFI entry points:
///
/// - `{prefix}_visitor_create(callbacks: *const {Prefix}VisitorCallbacks) -> *mut {Prefix}Visitor`
/// - `{prefix}_visitor_free(visitor: *mut {Prefix}Visitor)`
/// - `{prefix}_options_set_visitor_handle(options, visitor)` — attach visitor to options before `{prefix}_convert`
///
/// # Coverage
///
/// All `HtmlVisitor` trait methods are covered. The callback struct field
/// order matches the trait definition order (and therefore the Go binding's
/// expected layout).
use heck::ToPascalCase;

/// The integer codes that map to `VisitResult` variants crossing the FFI boundary.
///
/// | Value | Meaning               |
/// |-------|-----------------------|
/// |   0   | `VisitResult::Continue`     |
/// |   1   | `VisitResult::Skip`         |
/// |   2   | `VisitResult::PreserveHtml` |
/// |   3   | `VisitResult::Custom(…)`    |
/// |   4   | `VisitResult::Error(…)`     |
#[allow(dead_code)]
pub const VISIT_RESULT_CONTINUE: i32 = 0;
pub const VISIT_RESULT_SKIP: i32 = 1;
pub const VISIT_RESULT_PRESERVE_HTML: i32 = 2;
pub const VISIT_RESULT_CUSTOM: i32 = 3;
pub const VISIT_RESULT_ERROR: i32 = 4;

// ---------------------------------------------------------------------------
// Data-driven callback specifications
// ---------------------------------------------------------------------------

/// The kind of a single callback parameter (beyond the common ctx/user_data/out
/// prefix that every callback shares).
enum ParamKind {
    /// Required `*const c_char` — converted from `&str` via `CString::new`.
    Str(String),
    /// Optional `*const c_char` — converted from `Option<&str>` via `opt_str_to_c`.
    OptStr(String),
    /// `i32` — converted from `bool` via `i32::from`.
    Bool(String),
    /// `u32` — passed through directly.
    U32(String),
    /// `usize` — passed through directly.
    Usize(String),
    /// `*const *const c_char` + `usize` (cell_count) — special for table rows.
    CellSlice(String),
}

/// Specification for one visitor callback.
pub(crate) struct CallbackSpec {
    name: String,
    doc: String,
    params: Vec<ParamKind>,
}

/// Build a `Vec<CallbackSpec>` from a trait's IR definition for the FFI backend.
///
/// Derives all FFI-specific fields (`ParamKind`) from `TypeRef` + `optional` flag.
/// Methods with unsupported parameter types are skipped with a warning.
/// Parameters whose type is `TypeRef::Named(_)` (the context threaded via FFI's
/// separate channel) are silently skipped — they do not become C parameters.
pub(crate) fn callback_specs_from_trait(trait_def: &crate::core::ir::TypeDef) -> Vec<CallbackSpec> {
    use crate::core::ir::{PrimitiveType, TypeRef};

    let mut specs = Vec::with_capacity(trait_def.methods.len());
    'methods: for m in &trait_def.methods {
        if m.trait_source.is_some() {
            continue;
        }
        let mut params = Vec::new();
        for p in &m.params {
            // Skip the context parameter — it is threaded via FFI's separate channel.
            if matches!(&p.ty, TypeRef::Named(_)) {
                continue;
            }
            let param_name = p.name.trim_start_matches('_').to_string();
            match (&p.ty, p.optional) {
                (TypeRef::String, false) => {
                    params.push(ParamKind::Str(param_name));
                }
                (TypeRef::String, true) => {
                    params.push(ParamKind::OptStr(param_name));
                }
                (TypeRef::Primitive(PrimitiveType::Bool), false) => {
                    params.push(ParamKind::Bool(param_name));
                }
                (
                    TypeRef::Primitive(
                        PrimitiveType::U32
                        | PrimitiveType::I32
                        | PrimitiveType::U16
                        | PrimitiveType::I16
                        | PrimitiveType::U8
                        | PrimitiveType::I8,
                    ),
                    false,
                ) => {
                    params.push(ParamKind::U32(param_name));
                }
                (TypeRef::Primitive(PrimitiveType::Usize | PrimitiveType::U64 | PrimitiveType::I64), false) => {
                    params.push(ParamKind::Usize(param_name));
                }
                (TypeRef::Vec(inner), false) => match inner.as_ref() {
                    TypeRef::String => {
                        params.push(ParamKind::CellSlice(param_name));
                    }
                    _ => {
                        eprintln!(
                            "[alef] gen_visitor(ffi): skip method `{}` — unsupported Vec param `{}`",
                            m.name, p.name
                        );
                        continue 'methods;
                    }
                },
                _ => {
                    eprintln!(
                        "[alef] gen_visitor(ffi): skip method `{}` — unsupported param `{}: {:?}`",
                        m.name, p.name, p.ty
                    );
                    continue 'methods;
                }
            }
        }
        specs.push(CallbackSpec {
            name: m.name.clone(),
            doc: m.doc.clone(),
            params,
        });
    }
    specs
}

// ---------------------------------------------------------------------------
// Code-generation helpers — each produces one section of the output
// ---------------------------------------------------------------------------

/// Build the C `extern "C" fn(...)` signature parameters for one callback.
fn c_param_list(spec: &CallbackSpec, pascal_prefix: &str) -> String {
    let mut parts = vec![
        format!("ctx: *const {pascal_prefix}NodeContext"),
        "user_data: *mut std::ffi::c_void".to_string(),
    ];
    for p in &spec.params {
        match p {
            ParamKind::Str(n) | ParamKind::OptStr(n) => {
                parts.push(format!("{n}: *const std::ffi::c_char"));
            }
            ParamKind::Bool(n) => parts.push(format!("{n}: i32")),
            ParamKind::U32(n) => parts.push(format!("{n}: u32")),
            ParamKind::Usize(n) => parts.push(format!("{n}: usize")),
            ParamKind::CellSlice(n) => {
                parts.push(format!("{n}: *const *const std::ffi::c_char"));
                parts.push("cell_count: usize".to_string());
            }
        }
    }
    parts.push("out_custom: *mut *mut std::ffi::c_char".to_string());
    parts.push("out_len: *mut usize".to_string());
    parts.join(",\n            ")
}

/// Format a doc string so every line carries the `    ///` prefix.
///
/// Splits on `\n`, prepends `    /// ` to each line (trimming any existing
/// leading `///` a caller may have embedded), and rejoins with `\n`.
fn format_doc_comment(doc: &str) -> String {
    doc.lines()
        .map(|line| {
            // Strip any leading `///` the caller may have pre-pended so we
            // don't double-prefix embedded continuation lines.
            let stripped = line.trim_start_matches("///").trim_start();
            if stripped.is_empty() {
                "    ///".to_string()
            } else {
                format!("    /// {stripped}")
            }
        })
        .collect::<Vec<_>>()
        .join("\n")
}

/// Generate all `Option<unsafe extern "C" fn(...)>` struct fields.
fn gen_struct_fields(specs: &[CallbackSpec], pascal_prefix: &str) -> String {
    let mut out = String::new();
    for spec in specs {
        let doc_lines = format_doc_comment(&spec.doc);
        out.push_str(&crate::backends::ffi::template_env::render("formatted_line.jinja", minijinja::context! { content => format!("\n{doc_lines}\n    pub {name}: Option<\n        unsafe extern \"C\" fn(\n            {params}\n        ) -> i32,\n    >,\n", doc_lines = doc_lines, name = spec.name, params = c_param_list(spec, pascal_prefix)) }));
    }
    out
}

/// Build the Rust trait parameter list for a callback (the `&str`, `bool`, etc. side).
fn rust_param_list(spec: &CallbackSpec, core_import: &str) -> String {
    let mut parts = vec![
        "&mut self".to_string(),
        format!("ctx: &{core_import}::visitor::NodeContext"),
    ];
    for p in &spec.params {
        match p {
            ParamKind::Str(n) => parts.push(format!("{n}: &str")),
            ParamKind::OptStr(n) => parts.push(format!("{n}: Option<&str>")),
            ParamKind::Bool(n) => parts.push(format!("{n}: bool")),
            ParamKind::U32(n) => parts.push(format!("{n}: u32")),
            ParamKind::Usize(n) => parts.push(format!("{n}: usize")),
            ParamKind::CellSlice(n) => parts.push(format!("{n}: &[String]")),
        }
    }
    parts.join(", ")
}

/// Generate the body of one `impl HtmlVisitor` method.
///
/// Produces local CString bindings, the `call_with_ctx` invocation, and the
/// callback argument forwarding.
fn gen_impl_body(spec: &CallbackSpec, core_import: &str) -> String {
    let mut bindings = String::new();
    let mut cb_args = Vec::new();

    for p in &spec.params {
        match p {
            ParamKind::Str(n) => {
                bindings.push_str(&crate::backends::ffi::template_env::render("formatted_line.jinja", minijinja::context! { content => format!("        let {n}_cs = match std::ffi::CString::new({n}) {{\n            Ok(s) => s,\n            Err(_) => return {core_import}::visitor::VisitResult::Continue,\n        }};\n") }));
                cb_args.push(format!("{n}_cs.as_ptr()"));
            }
            ParamKind::OptStr(n) => {
                bindings.push_str(&crate::backends::ffi::template_env::render(
                    "formatted_line.jinja",
                    minijinja::context! { content => format!("        let ({n}_ptr, _{n}_cs) = opt_str_to_c({n});\n") },
                ));
                cb_args.push(format!("{n}_ptr"));
            }
            ParamKind::Bool(n) => {
                bindings.push_str(&crate::backends::ffi::template_env::render(
                    "formatted_line.jinja",
                    minijinja::context! { content => format!("        let {n}_i = i32::from({n});\n") },
                ));
                cb_args.push(format!("{n}_i"));
            }
            ParamKind::U32(n) | ParamKind::Usize(n) => {
                cb_args.push(n.clone());
            }
            ParamKind::CellSlice(n) => {
                bindings.push_str(&crate::backends::ffi::template_env::render("formatted_line.jinja", minijinja::context! { content => format!("        let {n}_cstrings: Vec<std::ffi::CString> = {n}\n            .iter()\n            .filter_map(|s| std::ffi::CString::new(s.as_str()).ok())\n            .collect();\n        let {n}_ptrs: Vec<*const std::ffi::c_char> =\n            {n}_cstrings.iter().map(|cs| cs.as_ptr()).collect();\n        let cell_count = {n}_ptrs.len();\n") }));
                cb_args.push(format!("{n}_ptrs.as_ptr()"));
                cb_args.push("cell_count".to_string());
            }
        }
    }

    let args_str = if cb_args.is_empty() {
        "out_custom, out_len".to_string()
    } else {
        format!("{}, out_custom, out_len", cb_args.join(", "))
    };

    format!(
        "        let Some(cb) = self.callbacks.{name} else {{\n            return {core_import}::visitor::VisitResult::Continue;\n        }};\n        let user_data = self.callbacks.user_data;\n{bindings}        // SAFETY: cb is a valid function pointer; all temporaries live for this call.\n        unsafe {{\n            call_with_ctx(ctx, |c_ctx, out_custom, out_len| {{\n                cb(c_ctx, user_data, {args_str})\n            }})\n        }}",
        name = spec.name,
    )
}

/// Generate all `impl HtmlVisitor` methods.
fn gen_impl_methods(specs: &[CallbackSpec], pascal_prefix: &str, core_import: &str) -> String {
    let mut out = String::new();
    for spec in specs {
        out.push_str(&crate::backends::ffi::template_env::render("formatted_line.jinja", minijinja::context! { content => format!("\n    fn {name}(\n        {params}\n    ) -> {core_import}::visitor::VisitResult {{\n{body}\n    }}\n", name = spec.name, params = rust_param_list(spec, core_import), body = gen_impl_body(spec, core_import)) }));
    }
    // Close the impl block — caller opens it.
    let _ = pascal_prefix; // used by caller
    out
}

/// Build the forwarding argument list for `VisitorRef` delegation.
fn visitor_ref_args(spec: &CallbackSpec) -> String {
    let mut args = vec!["ctx".to_string()];
    for p in &spec.params {
        match p {
            ParamKind::Str(n)
            | ParamKind::OptStr(n)
            | ParamKind::Bool(n)
            | ParamKind::U32(n)
            | ParamKind::Usize(n)
            | ParamKind::CellSlice(n) => args.push(n.clone()),
        }
    }
    args.join(", ")
}

/// Generate all `VisitorRef` forwarding methods.
fn gen_visitor_ref_methods(specs: &[CallbackSpec], core_import: &str) -> String {
    let mut out = String::new();
    for spec in specs {
        let params = rust_param_list(spec, core_import);
        let args = visitor_ref_args(spec);
        out.push_str(&crate::backends::ffi::template_env::render(
            "vtable_delegation_method.jinja",
            minijinja::context! {
                method_name => spec.name.as_str(),
                all_params => params,
                ret => format!("{}::visitor::VisitResult", core_import),
                arg_list => args,
            },
        ));
    }
    out
}

/// Generate the visitor FFI bindings block for `lib.rs`.
///
/// # Parameters
///
/// - `prefix`: the FFI function prefix (e.g. `"htm"`).
/// - `core_import`: the Rust `use` path for the core crate (e.g. `"sample_markdown_rs"`).
/// - `embed_visitor_in_options`: when `true`, the generated `{prefix}_convert_with_visitor`
///   embeds the visitor in `options.visitor` before calling the 2-argument `convert(html,
///   options)`.  Set `true` for the OptionsField bridge pattern; `false` for the legacy
///   FunctionParam pattern where `convert` takes a third visitor argument directly.
/// - `trait_def`: the IR `TypeDef` for the visitor trait, used to derive callback specs.
pub fn gen_visitor_bindings(
    prefix: &str,
    core_import: &str,
    embed_visitor_in_options: bool,
    trait_def: &crate::core::ir::TypeDef,
) -> String {
    let pascal_prefix = prefix.to_pascal_case();
    let specs = callback_specs_from_trait(trait_def);

    let struct_fields = gen_struct_fields(&specs, &pascal_prefix);
    let impl_methods = gen_impl_methods(&specs, &pascal_prefix, core_import);
    let visitor_ref_methods = gen_visitor_ref_methods(&specs, core_import);

    // Build the convert expression for {prefix}_convert_with_visitor.
    let convert_call = if embed_visitor_in_options {
        format!(
            "    let mut options_with_visitor: Option<{core_import}::ConversionOptions> = options_rs;\n\
             if visitor_handle.is_some() {{\n\
             let opts = options_with_visitor.get_or_insert_with({core_import}::ConversionOptions::default);\n\
             opts.visitor = visitor_handle;\n\
             }}\n\
             match {core_import}::convert(&html_str, options_with_visitor) {{"
        )
    } else {
        format!("    match {core_import}::convert(&html_str, options_rs, visitor_handle) {{")
    };

    format!(
        r#"// ---------------------------------------------------------------------------
// Visitor / callback FFI — all 42 HtmlVisitor methods
// ---------------------------------------------------------------------------

/// Visit-result code: continue with default conversion.
pub const HTM_VISIT_CONTINUE: i32 = 0;
/// Visit-result code: skip this element entirely (no output).
pub const HTM_VISIT_SKIP: i32 = 1;
/// Visit-result code: preserve the original HTML verbatim.
pub const HTM_VISIT_PRESERVE_HTML: i32 = 2;
/// Visit-result code: use `out_custom` / `out_len` as custom Markdown output.
pub const HTM_VISIT_CUSTOM: i32 = 3;
/// Visit-result code: abort conversion; `out_custom` contains the error message.
pub const HTM_VISIT_ERROR: i32 = 4;

/// Opaque context passed to every C callback.
///
/// Fields reflect `NodeContext` from the Rust core. All string pointers are
/// valid only for the duration of the callback invocation.
#[repr(C)]
pub struct {pascal_prefix}NodeContext {{
    /// Coarse-grained node type tag (matches `NodeType` discriminant).
    pub node_type: i32,
    /// Null-terminated tag name (e.g. `"div"`). Never null.
    pub tag_name: *const std::ffi::c_char,
    /// Depth in the DOM tree (0 = root).
    pub depth: usize,
    /// Index among siblings (0-based).
    pub index_in_parent: usize,
    /// Null-terminated parent tag name, or null if root.
    pub parent_tag: *const std::ffi::c_char,
    /// Non-zero if this element is treated as inline.
    pub is_inline: i32,
}}

/// C-facing callback struct for the visitor pattern.
///
/// Populate the function-pointer fields you care about; leave the rest null.
/// The `user_data` pointer is forwarded unchanged to every callback — use it
/// to thread your own context through the conversion.
///
/// # Field order
///
/// The field order matches the Go binding's expected C layout exactly.
///
/// # Callback return protocol
///
/// Callbacks return an `i32` visit-result code.  When the code is
/// `HTM_VISIT_CUSTOM` (3) or `HTM_VISIT_ERROR` (4), the callback must also
/// write a heap-allocated, null-terminated string into `*out_custom` and set
/// `*out_len` to its byte length (excluding the null terminator).  The Rust
/// side will read the string and then call `free()` on the pointer.
///
/// For all other codes `out_custom` and `out_len` are not written.
///
/// # Callback signatures
///
/// All callbacks share the same leading parameters:
/// ```c
/// fn(ctx, user_data, out_custom, out_len, ...) -> i32
/// ```
/// followed by method-specific parameters documented on each field.
#[repr(C)]
pub struct {pascal_prefix}VisitorCallbacks {{
    /// Arbitrary caller context forwarded to every callback.
    pub user_data: *mut std::ffi::c_void,
{struct_fields}}}

// SAFETY: The `user_data` pointer is the caller's responsibility. We require
// callers to uphold thread-safety themselves (i.e. not share a visitor across
// threads without synchronisation). The callbacks themselves are `extern "C"`
// and therefore inherently `Send`.
unsafe impl Send for {pascal_prefix}VisitorCallbacks {{}}
// SAFETY: see Send impl above; the callbacks struct is effectively a POD vtable.
unsafe impl Sync for {pascal_prefix}VisitorCallbacks {{}}

/// Opaque handle wrapping a `{pascal_prefix}VisitorCallbacks` and implementing
/// the Rust `HtmlVisitor` trait.
///
/// Allocate with `{prefix}_visitor_create` and release with `{prefix}_visitor_free`.
/// The handle must NOT outlive the `{pascal_prefix}VisitorCallbacks` it was created from.
pub struct {pascal_prefix}Visitor {{
    callbacks: {pascal_prefix}VisitorCallbacks,
    /// CString storage for tag names / parent tags that we pass back to C.
    /// RefCell is used for interior mutability; it is Send (Vec<CString> is Send) and
    /// the outer Arc<Mutex> serialises all access, so Sync is not required on RefCell itself.
    _tag_scratch: std::cell::RefCell<Vec<std::ffi::CString>>,
}}

// SAFETY: {pascal_prefix}Visitor is only accessed through the outer Arc<Mutex<dyn HtmlVisitor + Send>>
// which serialises access. The `user_data` pointer is the caller's responsibility.
unsafe impl Send for {pascal_prefix}Visitor {{}}
// SAFETY: see Send impl above; Sync is safe because all mutation goes through Mutex.
unsafe impl Sync for {pascal_prefix}Visitor {{}}

impl std::fmt::Debug for {pascal_prefix}Visitor {{
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {{
        f.debug_struct("{pascal_prefix}Visitor").finish_non_exhaustive()
    }}
}}

/// Map a `VisitResult` integer code + optional custom string pointer back to
/// the Rust `VisitResult` enum.
///
/// # Safety
///
/// `custom_ptr` must be either null or a pointer to a heap-allocated
/// null-terminated string that this function will take ownership of (freeing
/// it after reading).
unsafe fn decode_visit_result(
    code: i32,
    custom_ptr: *mut std::ffi::c_char,
) -> {core_import}::visitor::VisitResult {{
    use {core_import}::visitor::VisitResult;
    match code {{
        {VISIT_RESULT_SKIP} => VisitResult::Skip,
        {VISIT_RESULT_PRESERVE_HTML} => VisitResult::PreserveHtml,
        {VISIT_RESULT_CUSTOM} | {VISIT_RESULT_ERROR} => {{
            let msg = if custom_ptr.is_null() {{
                String::new()
            }} else {{
                // SAFETY: caller guarantees this is a valid heap CString.
                let cstr = unsafe {{ std::ffi::CString::from_raw(custom_ptr) }};
                cstr.to_string_lossy().into_owned()
            }};
            if code == {VISIT_RESULT_CUSTOM} {{
                VisitResult::Custom(msg)
            }} else {{
                VisitResult::Error(msg)
            }}
        }}
        _ => VisitResult::Continue,
    }}
}}

/// Build a temporary `{pascal_prefix}NodeContext` from a Rust `NodeContext`, invoke
/// the provided callback, and decode the `VisitResult`.
///
/// The `NodeContext` passed to the C callback is only valid for the duration
/// of this function call.
unsafe fn call_with_ctx<F>(
    ctx: &{core_import}::visitor::NodeContext,
    callback: F,
) -> {core_import}::visitor::VisitResult
where
    F: FnOnce(
        *const {pascal_prefix}NodeContext,
        *mut *mut std::ffi::c_char,
        *mut usize,
    ) -> i32,
{{
    // Build temporary CStrings for the string fields.
    let tag_cstring = std::ffi::CString::new(ctx.tag_name.as_str()).unwrap_or_default();
    let parent_cstring: Option<std::ffi::CString> = ctx
        .parent_tag
        .as_deref()
        .and_then(|s| std::ffi::CString::new(s).ok());

    let c_ctx = {pascal_prefix}NodeContext {{
        node_type: ctx.node_type as i32,
        tag_name: tag_cstring.as_ptr(),
        depth: ctx.depth,
        index_in_parent: ctx.index_in_parent,
        parent_tag: parent_cstring.as_ref().map_or(std::ptr::null(), |c| c.as_ptr()),
        is_inline: ctx.is_inline as i32,
    }};

    let mut out_custom: *mut std::ffi::c_char = std::ptr::null_mut();
    let mut out_len: usize = 0;

    let code = callback(&c_ctx, &mut out_custom, &mut out_len);

    // SAFETY: decode_visit_result takes ownership of out_custom when non-null.
    unsafe {{ decode_visit_result(code, out_custom) }}
}}

/// Convert an `Option<&str>` to a C pointer: non-null CString when `Some`, null when `None`.
///
/// Returns `(ptr, Option<CString>)` — the `Option<CString>` must be kept alive
/// until after the pointer is consumed by the callback.
fn opt_str_to_c(s: Option<&str>) -> (*const std::ffi::c_char, Option<std::ffi::CString>) {{
    match s {{
        Some(val) => match std::ffi::CString::new(val) {{
            Ok(cs) => {{
                let ptr = cs.as_ptr();
                (ptr, Some(cs))
            }}
            Err(_) => (std::ptr::null(), None),
        }},
        None => (std::ptr::null(), None),
    }}
}}

impl {core_import}::visitor::HtmlVisitor for {pascal_prefix}Visitor {{
{impl_methods}}}

/// Create a new visitor handle from a callbacks struct.
///
/// The returned handle must be freed with `{prefix}_visitor_free`.
/// The `{pascal_prefix}VisitorCallbacks` struct is **copied** into the handle;
/// the caller may free it after this call returns.
///
/// Returns null on allocation failure.
///
/// # Safety
///
/// `callbacks` must point to a valid, fully initialised `{pascal_prefix}VisitorCallbacks`.
/// `user_data` (embedded in the struct) must remain valid and accessible from
/// any thread that calls `{prefix}_convert_with_visitor` until after
/// `{prefix}_visitor_free` is called.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn {prefix}_visitor_create(
    callbacks: *const {pascal_prefix}VisitorCallbacks,
) -> *mut {pascal_prefix}Visitor {{
    if callbacks.is_null() {{
        return std::ptr::null_mut();
    }}
    // SAFETY: caller guarantees the pointer is valid.
    let cbs = unsafe {{ callbacks.read() }};
    let visitor = {pascal_prefix}Visitor {{
        callbacks: cbs,
        _tag_scratch: std::cell::RefCell::new(Vec::new()),
    }};
    Box::into_raw(Box::new(visitor))
}}

/// Free a visitor handle previously returned by `{prefix}_visitor_create`.
///
/// After this call the pointer is invalid and must not be used.
///
/// # Safety
///
/// `visitor` must have been returned by `{prefix}_visitor_create`, or be null.
/// Passing a null pointer is safe and has no effect.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn {prefix}_visitor_free(visitor: *mut {pascal_prefix}Visitor) {{
    if !visitor.is_null() {{
        // SAFETY: visitor was created with Box::into_raw.
        unsafe {{ drop(Box::from_raw(visitor)); }}
    }}
}}

/// Attach a visitor to a [`{core_import}::ConversionOptions`] handle before calling `{prefix}_convert`.
///
/// The visitor will be invoked during conversion via the normal `{prefix}_convert` path.
/// The `visitor` pointer must remain valid until after `{prefix}_convert` returns.
///
/// Passing `null` for either argument is a no-op.
///
/// # Safety
///
/// `options` must be a non-null pointer returned by `{prefix}_conversion_options_from_json`,
/// valid for write access.  `visitor` must be a non-null pointer returned by
/// `{prefix}_visitor_create`, or null.  Both must remain valid for the duration of any
/// subsequent `{prefix}_convert` call.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn {prefix}_options_set_visitor_handle(
    options: *mut {core_import}::ConversionOptions,
    visitor: *mut {pascal_prefix}Visitor,
) {{
    if options.is_null() || visitor.is_null() {{
        return;
    }}
    struct VisitorRef(*mut {pascal_prefix}Visitor);
    impl std::fmt::Debug for VisitorRef {{
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {{
            f.debug_struct("VisitorRef").finish_non_exhaustive()
        }}
    }}
    // SAFETY: VisitorRef is a thin wrapper around a raw pointer to {pascal_prefix}Visitor which
    // is itself Send + Sync. The caller guarantees the pointer remains valid during conversion.
    unsafe impl Send for VisitorRef {{}}
    // SAFETY: see Send impl above.
    unsafe impl Sync for VisitorRef {{}}
    impl {core_import}::visitor::HtmlVisitor for VisitorRef {{
{visitor_ref_methods}    }}
    // SAFETY: options is non-null (checked above); caller guarantees it is valid for write.
    let options_ref = unsafe {{ &mut *options }};
    options_ref.visitor = Some(std::sync::Arc::new(std::sync::Mutex::new(VisitorRef(visitor))));
}}"#,
        VISIT_RESULT_SKIP = VISIT_RESULT_SKIP,
        VISIT_RESULT_PRESERVE_HTML = VISIT_RESULT_PRESERVE_HTML,
        VISIT_RESULT_CUSTOM = VISIT_RESULT_CUSTOM,
        VISIT_RESULT_ERROR = VISIT_RESULT_ERROR,
        prefix = prefix,
        pascal_prefix = pascal_prefix,
        core_import = core_import,
        struct_fields = struct_fields,
        impl_methods = impl_methods,
        visitor_ref_methods = visitor_ref_methods,
    ) + &format!(
        r#"
/// Run conversion using a callback-based visitor.
///
/// Returns a heap-allocated `ConversionResult` on success, or null on failure.
/// Check `{prefix}_last_error_code` / `{prefix}_last_error_context` for error details.
/// The returned pointer must be freed with `{prefix}_conversion_result_free`.
///
/// # Safety
///
/// `html` must be a valid, non-null, null-terminated UTF-8 string.
/// `options` must be a valid pointer or null.
/// `visitor` must have been created with `{prefix}_visitor_create`, or be null.
/// Returned pointer must be freed with `{prefix}_conversion_result_free`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn {prefix}_convert_with_visitor(
    html: *const std::ffi::c_char,
    options: *const {core_import}::ConversionOptions,
    visitor: *mut {pascal_prefix}Visitor,
) -> *mut {core_import}::ConversionResult {{
    clear_last_error();

    if html.is_null() {{
        set_last_error(1, "Null pointer passed for html");
        return std::ptr::null_mut();
    }}

    // SAFETY: null check above guarantees html is a valid pointer.
    let html_str = match unsafe {{ std::ffi::CStr::from_ptr(html) }}.to_str() {{
        Ok(s) => s,
        Err(_) => {{
            set_last_error(1, "Invalid UTF-8 in html parameter");
            return std::ptr::null_mut();
        }}
    }};

    let options_rs: Option<{core_import}::ConversionOptions> = if options.is_null() {{
        None
    }} else {{
        // SAFETY: options is a valid pointer guaranteed by the caller.
        Some(unsafe {{ &*options }}.clone())
    }};

    struct VisitorRef(*mut {pascal_prefix}Visitor);
    impl std::fmt::Debug for VisitorRef {{
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {{
            f.debug_struct("VisitorRef").finish_non_exhaustive()
        }}
    }}
    // SAFETY: VisitorRef is a thin wrapper around a raw pointer to {pascal_prefix}Visitor which
    // is itself Send + Sync. The caller guarantees the pointer remains valid during conversion.
    unsafe impl Send for VisitorRef {{}}
    // SAFETY: see Send impl above.
    unsafe impl Sync for VisitorRef {{}}
    impl {core_import}::visitor::HtmlVisitor for VisitorRef {{
{visitor_ref_methods}    }}
    let visitor_handle: Option<std::sync::Arc<std::sync::Mutex<dyn {core_import}::visitor::HtmlVisitor + Send>>> = if visitor.is_null() {{
        None
    }} else {{
        Some(std::sync::Arc::new(std::sync::Mutex::new(VisitorRef(visitor))))
    }};

{convert_call}
        Ok(result) => Box::into_raw(Box::new(result)),
        Err(e) => {{
            set_last_error(2, &e.to_string());
            std::ptr::null_mut()
        }}
    }}
}}
"#,
        prefix = prefix,
        pascal_prefix = pascal_prefix,
        core_import = core_import,
        visitor_ref_methods = visitor_ref_methods,
        convert_call = convert_call,
    )
}

/// Generate `{prefix}_convert` — the real no-visitor implementation of the core `convert`
/// function.
///
/// When `visitor_callbacks = true`, the core `convert` function has a visitor parameter
/// that causes the IR to sanitize the function (marking it as unimplementable via the normal
/// codegen path).  Instead of emitting a stub, the FFI generator calls this function to
/// produce a proper implementation that passes `None` for the visitor.
///
/// The generated function takes `html` and `options` (no visitor param) and returns a
/// heap-allocated `*mut ConversionResult` that the caller must free with
/// `{prefix}_conversion_result_free`.
pub fn gen_convert_no_visitor(prefix: &str, core_import: &str) -> String {
    let fn_name = format!("{prefix}_convert");
    format!(
        r#"/// Run conversion.
///
/// Returns a heap-allocated [`ConversionResult`] on success, or null on failure.
/// Check `{prefix}_last_error_code` / `{prefix}_last_error_context` for error details.
/// The returned pointer must be freed with `{prefix}_conversion_result_free`.
///
/// # Arguments
///
/// - `html`: null-terminated, UTF-8 HTML input. Must not be null.
/// - `options`: optional conversion options; pass null for defaults.
///
/// # Safety
///
/// `html` must be a valid, non-null, null-terminated UTF-8 string.
/// `options` must be a valid pointer or null.
/// Returned pointer must be freed with `{prefix}_conversion_result_free`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn {fn_name}(
    html: *const std::ffi::c_char,
    options: *const {core_import}::options::ConversionOptions,
) -> *mut {core_import}::ConversionResult {{
    clear_last_error();

    if html.is_null() {{
        set_last_error(1, "Null pointer passed for html");
        return std::ptr::null_mut();
    }}

    // SAFETY: null check above guarantees html is a valid pointer; string is valid UTF-8 from caller.
    let html_str = match unsafe {{ std::ffi::CStr::from_ptr(html) }}.to_str() {{
        Ok(s) => s,
        Err(_) => {{
            set_last_error(1, "Invalid UTF-8 in html parameter");
            return std::ptr::null_mut();
        }}
    }};

    let options_rs: Option<{core_import}::options::ConversionOptions> = if options.is_null() {{
        None
    }} else {{
        // SAFETY: options is a valid pointer guaranteed by the caller.
        Some(unsafe {{ &*options }}.clone())
    }};

    match {core_import}::convert(html_str, options_rs, None) {{
        Ok(result) => Box::into_raw(Box::new(result)),
        Err(e) => {{
            set_last_error(2, &e.to_string());
            std::ptr::null_mut()
        }}
    }}
}}"#,
        prefix = prefix,
        fn_name = fn_name,
        core_import = core_import,
    )
}
