Skip to main content

alef_backend_csharp/
gen_bindings.rs

1use crate::type_map::csharp_type;
2use alef_codegen::doc_emission;
3use alef_codegen::naming::to_csharp_name;
4use alef_core::backend::{Backend, BuildConfig, BuildDependency, Capabilities, GeneratedFile};
5use alef_core::config::{AdapterPattern, AlefConfig, BridgeBinding, Language, resolve_output_dir};
6use alef_core::hash::{self, CommentStyle};
7use alef_core::ir::{ApiSurface, EnumDef, FieldDef, FunctionDef, MethodDef, PrimitiveType, TypeDef, TypeRef};
8use heck::{ToLowerCamelCase, ToPascalCase, ToSnakeCase};
9use std::collections::HashSet;
10use std::path::PathBuf;
11
12/// Collected once in `generate_bindings` and threaded through to generators that need it.
13///
14/// Describes a trait bridge whose handle lives as a field on an options struct rather than
15/// as a standalone function parameter (`bind_via = "options_field"`).
16#[derive(Clone, Debug)]
17struct OptionsFieldBridgeInfo {
18    /// IR type name that owns the bridge field (e.g. `"ConversionOptions"`).
19    options_type: String,
20    /// Field name on that type that holds the bridge handle (e.g. `"visitor"`).
21    field_name: String,
22    /// C# bridge type derived from the trait name (e.g. `"HtmlVisitor"` → `"HtmlVisitorBridge"`).
23    bridge_cs_type: String,
24    /// FFI prefix for symbol derivation (e.g. `"htm"` → `htm_options_set_visitor`).
25    ffi_prefix: String,
26}
27
28impl OptionsFieldBridgeInfo {
29    /// Returns the C# P/Invoke method name for the FFI setter.
30    ///
31    /// Uses the options type name for disambiguation in multi-bridge scenarios,
32    /// e.g. `ConversionOptionsSetVisitor` for options type `ConversionOptions` and field `visitor`.
33    fn cs_setter_name(&self) -> String {
34        let opts_pascal = self.options_type.to_pascal_case();
35        let field_pascal = self.field_name.to_pascal_case();
36        format!("{}Set{}", opts_pascal, field_pascal)
37    }
38
39    /// Returns the FFI entry-point symbol name, e.g. `htm_options_set_visitor`.
40    ///
41    /// Matches the symbol emitted by `alef-backend-ffi::gen_bridge_field`:
42    /// `{prefix}_options_set_{field_name}`.
43    fn ffi_symbol(&self) -> String {
44        let field_snake = self.field_name.to_snake_case();
45        format!("{}_options_set_{}", self.ffi_prefix, field_snake)
46    }
47}
48
49/// Collect all `options_field` bridge descriptors from the config, skipping bridges
50/// that exclude the C# language.
51fn collect_options_field_bridges(config: &AlefConfig, api: &ApiSurface) -> Vec<OptionsFieldBridgeInfo> {
52    let prefix = config.ffi_prefix();
53    let mut result = Vec::new();
54    for bridge_cfg in &config.trait_bridges {
55        if bridge_cfg.bind_via != BridgeBinding::OptionsField {
56            continue;
57        }
58        if bridge_cfg.exclude_languages.contains(&"csharp".to_string()) {
59            continue;
60        }
61        let options_type = match bridge_cfg.options_type.as_deref() {
62            Some(t) => t.to_string(),
63            None => continue,
64        };
65        let field_name = match bridge_cfg.resolved_options_field() {
66            Some(f) => f.to_string(),
67            None => continue,
68        };
69        // Use trait_name for the C# Bridge class (not type_alias).
70        // E.g. trait_name = "HtmlVisitor" → bridge class = "HtmlVisitorBridge".
71        let bridge_cs_type = bridge_cfg.trait_name.clone();
72
73        // Guard: options_type must exist in the IR.
74        if !api.types.iter().any(|t| t.name == options_type) {
75            continue;
76        }
77        result.push(OptionsFieldBridgeInfo {
78            options_type,
79            field_name,
80            bridge_cs_type,
81            ffi_prefix: prefix.clone(),
82        });
83    }
84    result
85}
86
87pub struct CsharpBackend;
88
89impl CsharpBackend {
90    // lib_name comes from config.ffi_lib_name()
91}
92
93impl Backend for CsharpBackend {
94    fn name(&self) -> &str {
95        "csharp"
96    }
97
98    fn language(&self) -> Language {
99        Language::Csharp
100    }
101
102    fn capabilities(&self) -> Capabilities {
103        Capabilities {
104            supports_async: true,
105            supports_classes: true,
106            supports_enums: true,
107            supports_option: true,
108            supports_result: true,
109            ..Capabilities::default()
110        }
111    }
112
113    fn generate_bindings(&self, api: &ApiSurface, config: &AlefConfig) -> anyhow::Result<Vec<GeneratedFile>> {
114        let namespace = config.csharp_namespace();
115        let prefix = config.ffi_prefix();
116        let lib_name = config.ffi_lib_name();
117
118        // Collect bridge param names and type aliases from trait_bridges config so we can strip
119        // them from generated function signatures and emit ConvertWithVisitor instead.
120        let bridge_param_names: HashSet<String> = config
121            .trait_bridges
122            .iter()
123            .filter_map(|b| b.param_name.clone())
124            .collect();
125        let bridge_type_aliases: HashSet<String> = config
126            .trait_bridges
127            .iter()
128            .filter_map(|b| b.type_alias.clone())
129            .collect();
130        // Only emit ConvertWithVisitor method if visitor_callbacks is explicitly enabled in FFI config
131        let has_visitor_callbacks = config.ffi.as_ref().map(|f| f.visitor_callbacks).unwrap_or(false);
132
133        // Collect options-field bridge descriptors (bind_via = "options_field").
134        let options_field_bridges = collect_options_field_bridges(config, api);
135
136        // Streaming adapter methods use a callback-based C signature that P/Invoke can't call
137        // directly. Skip them in all generated method loops.
138        let streaming_methods: HashSet<String> = config
139            .adapters
140            .iter()
141            .filter(|a| matches!(a.pattern, AdapterPattern::Streaming))
142            .map(|a| a.name.clone())
143            .collect();
144
145        // Functions explicitly excluded from C# bindings (e.g., not present in the C FFI layer).
146        let mut exclude_functions: HashSet<String> = config
147            .csharp
148            .as_ref()
149            .map(|c| c.exclude_functions.iter().cloned().collect())
150            .unwrap_or_default();
151
152        // Automatically exclude trait-bridge registration functions to prevent double-emission:
153        // gen_trait_bridge emits them as idiomatic bridge functions, so gen_function should skip them.
154        let trait_bridge_reg_fns =
155            alef_codegen::generators::trait_bridge::collect_trait_bridge_registration_fn_names(&config.trait_bridges);
156        exclude_functions.extend(trait_bridge_reg_fns);
157
158        let output_dir = resolve_output_dir(
159            config.output.csharp.as_ref(),
160            &config.crate_config.name,
161            "packages/csharp/",
162        );
163
164        let base_path = PathBuf::from(&output_dir).join(namespace.replace('.', "/"));
165
166        let mut files = Vec::new();
167
168        // 1. Generate NativeMethods.cs
169        files.push(GeneratedFile {
170            path: base_path.join("NativeMethods.cs"),
171            content: strip_trailing_whitespace(&gen_native_methods(
172                api,
173                &namespace,
174                &lib_name,
175                &prefix,
176                &bridge_param_names,
177                &bridge_type_aliases,
178                has_visitor_callbacks,
179                &config.trait_bridges,
180                &streaming_methods,
181                &exclude_functions,
182                &options_field_bridges,
183            )),
184            generated_header: true,
185        });
186
187        // 2. Generate error types from thiserror enums (if any), otherwise generic exception
188        if !api.errors.is_empty() {
189            for error in &api.errors {
190                let error_files = alef_codegen::error_gen::gen_csharp_error_types(error, &namespace);
191                for (class_name, content) in error_files {
192                    files.push(GeneratedFile {
193                        path: base_path.join(format!("{}.cs", class_name)),
194                        content: strip_trailing_whitespace(&content),
195                        generated_header: false, // already has header
196                    });
197                }
198            }
199        }
200
201        // Fallback generic exception class (always generated for GetLastError)
202        let exception_class_name = format!("{}Exception", api.crate_name.to_pascal_case());
203        if api.errors.is_empty()
204            || !api
205                .errors
206                .iter()
207                .any(|e| format!("{}Exception", e.name) == exception_class_name)
208        {
209            files.push(GeneratedFile {
210                path: base_path.join(format!("{}.cs", exception_class_name)),
211                content: strip_trailing_whitespace(&gen_exception_class(&namespace, &exception_class_name)),
212                generated_header: true,
213            });
214        }
215
216        // 3. Generate main wrapper class
217        let base_class_name = api.crate_name.to_pascal_case();
218        let wrapper_class_name = if namespace == base_class_name {
219            format!("{}Lib", base_class_name)
220        } else {
221            base_class_name
222        };
223        files.push(GeneratedFile {
224            path: base_path.join(format!("{}.cs", wrapper_class_name)),
225            content: strip_trailing_whitespace(&gen_wrapper_class(
226                api,
227                &namespace,
228                &wrapper_class_name,
229                &exception_class_name,
230                &prefix,
231                &bridge_param_names,
232                &bridge_type_aliases,
233                has_visitor_callbacks,
234                &streaming_methods,
235                &exclude_functions,
236                &options_field_bridges,
237            )),
238            generated_header: true,
239        });
240
241        // 3b. Generate visitor support files when a bridge is configured.
242        if has_visitor_callbacks {
243            for (filename, content) in crate::gen_visitor::gen_visitor_files(&namespace) {
244                files.push(GeneratedFile {
245                    path: base_path.join(filename),
246                    content: strip_trailing_whitespace(&content),
247                    generated_header: true,
248                });
249            }
250        } else {
251            // When visitor_callbacks is disabled, delete stale files from prior runs
252            // to prevent CS8632 warnings (nullable context not enabled).
253            delete_stale_visitor_files(&base_path)?;
254        }
255
256        // 3c. Generate trait bridge classes when configured.
257        if !config.trait_bridges.is_empty() {
258            let trait_defs: Vec<_> = api.types.iter().filter(|t| t.is_trait).collect();
259            let bridges: Vec<_> = config
260                .trait_bridges
261                .iter()
262                .filter_map(|cfg| {
263                    let trait_name = cfg.trait_name.clone();
264                    trait_defs
265                        .iter()
266                        .find(|t| t.name == trait_name)
267                        .map(|trait_def| (trait_name, cfg, *trait_def))
268                })
269                .collect();
270
271            if !bridges.is_empty() {
272                let (filename, content) = crate::trait_bridge::gen_trait_bridges_file(&namespace, &prefix, &bridges);
273                files.push(GeneratedFile {
274                    path: base_path.join(filename),
275                    content: strip_trailing_whitespace(&content),
276                    generated_header: true,
277                });
278            }
279        }
280
281        // 4. Generate opaque handle classes
282        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
283            if typ.is_opaque {
284                let type_filename = typ.name.to_pascal_case();
285                files.push(GeneratedFile {
286                    path: base_path.join(format!("{}.cs", type_filename)),
287                    content: strip_trailing_whitespace(&gen_opaque_handle(typ, &namespace)),
288                    generated_header: true,
289                });
290            }
291        }
292
293        // Collect enum names so record generation can distinguish enum fields from class fields.
294        let enum_names: HashSet<String> = api.enums.iter().map(|e| e.name.to_pascal_case()).collect();
295
296        // Collect complex enums (enums with data variants and no serde tag) — these can't be
297        // simple C# enums and should be represented as JsonElement for flexible deserialization.
298        // Tagged unions (serde_tag is set) are now generated as proper abstract records
299        // and can be deserialized as their concrete types, so they are NOT complex_enums.
300        let complex_enums: HashSet<String> = api
301            .enums
302            .iter()
303            .filter(|e| e.serde_tag.is_none() && e.variants.iter().any(|v| !v.fields.is_empty()))
304            .map(|e| e.name.to_pascal_case())
305            .collect();
306
307        // Collect enums that require a custom JsonConverter (non-standard serialized names or
308        // tagged unions). When a property has this enum as its type, we must emit a property-level
309        // [JsonConverter] attribute so the custom converter wins over the global JsonStringEnumConverter.
310        let custom_converter_enums: HashSet<String> = api
311            .enums
312            .iter()
313            .filter(|e| {
314                // Tagged unions always use a custom converter
315                (e.serde_tag.is_some() && e.variants.iter().any(|v| !v.fields.is_empty()))
316                // Enums with non-standard variant names need a custom converter
317                || e.variants.iter().any(|v| {
318                    if let Some(ref rename) = v.serde_rename {
319                        let snake = apply_rename_all(&v.name, e.serde_rename_all.as_deref());
320                        rename != &snake
321                    } else {
322                        false
323                    }
324                })
325            })
326            .map(|e| e.name.to_pascal_case())
327            .collect();
328
329        // Resolve the language-level serde rename_all strategy (always wins over IR type-level).
330        let lang_rename_all = config.serde_rename_all_for_language(Language::Csharp);
331
332        // 5. Generate record types (structs)
333        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
334            if !typ.is_opaque {
335                // Skip types where all fields are unnamed tuple positions — they have no
336                // meaningful properties to expose in C#.
337                let has_named_fields = typ.fields.iter().any(|f| !is_tuple_field(f));
338                if !typ.fields.is_empty() && !has_named_fields {
339                    continue;
340                }
341                // Skip types that gen_visitor handles with richer visitor-specific versions
342                if has_visitor_callbacks && (typ.name == "NodeContext" || typ.name == "VisitResult") {
343                    continue;
344                }
345
346                let type_filename = typ.name.to_pascal_case();
347                files.push(GeneratedFile {
348                    path: base_path.join(format!("{}.cs", type_filename)),
349                    content: strip_trailing_whitespace(&gen_record_type(
350                        typ,
351                        &namespace,
352                        &enum_names,
353                        &complex_enums,
354                        &custom_converter_enums,
355                        &lang_rename_all,
356                        &options_field_bridges,
357                    )),
358                    generated_header: true,
359                });
360            }
361        }
362
363        // 6. Generate enums
364        for enum_def in &api.enums {
365            // Skip enums that gen_visitor handles with richer visitor-specific versions
366            if has_visitor_callbacks && (enum_def.name == "VisitResult" || enum_def.name == "NodeContext") {
367                continue;
368            }
369            let enum_filename = enum_def.name.to_pascal_case();
370            files.push(GeneratedFile {
371                path: base_path.join(format!("{}.cs", enum_filename)),
372                content: strip_trailing_whitespace(&gen_enum(enum_def, &namespace)),
373                generated_header: true,
374            });
375        }
376
377        // Build adapter body map (consumed by generators via body substitution)
378        let _adapter_bodies = alef_adapters::build_adapter_bodies(config, Language::Csharp)?;
379
380        // 7. Generate Directory.Build.props at the package root (always overwritten).
381        // This file enables Nullable=enable and latest LangVersion for all C# projects
382        // in the packages/csharp hierarchy without requiring per-csproj configuration.
383        files.push(GeneratedFile {
384            path: PathBuf::from("packages/csharp/Directory.Build.props"),
385            content: gen_directory_build_props(),
386            generated_header: true,
387        });
388
389        Ok(files)
390    }
391
392    /// C# wrapper class is already the public API.
393    /// The `gen_wrapper_class` (generated in `generate_bindings`) provides high-level public methods
394    /// that wrap NativeMethods (P/Invoke), marshal types, and handle errors.
395    /// No additional facade is needed.
396    fn generate_public_api(&self, _api: &ApiSurface, _config: &AlefConfig) -> anyhow::Result<Vec<GeneratedFile>> {
397        // C#'s wrapper class IS the public API — no additional wrapper needed.
398        Ok(vec![])
399    }
400
401    fn build_config(&self) -> Option<BuildConfig> {
402        Some(BuildConfig {
403            tool: "dotnet",
404            crate_suffix: "",
405            build_dep: BuildDependency::Ffi,
406            post_build: vec![],
407        })
408    }
409}
410
411/// Returns true if a field is a tuple struct positional field (e.g., `_0`, `_1`, `0`, `1`).
412fn is_tuple_field(field: &FieldDef) -> bool {
413    (field.name.starts_with('_') && field.name[1..].chars().all(|c| c.is_ascii_digit()))
414        || field.name.chars().next().is_none_or(|c| c.is_ascii_digit())
415}
416
417/// Strip trailing whitespace from every line and ensure the file ends with a single newline.
418fn strip_trailing_whitespace(content: &str) -> String {
419    let mut result: String = content
420        .lines()
421        .map(|line| line.trim_end())
422        .collect::<Vec<_>>()
423        .join("\n");
424    if !result.ends_with('\n') {
425        result.push('\n');
426    }
427    result
428}
429
430/// Generate C# file header with hash and nullable-enable pragma.
431fn csharp_file_header() -> String {
432    let mut out = hash::header(CommentStyle::DoubleSlash);
433    out.push_str("#nullable enable\n\n");
434    out
435}
436
437// ---------------------------------------------------------------------------
438// Helpers: P/Invoke return type mapping
439// ---------------------------------------------------------------------------
440
441/// Returns the C# type to use in a `[DllImport]` declaration for the given return type.
442///
443/// Key differences from the high-level `csharp_type`:
444/// - Bool is marshalled as `int` (C FFI convention) — the wrapper compares != 0.
445/// - String / Named / Vec / Map / Path / Json / Bytes all come back as `IntPtr`.
446/// - Numeric primitives use their natural C# types (`nuint`, `int`, etc.).
447fn pinvoke_return_type(ty: &TypeRef) -> &'static str {
448    match ty {
449        TypeRef::Unit => "void",
450        // Bool over FFI is a C int (0/1).
451        TypeRef::Primitive(PrimitiveType::Bool) => "int",
452        // Numeric primitives — use their real C# types.
453        TypeRef::Primitive(PrimitiveType::U8) => "byte",
454        TypeRef::Primitive(PrimitiveType::U16) => "ushort",
455        TypeRef::Primitive(PrimitiveType::U32) => "uint",
456        TypeRef::Primitive(PrimitiveType::U64) => "ulong",
457        TypeRef::Primitive(PrimitiveType::I8) => "sbyte",
458        TypeRef::Primitive(PrimitiveType::I16) => "short",
459        TypeRef::Primitive(PrimitiveType::I32) => "int",
460        TypeRef::Primitive(PrimitiveType::I64) => "long",
461        TypeRef::Primitive(PrimitiveType::F32) => "float",
462        TypeRef::Primitive(PrimitiveType::F64) => "double",
463        TypeRef::Primitive(PrimitiveType::Usize) => "ulong",
464        TypeRef::Primitive(PrimitiveType::Isize) => "long",
465        // Duration as u64
466        TypeRef::Duration => "ulong",
467        // Everything else is a pointer that needs manual marshalling.
468        TypeRef::String
469        | TypeRef::Char
470        | TypeRef::Bytes
471        | TypeRef::Optional(_)
472        | TypeRef::Vec(_)
473        | TypeRef::Map(_, _)
474        | TypeRef::Named(_)
475        | TypeRef::Path
476        | TypeRef::Json => "IntPtr",
477    }
478}
479
480/// Does the return type need IntPtr→string marshalling in the wrapper?
481fn returns_string(ty: &TypeRef) -> bool {
482    matches!(ty, TypeRef::String | TypeRef::Char | TypeRef::Path | TypeRef::Json)
483}
484
485/// Does the return type come back as a C int that should be converted to bool?
486fn returns_bool_via_int(ty: &TypeRef) -> bool {
487    matches!(ty, TypeRef::Primitive(PrimitiveType::Bool))
488}
489
490/// Does the return type need JSON deserialization from an IntPtr string?
491fn returns_json_object(ty: &TypeRef) -> bool {
492    matches!(
493        ty,
494        TypeRef::Vec(_) | TypeRef::Map(_, _) | TypeRef::Named(_) | TypeRef::Bytes | TypeRef::Optional(_)
495    )
496}
497
498/// Does this return type represent an opaque handle (Named struct type) that needs special marshalling?
499///
500/// Opaque handles are returned as `IntPtr` from P/Invoke.  The wrapper must call
501/// `{prefix}_{type_snake}_to_json(ptr)` to obtain a JSON string, then deserialise it,
502/// Returns the C# type to use for a parameter in a `[DllImport]` declaration.
503///
504/// Managed reference types (Named structs, Vec, Map, Bytes, Optional of Named, etc.)
505/// cannot be directly marshalled by P/Invoke.  They must be passed as `IntPtr` (opaque
506/// handle or JSON-string pointer).  Primitive types and plain strings use their natural
507/// types.
508fn pinvoke_param_type(ty: &TypeRef) -> &'static str {
509    match ty {
510        TypeRef::String | TypeRef::Char | TypeRef::Path | TypeRef::Json => "string",
511        // Managed objects — pass as opaque IntPtr (serialised to handle before call)
512        TypeRef::Named(_) | TypeRef::Vec(_) | TypeRef::Map(_, _) | TypeRef::Bytes | TypeRef::Optional(_) => "IntPtr",
513        TypeRef::Unit => "void",
514        TypeRef::Primitive(PrimitiveType::Bool) => "int",
515        TypeRef::Primitive(PrimitiveType::U8) => "byte",
516        TypeRef::Primitive(PrimitiveType::U16) => "ushort",
517        TypeRef::Primitive(PrimitiveType::U32) => "uint",
518        TypeRef::Primitive(PrimitiveType::U64) => "ulong",
519        TypeRef::Primitive(PrimitiveType::I8) => "sbyte",
520        TypeRef::Primitive(PrimitiveType::I16) => "short",
521        TypeRef::Primitive(PrimitiveType::I32) => "int",
522        TypeRef::Primitive(PrimitiveType::I64) => "long",
523        TypeRef::Primitive(PrimitiveType::F32) => "float",
524        TypeRef::Primitive(PrimitiveType::F64) => "double",
525        TypeRef::Primitive(PrimitiveType::Usize) => "ulong",
526        TypeRef::Primitive(PrimitiveType::Isize) => "long",
527        TypeRef::Duration => "ulong",
528    }
529}
530
531// ---------------------------------------------------------------------------
532// Code generation functions
533// ---------------------------------------------------------------------------
534
535/// Returns true if a parameter should be hidden from the public API because it is a
536/// trait-bridge param (e.g. the FFI visitor handle).
537fn is_bridge_param(
538    param: &alef_core::ir::ParamDef,
539    bridge_param_names: &HashSet<String>,
540    bridge_type_aliases: &HashSet<String>,
541) -> bool {
542    bridge_param_names.contains(&param.name)
543        || matches!(&param.ty, alef_core::ir::TypeRef::Named(n) if bridge_type_aliases.contains(n))
544}
545
546#[allow(clippy::too_many_arguments)]
547fn gen_native_methods(
548    api: &ApiSurface,
549    namespace: &str,
550    lib_name: &str,
551    prefix: &str,
552    bridge_param_names: &HashSet<String>,
553    bridge_type_aliases: &HashSet<String>,
554    has_visitor_callbacks: bool,
555    trait_bridges: &[alef_core::config::TraitBridgeConfig],
556    streaming_methods: &HashSet<String>,
557    exclude_functions: &HashSet<String>,
558    options_field_bridges: &[OptionsFieldBridgeInfo],
559) -> String {
560    let mut out = csharp_file_header();
561    out.push_str("using System;\n");
562    out.push_str("using System.Runtime.InteropServices;\n\n");
563
564    out.push_str(&format!("namespace {};\n\n", namespace));
565
566    out.push_str("internal static partial class NativeMethods\n{\n");
567    out.push_str(&format!("    private const string LibName = \"{}\";\n\n", lib_name));
568
569    // Track emitted C entry-point names to avoid duplicates when the same FFI
570    // function appears both as a free function and as a type method.
571    let mut emitted: HashSet<String> = HashSet::new();
572
573    // Enum type names — these are NOT opaque handles and must not have from_json / to_json / free
574    // helpers emitted for them.
575    let enum_names: HashSet<String> = api.enums.iter().map(|e| e.name.clone()).collect();
576
577    // Collect opaque struct type names that appear as parameters or return types so we can
578    // emit their from_json / to_json / free P/Invoke helpers.
579    // Enum types are excluded.
580    let mut opaque_param_types: HashSet<String> = HashSet::new();
581    let mut opaque_return_types: HashSet<String> = HashSet::new();
582
583    // Enums passed as parameters in any FFI function flow through *_from_json + *_free
584    // (the alef-backend-ffi side now emits these for param-passed enums). Treat them
585    // like opaque struct params so the DllImport entries get generated.
586    for func in api.functions.iter().filter(|f| !exclude_functions.contains(&f.name)) {
587        for param in &func.params {
588            if let TypeRef::Named(name) = &param.ty {
589                opaque_param_types.insert(name.clone());
590            }
591        }
592        if let TypeRef::Named(name) = &func.return_type {
593            if !enum_names.contains(name) {
594                opaque_return_types.insert(name.clone());
595            }
596        }
597    }
598    for typ in api.types.iter().filter(|typ| !typ.is_trait) {
599        for method in &typ.methods {
600            if streaming_methods.contains(&method.name) {
601                continue;
602            }
603            for param in &method.params {
604                if let TypeRef::Named(name) = &param.ty {
605                    opaque_param_types.insert(name.clone());
606                }
607            }
608            if let TypeRef::Named(name) = &method.return_type {
609                if !enum_names.contains(name) {
610                    opaque_return_types.insert(name.clone());
611                }
612            }
613        }
614    }
615
616    // Collect truly opaque types (is_opaque = true in IR) — these have no to_json/from_json FFI.
617    let true_opaque_types: HashSet<String> = api
618        .types
619        .iter()
620        .filter(|t| t.is_opaque)
621        .map(|t| t.name.clone())
622        .collect();
623
624    // Types used as options-field bridge options: their options handle is constructed via
625    // the builder/setter pattern (`{type}_default` + `{type}_apply_update` or
626    // `{type}_from_update`), not via `{type}_from_json`.  Skip emitting the stale
627    // `from_json` helper for these types; `free` is still needed.
628    let options_field_bridge_options_types: HashSet<&str> =
629        options_field_bridges.iter().map(|b| b.options_type.as_str()).collect();
630
631    // Emit from_json + free helpers for opaque types used as parameters.
632    // Truly opaque handles (is_opaque = true) have no from_json — only free.
633    // Types that are options-field bridge options types also skip from_json (builder pattern).
634    // E.g. `htm_conversion_options_from_json(const char *json) -> HTMConversionOptions*`
635    let mut sorted_param_types: Vec<&String> = opaque_param_types.iter().collect();
636    sorted_param_types.sort();
637    for type_name in sorted_param_types {
638        let snake = type_name.to_snake_case();
639        let is_options_field_type = options_field_bridge_options_types.contains(type_name.as_str());
640        if !true_opaque_types.contains(type_name) && !is_options_field_type {
641            let from_json_entry = format!("{prefix}_{snake}_from_json");
642            let from_json_cs = format!("{}FromJson", type_name.to_pascal_case());
643            if emitted.insert(from_json_entry.clone()) {
644                out.push_str(&format!(
645                    "    [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = \"{from_json_entry}\")]\n"
646                ));
647                out.push_str(&format!(
648                    "    internal static extern IntPtr {from_json_cs}([MarshalAs(UnmanagedType.LPStr)] string json);\n\n"
649                ));
650            }
651        }
652        let free_entry = format!("{prefix}_{snake}_free");
653        let free_cs = format!("{}Free", type_name.to_pascal_case());
654        if emitted.insert(free_entry.clone()) {
655            out.push_str(&format!(
656                "    [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = \"{free_entry}\")]\n"
657            ));
658            out.push_str(&format!("    internal static extern void {free_cs}(IntPtr ptr);\n\n"));
659        }
660    }
661
662    // Emit to_json + free helpers for opaque types returned from functions.
663    // Truly opaque handles (is_opaque = true) have no to_json — only free.
664    let mut sorted_return_types: Vec<&String> = opaque_return_types.iter().collect();
665    sorted_return_types.sort();
666    for type_name in sorted_return_types {
667        let snake = type_name.to_snake_case();
668        if !true_opaque_types.contains(type_name) {
669            let to_json_entry = format!("{prefix}_{snake}_to_json");
670            let to_json_cs = format!("{}ToJson", type_name.to_pascal_case());
671            if emitted.insert(to_json_entry.clone()) {
672                out.push_str(&format!(
673                    "    [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = \"{to_json_entry}\")]\n"
674                ));
675                out.push_str(&format!(
676                    "    internal static extern IntPtr {to_json_cs}(IntPtr ptr);\n\n"
677                ));
678            }
679        }
680        let free_entry = format!("{prefix}_{snake}_free");
681        let free_cs = format!("{}Free", type_name.to_pascal_case());
682        if emitted.insert(free_entry.clone()) {
683            out.push_str(&format!(
684                "    [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = \"{free_entry}\")]\n"
685            ));
686            out.push_str(&format!("    internal static extern void {free_cs}(IntPtr ptr);\n\n"));
687        }
688    }
689
690    // Generate P/Invoke declarations for functions
691    for func in api.functions.iter().filter(|f| !exclude_functions.contains(&f.name)) {
692        let c_func_name = format!("{}_{}", prefix, func.name.to_lowercase());
693        if emitted.insert(c_func_name.clone()) {
694            out.push_str(&gen_pinvoke_for_func(
695                &c_func_name,
696                func,
697                bridge_param_names,
698                bridge_type_aliases,
699            ));
700        }
701    }
702
703    // Generate P/Invoke declarations for type methods.
704    // Skip streaming adapter methods — their FFI signature uses callbacks that P/Invoke can't call.
705    for typ in api.types.iter().filter(|typ| !typ.is_trait) {
706        let type_snake = typ.name.to_snake_case();
707        for method in &typ.methods {
708            if streaming_methods.contains(&method.name) {
709                continue;
710            }
711            let c_method_name = format!("{}_{}_{}", prefix, type_snake, method.name.to_lowercase());
712            // Use a type-prefixed C# method name to avoid collisions when different types
713            // share a method with the same name (e.g. BrowserConfig::default and CrawlConfig::default
714            // would both produce "Default" without the prefix, but have different FFI entry points).
715            let cs_method_name = format!("{}{}", typ.name.to_pascal_case(), to_csharp_name(&method.name));
716            if emitted.insert(c_method_name.clone()) {
717                out.push_str(&gen_pinvoke_for_method(&c_method_name, &cs_method_name, method));
718            }
719        }
720    }
721
722    // Add error handling functions with PascalCase names
723    out.push_str(&format!(
724        "    [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = \"{prefix}_last_error_code\")]\n"
725    ));
726    out.push_str("    internal static extern int LastErrorCode();\n\n");
727
728    out.push_str(&format!(
729        "    [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = \"{prefix}_last_error_context\")]\n"
730    ));
731    out.push_str("    internal static extern IntPtr LastErrorContext();\n\n");
732
733    out.push_str(&format!(
734        "    [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = \"{prefix}_free_string\")]\n"
735    ));
736    out.push_str("    internal static extern void FreeString(IntPtr ptr);\n");
737
738    // Inject visitor create/free/convert P/Invoke declarations when a bridge is configured.
739    // When options-field bridges are active, `gen_native_methods_visitor` suppresses the
740    // three deleted legacy symbols (`_visitor_create`, `_visitor_free`, `_convert_with_visitor`).
741    if has_visitor_callbacks {
742        let has_options_field_bridge = !options_field_bridges.is_empty();
743        let visitor_decls =
744            crate::gen_visitor::gen_native_methods_visitor(namespace, lib_name, prefix, has_options_field_bridge);
745        if !visitor_decls.is_empty() {
746            out.push('\n');
747            out.push_str(&visitor_decls);
748        }
749    }
750
751    // Inject trait bridge registration/unregistration P/Invoke declarations.
752    if !trait_bridges.is_empty() {
753        // Collect trait definitions from api.types (by name) to match with trait_bridges config
754        let trait_defs: Vec<_> = api.types.iter().filter(|t| t.is_trait).collect();
755
756        // Build a list of (trait_name, bridge_config, trait_def) tuples for trait bridges
757        let bridges: Vec<_> = trait_bridges
758            .iter()
759            .filter_map(|config| {
760                let trait_name = config.trait_name.clone();
761                trait_defs
762                    .iter()
763                    .find(|t| t.name == trait_name)
764                    .map(|trait_def| (trait_name, config, *trait_def))
765            })
766            .collect();
767
768        if !bridges.is_empty() {
769            out.push('\n');
770            out.push_str(&crate::trait_bridge::gen_native_methods_trait_bridges(
771                namespace, prefix, &bridges,
772            ));
773        }
774    }
775
776    // Inject options-field bridge setter P/Invoke declarations.
777    // E.g. `htm_conversion_options_set_visitor(IntPtr options, IntPtr vtable)`.
778    for bridge in options_field_bridges {
779        let entry_point = bridge.ffi_symbol();
780        let cs_name = bridge.cs_setter_name();
781        out.push_str("\n    // Options-field bridge setter\n");
782        out.push_str(&format!(
783            "    [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = \"{entry_point}\")]\n"
784        ));
785        out.push_str(&format!(
786            "    internal static extern void {cs_name}(IntPtr options, IntPtr vtable);\n"
787        ));
788    }
789
790    out.push_str("}\n");
791
792    out
793}
794
795fn gen_pinvoke_for_func(
796    c_name: &str,
797    func: &FunctionDef,
798    bridge_param_names: &HashSet<String>,
799    bridge_type_aliases: &HashSet<String>,
800) -> String {
801    let cs_name = to_csharp_name(&func.name);
802    let mut out =
803        format!("    [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = \"{c_name}\")]\n");
804    out.push_str("    internal static extern ");
805
806    // Return type — use the correct P/Invoke type for each kind.
807    out.push_str(pinvoke_return_type(&func.return_type));
808
809    out.push_str(&format!(" {}(", cs_name));
810
811    // Filter bridge params — they are not visible in P/Invoke declarations; the wrapper
812    // passes IntPtr.Zero directly when calling the visitor-less FFI entry point.
813    let visible_params: Vec<_> = func
814        .params
815        .iter()
816        .filter(|p| !is_bridge_param(p, bridge_param_names, bridge_type_aliases))
817        .collect();
818
819    if visible_params.is_empty() {
820        out.push_str(");\n\n");
821    } else {
822        out.push('\n');
823        for (i, param) in visible_params.iter().enumerate() {
824            out.push_str("        ");
825            let pinvoke_ty = pinvoke_param_type(&param.ty);
826            if pinvoke_ty == "string" {
827                out.push_str("[MarshalAs(UnmanagedType.LPStr)] ");
828            }
829            let param_name = param.name.to_lower_camel_case();
830            out.push_str(&format!("{pinvoke_ty} {param_name}"));
831
832            if i < visible_params.len() - 1 {
833                out.push(',');
834            }
835            out.push('\n');
836        }
837        out.push_str("    );\n\n");
838    }
839
840    out
841}
842
843fn gen_pinvoke_for_method(c_name: &str, cs_name: &str, method: &MethodDef) -> String {
844    let mut out =
845        format!("    [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = \"{c_name}\")]\n");
846    out.push_str("    internal static extern ");
847
848    // Return type — use the correct P/Invoke type for each kind.
849    out.push_str(pinvoke_return_type(&method.return_type));
850
851    out.push_str(&format!(" {}(", cs_name));
852
853    // Non-static methods take the receiver as the first FFI parameter (the
854    // generated extern "C" fn signature is `fn (this: *const T, ...)`). Prepend
855    // an `IntPtr handle` here so the P/Invoke signature matches; without this
856    // the C# wrapper falls one argument short and the runtime throws
857    // EntryPointNotFoundException / the C# compiler rejects the call site.
858    let has_receiver = !method.is_static && method.receiver.is_some();
859
860    if !has_receiver && method.params.is_empty() {
861        out.push_str(");\n\n");
862    } else {
863        out.push('\n');
864        let total = if has_receiver {
865            method.params.len() + 1
866        } else {
867            method.params.len()
868        };
869        let mut idx = 0usize;
870        if has_receiver {
871            out.push_str("        IntPtr handle");
872            if total > 1 {
873                out.push(',');
874            }
875            out.push('\n');
876            idx += 1;
877        }
878        for param in method.params.iter() {
879            out.push_str("        ");
880            let pinvoke_ty = pinvoke_param_type(&param.ty);
881            if pinvoke_ty == "string" {
882                out.push_str("[MarshalAs(UnmanagedType.LPStr)] ");
883            }
884            let param_name = param.name.to_lower_camel_case();
885            out.push_str(&format!("{pinvoke_ty} {param_name}"));
886
887            if idx < total - 1 {
888                out.push(',');
889            }
890            out.push('\n');
891            idx += 1;
892        }
893        out.push_str("    );\n\n");
894    }
895
896    out
897}
898
899fn gen_exception_class(namespace: &str, class_name: &str) -> String {
900    let mut out = csharp_file_header();
901    out.push_str("using System;\n\n");
902
903    out.push_str(&format!("namespace {};\n\n", namespace));
904
905    out.push_str(&format!("public class {} : Exception\n", class_name));
906    out.push_str("{\n");
907    out.push_str("    public int Code { get; }\n\n");
908    out.push_str(&format!(
909        "    public {}(int code, string message) : base(message)\n",
910        class_name
911    ));
912    out.push_str("    {\n");
913    out.push_str("        Code = code;\n");
914    out.push_str("    }\n");
915    out.push_str("}\n");
916
917    out
918}
919
920#[allow(clippy::too_many_arguments)]
921fn gen_wrapper_class(
922    api: &ApiSurface,
923    namespace: &str,
924    class_name: &str,
925    exception_name: &str,
926    prefix: &str,
927    bridge_param_names: &HashSet<String>,
928    bridge_type_aliases: &HashSet<String>,
929    has_visitor_callbacks: bool,
930    streaming_methods: &HashSet<String>,
931    exclude_functions: &HashSet<String>,
932    options_field_bridges: &[OptionsFieldBridgeInfo],
933) -> String {
934    let mut out = csharp_file_header();
935    out.push_str("using System;\n");
936    out.push_str("using System.Collections.Generic;\n");
937    out.push_str("using System.Runtime.InteropServices;\n");
938    out.push_str("using System.Text.Json;\n");
939    out.push_str("using System.Text.Json.Serialization;\n");
940    out.push_str("using System.Threading.Tasks;\n\n");
941
942    out.push_str(&format!("namespace {};\n\n", namespace));
943
944    out.push_str(&format!("public static class {}\n", class_name));
945    out.push_str("{\n");
946    out.push_str("    private static readonly JsonSerializerOptions JsonOptions = new()\n");
947    out.push_str("    {\n");
948    out.push_str("        Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) },\n");
949    out.push_str("        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault\n");
950    out.push_str("    };\n\n");
951
952    // Enum names: used to distinguish opaque struct handles from enum return types.
953    let enum_names: HashSet<String> = api.enums.iter().map(|e| e.name.to_pascal_case()).collect();
954
955    // Truly opaque types (is_opaque = true) — returned/passed as handles, no JSON serialization.
956    let true_opaque_types: HashSet<String> = api
957        .types
958        .iter()
959        .filter(|t| t.is_opaque)
960        .map(|t| t.name.clone())
961        .collect();
962
963    // Generate wrapper methods for functions
964    for func in api.functions.iter().filter(|f| !exclude_functions.contains(&f.name)) {
965        // Check whether this function carries an options-field bridge parameter.
966        let bridge_info = options_field_bridges.iter().find(|b| {
967            func.params.iter().any(|p| {
968                let type_name = match &p.ty {
969                    TypeRef::Named(n) => Some(n.as_str()),
970                    TypeRef::Optional(inner) => {
971                        if let TypeRef::Named(n) = inner.as_ref() {
972                            Some(n.as_str())
973                        } else {
974                            None
975                        }
976                    }
977                    _ => None,
978                };
979                type_name == Some(b.options_type.as_str())
980            })
981        });
982        out.push_str(&gen_wrapper_function(
983            func,
984            exception_name,
985            prefix,
986            &enum_names,
987            &true_opaque_types,
988            bridge_param_names,
989            bridge_type_aliases,
990            bridge_info,
991        ));
992    }
993
994    // Generate wrapper methods for type methods (prefixed with type name to avoid collisions).
995    // Skip streaming adapter methods — their FFI signature uses callbacks that P/Invoke can't call.
996    for typ in api.types.iter().filter(|typ| !typ.is_trait) {
997        // Skip opaque types — their methods belong on the opaque handle class, not the static wrapper
998        if typ.is_opaque {
999            continue;
1000        }
1001        for method in &typ.methods {
1002            if streaming_methods.contains(&method.name) {
1003                continue;
1004            }
1005            out.push_str(&gen_wrapper_method(
1006                method,
1007                exception_name,
1008                prefix,
1009                &typ.name,
1010                &enum_names,
1011                &true_opaque_types,
1012                bridge_param_names,
1013                bridge_type_aliases,
1014            ));
1015        }
1016    }
1017
1018    // Inject ConvertWithVisitor only when visitor_callbacks is enabled AND no options-field
1019    // bridge is active.  In options-field mode the visitor is folded onto ConversionOptions
1020    // and the standard Convert wrapper already handles it — ConvertWithVisitor is not needed
1021    // and its legacy FFI entry-point (`_convert_with_visitor`) has been removed.
1022    let has_options_field_bridge = !options_field_bridges.is_empty();
1023    if has_visitor_callbacks && !has_options_field_bridge {
1024        out.push_str(&crate::gen_visitor::gen_convert_with_visitor_method(
1025            exception_name,
1026            prefix,
1027        ));
1028    }
1029
1030    // Add error handling helper
1031    out.push_str("    private static ");
1032    out.push_str(&format!("{} GetLastError()\n", exception_name));
1033    out.push_str("    {\n");
1034    out.push_str("        var code = NativeMethods.LastErrorCode();\n");
1035    out.push_str("        var ctxPtr = NativeMethods.LastErrorContext();\n");
1036    out.push_str("        var message = Marshal.PtrToStringAnsi(ctxPtr) ?? \"Unknown error\";\n");
1037    out.push_str(&format!("        return new {}(code, message);\n", exception_name));
1038    out.push_str("    }\n");
1039
1040    out.push_str("}\n");
1041
1042    out
1043}
1044
1045// ---------------------------------------------------------------------------
1046// Helpers: Named-param setup/teardown for opaque handle marshalling
1047// ---------------------------------------------------------------------------
1048
1049/// For each `Named` parameter, emit code to serialise it to JSON and obtain a native handle.
1050///
1051/// For truly opaque types (is_opaque = true), the C# class already wraps the native handle, so
1052/// we pass `param.Handle` directly without any JSON serialisation.
1053///
1054/// ```text
1055/// // Data struct (has from_json):
1056/// var optionsJson = JsonSerializer.Serialize(options);
1057/// var optionsHandle = NativeMethods.ConversionOptionsFromJson(optionsJson);
1058///
1059/// // Truly opaque handle: passed as engineHandle.Handle directly — no setup needed.
1060/// ```
1061fn emit_named_param_setup(
1062    out: &mut String,
1063    params: &[alef_core::ir::ParamDef],
1064    indent: &str,
1065    true_opaque_types: &HashSet<String>,
1066) {
1067    for param in params {
1068        let param_name = param.name.to_lower_camel_case();
1069        let json_var = format!("{param_name}Json");
1070        let handle_var = format!("{param_name}Handle");
1071
1072        match &param.ty {
1073            TypeRef::Named(type_name) => {
1074                // Truly opaque handles: the C# wrapper class holds the IntPtr directly.
1075                // No from_json round-trip needed — pass .Handle directly in native_call_arg.
1076                if true_opaque_types.contains(type_name) {
1077                    continue;
1078                }
1079                let from_json_method = format!("{}FromJson", type_name.to_pascal_case());
1080                if param.optional {
1081                    out.push_str(&format!(
1082                        "{indent}var {json_var} = {param_name} != null ? JsonSerializer.Serialize({param_name}, JsonOptions) : \"null\";\n"
1083                    ));
1084                } else {
1085                    out.push_str(&format!(
1086                        "{indent}var {json_var} = JsonSerializer.Serialize({param_name}, JsonOptions);\n"
1087                    ));
1088                }
1089                out.push_str(&format!(
1090                    "{indent}var {handle_var} = NativeMethods.{from_json_method}({json_var});\n"
1091                ));
1092            }
1093            TypeRef::Vec(_) | TypeRef::Map(_, _) => {
1094                // Vec/Map: serialize to JSON string, marshal to native pointer
1095                out.push_str(&format!(
1096                    "{indent}var {json_var} = JsonSerializer.Serialize({param_name}, JsonOptions);\n"
1097                ));
1098                out.push_str(&format!(
1099                    "{indent}var {handle_var} = Marshal.StringToHGlobalAnsi({json_var});\n"
1100                ));
1101            }
1102            TypeRef::Bytes => {
1103                // byte[]: pin the managed array and pass pointer to native
1104                out.push_str(&format!(
1105                    "{indent}var {handle_var} = GCHandle.Alloc({param_name}, GCHandleType.Pinned);\n"
1106                ));
1107            }
1108            _ => {}
1109        }
1110    }
1111}
1112
1113/// Like [`emit_named_param_setup`] but skips params whose camelCase name is in `exclude`.
1114///
1115/// Used by `gen_wrapper_function` to skip options params that are handled by the
1116/// options-field bridge inline path (which uses `{type}FromUpdate` instead of `{type}FromJson`).
1117fn emit_named_param_setup_excluding(
1118    out: &mut String,
1119    params: &[alef_core::ir::ParamDef],
1120    indent: &str,
1121    true_opaque_types: &HashSet<String>,
1122    exclude: &HashSet<String>,
1123) {
1124    let filtered: Vec<alef_core::ir::ParamDef> = params
1125        .iter()
1126        .filter(|p| !exclude.contains(&p.name.to_lower_camel_case()))
1127        .cloned()
1128        .collect();
1129    emit_named_param_setup(out, &filtered, indent, true_opaque_types);
1130}
1131
1132/// Returns true if the FFI return type is a pointer (IntPtr), as opposed to a numeric value.
1133/// Only pointer-returning functions use `IntPtr.Zero` as an error sentinel.
1134fn returns_ptr(ty: &TypeRef) -> bool {
1135    matches!(
1136        ty,
1137        TypeRef::String
1138            | TypeRef::Char
1139            | TypeRef::Path
1140            | TypeRef::Json
1141            | TypeRef::Named(_)
1142            | TypeRef::Vec(_)
1143            | TypeRef::Map(_, _)
1144            | TypeRef::Bytes
1145            | TypeRef::Optional(_)
1146    )
1147}
1148
1149/// Returns the argument expression to pass to the native method for a given parameter.
1150///
1151/// For truly opaque types (is_opaque = true), the C# class wraps an IntPtr; pass `.Handle`.
1152/// For data-struct `Named` types this is the handle variable (e.g. `optionsHandle`).
1153/// For everything else it is the parameter name (with `!` for optional).
1154fn native_call_arg(ty: &TypeRef, param_name: &str, optional: bool, true_opaque_types: &HashSet<String>) -> String {
1155    match ty {
1156        TypeRef::Named(type_name) if true_opaque_types.contains(type_name) => {
1157            // Truly opaque: unwrap the IntPtr from the C# handle class.
1158            let bang = if optional { "!" } else { "" };
1159            format!("{param_name}{bang}.Handle")
1160        }
1161        TypeRef::Named(_) | TypeRef::Vec(_) | TypeRef::Map(_, _) => {
1162            format!("{param_name}Handle")
1163        }
1164        TypeRef::Bytes => {
1165            format!("{param_name}Handle.AddrOfPinnedObject()")
1166        }
1167        TypeRef::Primitive(alef_core::ir::PrimitiveType::Bool) => {
1168            // FFI convention: bool marshalled as int (0 = false, non-zero = true)
1169            if optional {
1170                format!("({param_name}?.Value ? 1 : 0)")
1171            } else {
1172                format!("({param_name} ? 1 : 0)")
1173            }
1174        }
1175        ty => {
1176            if optional {
1177                // For optional primitive types (e.g. ulong?, uint?), use GetValueOrDefault()
1178                // to safely unwrap with a default of 0 if null. String/Char/Path/Json are
1179                // reference types so `!` is correct for those.
1180                let needs_value_unwrap = matches!(ty, TypeRef::Primitive(_) | TypeRef::Duration);
1181                if needs_value_unwrap {
1182                    format!("{param_name}.GetValueOrDefault()")
1183                } else {
1184                    format!("{param_name}!")
1185                }
1186            } else {
1187                param_name.to_string()
1188            }
1189        }
1190    }
1191}
1192
1193/// Emit cleanup code to free native handles allocated for `Named` parameters.
1194///
1195/// Truly opaque handles (is_opaque = true) are NOT freed here — their lifetime is managed by
1196/// the C# wrapper class (IDisposable). Only data-struct handles (from_json-allocated) are freed.
1197fn emit_named_param_teardown(
1198    out: &mut String,
1199    params: &[alef_core::ir::ParamDef],
1200    true_opaque_types: &HashSet<String>,
1201) {
1202    for param in params {
1203        let param_name = param.name.to_lower_camel_case();
1204        let handle_var = format!("{param_name}Handle");
1205        match &param.ty {
1206            TypeRef::Named(type_name) => {
1207                if true_opaque_types.contains(type_name) {
1208                    // Caller owns the opaque handle — do not free it here.
1209                    continue;
1210                }
1211                let free_method = format!("{}Free", type_name.to_pascal_case());
1212                out.push_str(&format!("        NativeMethods.{free_method}({handle_var});\n"));
1213            }
1214            TypeRef::Vec(_) | TypeRef::Map(_, _) => {
1215                out.push_str(&format!("        Marshal.FreeHGlobal({handle_var});\n"));
1216            }
1217            TypeRef::Bytes => {
1218                out.push_str(&format!("        {handle_var}.Free();\n"));
1219            }
1220            _ => {}
1221        }
1222    }
1223}
1224
1225/// Emit cleanup code with configurable indentation (used inside `Task.Run` lambdas).
1226fn emit_named_param_teardown_indented(
1227    out: &mut String,
1228    params: &[alef_core::ir::ParamDef],
1229    indent: &str,
1230    true_opaque_types: &HashSet<String>,
1231) {
1232    for param in params {
1233        let param_name = param.name.to_lower_camel_case();
1234        let handle_var = format!("{param_name}Handle");
1235        match &param.ty {
1236            TypeRef::Named(type_name) => {
1237                if true_opaque_types.contains(type_name) {
1238                    // Caller owns the opaque handle — do not free it here.
1239                    continue;
1240                }
1241                let free_method = format!("{}Free", type_name.to_pascal_case());
1242                out.push_str(&format!("{indent}NativeMethods.{free_method}({handle_var});\n"));
1243            }
1244            TypeRef::Vec(_) | TypeRef::Map(_, _) => {
1245                out.push_str(&format!("{indent}Marshal.FreeHGlobal({handle_var});\n"));
1246            }
1247            TypeRef::Bytes => {
1248                out.push_str(&format!("{indent}{handle_var}.Free();\n"));
1249            }
1250            _ => {}
1251        }
1252    }
1253}
1254
1255#[allow(clippy::too_many_arguments)]
1256fn gen_wrapper_function(
1257    func: &FunctionDef,
1258    _exception_name: &str,
1259    _prefix: &str,
1260    enum_names: &HashSet<String>,
1261    true_opaque_types: &HashSet<String>,
1262    bridge_param_names: &HashSet<String>,
1263    bridge_type_aliases: &HashSet<String>,
1264    options_field_bridge: Option<&OptionsFieldBridgeInfo>,
1265) -> String {
1266    let mut out = String::with_capacity(1024);
1267
1268    // Collect visible params (non-bridge) for the public C# signature.
1269    let visible_params: Vec<alef_core::ir::ParamDef> = func
1270        .params
1271        .iter()
1272        .filter(|p| !is_bridge_param(p, bridge_param_names, bridge_type_aliases))
1273        .cloned()
1274        .collect();
1275
1276    // XML doc comment using shared doc emission
1277    doc_emission::emit_csharp_doc(&mut out, &func.doc, "    ");
1278    for param in &visible_params {
1279        if !func.doc.is_empty() {
1280            out.push_str(&format!(
1281                "    /// <param name=\"{}\">{}</param>\n",
1282                param.name.to_lower_camel_case(),
1283                if param.optional { "Optional." } else { "" }
1284            ));
1285        }
1286    }
1287
1288    out.push_str("    public static ");
1289
1290    // Return type — use async Task<T> for async methods
1291    if func.is_async {
1292        if func.return_type == TypeRef::Unit {
1293            out.push_str("async Task");
1294        } else {
1295            out.push_str(&format!("async Task<{}>", csharp_type(&func.return_type)));
1296        }
1297    } else if func.return_type == TypeRef::Unit {
1298        out.push_str("void");
1299    } else {
1300        out.push_str(&csharp_type(&func.return_type));
1301    }
1302
1303    out.push_str(&format!(" {}", to_csharp_name(&func.name)));
1304    out.push('(');
1305
1306    // Parameters (bridge params stripped from public signature)
1307    for (i, param) in visible_params.iter().enumerate() {
1308        let param_name = param.name.to_lower_camel_case();
1309        let mapped = csharp_type(&param.ty);
1310        if param.optional && !mapped.ends_with('?') {
1311            out.push_str(&format!("{mapped}? {param_name}"));
1312        } else {
1313            out.push_str(&format!("{mapped} {param_name}"));
1314        }
1315
1316        if i < visible_params.len() - 1 {
1317            out.push_str(", ");
1318        }
1319    }
1320
1321    out.push_str(")\n    {\n");
1322
1323    // Null checks for required string/object parameters
1324    for param in &visible_params {
1325        if !param.optional && matches!(param.ty, TypeRef::String | TypeRef::Named(_) | TypeRef::Bytes) {
1326            let param_name = param.name.to_lower_camel_case();
1327            out.push_str(&format!("        ArgumentNullException.ThrowIfNull({param_name});\n"));
1328        }
1329    }
1330
1331    // When an options-field bridge is active, identify the options param so we can bypass
1332    // the stale `{type}FromJson` path (which no longer exists in the FFI) and instead use
1333    // the builder/update pattern: `{type}Default()` or `{type}FromUpdate(updateHandle)`.
1334    let bridge_options_param: Option<(&alef_core::ir::ParamDef, &OptionsFieldBridgeInfo)> =
1335        if let Some(bridge) = options_field_bridge {
1336            visible_params
1337                .iter()
1338                .find(|p| {
1339                    let type_name = match &p.ty {
1340                        TypeRef::Named(n) => Some(n.as_str()),
1341                        TypeRef::Optional(inner) => {
1342                            if let TypeRef::Named(n) = inner.as_ref() {
1343                                Some(n.as_str())
1344                            } else {
1345                                None
1346                            }
1347                        }
1348                        _ => None,
1349                    };
1350                    type_name == Some(bridge.options_type.as_str())
1351                })
1352                .map(|p| (p, bridge))
1353        } else {
1354            None
1355        };
1356
1357    // Params to skip in the standard emit_named_param_setup — handled inline below.
1358    let skip_param_names: HashSet<String> = bridge_options_param
1359        .iter()
1360        .map(|(p, _)| p.name.to_lower_camel_case())
1361        .collect();
1362
1363    // Serialize Named (opaque handle) params to JSON and obtain native handles.
1364    // Skip any params handled by the options-field bridge inline path below.
1365    emit_named_param_setup_excluding(
1366        &mut out,
1367        &visible_params,
1368        "        ",
1369        true_opaque_types,
1370        &skip_param_names,
1371    );
1372
1373    // Options-field bridge: build the options handle via the update-from-JSON path
1374    // (`{type}FromUpdate` + `{type}UpdateFree`) then attach the visitor bridge via
1375    // the FFI setter (`{type}Set{Field}`).  This replaces the deleted `{type}FromJson`
1376    // helper that the standard path would otherwise call.
1377    if let Some((opts_param, bridge)) = bridge_options_param {
1378        let opts_name = opts_param.name.to_lower_camel_case();
1379        let opts_handle = format!("{opts_name}Handle");
1380        let opts_pascal = bridge.options_type.to_pascal_case();
1381        let update_from_json_method = format!("{}UpdateFromJson", opts_pascal);
1382        let from_update_method = format!("{}FromUpdate", opts_pascal);
1383        let update_free_method = format!("{}UpdateFree", opts_pascal);
1384        let default_method = format!("{}Default", opts_pascal);
1385        let field_pascal = bridge.field_name.to_pascal_case();
1386        let _bridge_type = &bridge.bridge_cs_type;
1387        let setter = bridge.cs_setter_name();
1388
1389        // Build the options handle from the C# options object (if non-null).
1390        if opts_param.optional {
1391            out.push_str("        // options-field bridge: build options handle via update pattern\n");
1392            out.push_str(&format!("        var {opts_handle} = IntPtr.Zero;\n"));
1393            out.push_str(&format!("        if ({opts_name} != null)\n        {{\n"));
1394            out.push_str(&format!(
1395                "            var {opts_name}Json = JsonSerializer.Serialize({opts_name}, JsonOptions);\n"
1396            ));
1397            out.push_str(&format!(
1398                "            var {opts_name}UpdateHandle = NativeMethods.{update_from_json_method}({opts_name}Json);\n"
1399            ));
1400            out.push_str(&format!(
1401                "            {opts_handle} = NativeMethods.{from_update_method}({opts_name}UpdateHandle);\n"
1402            ));
1403            out.push_str(&format!(
1404                "            NativeMethods.{update_free_method}({opts_name}UpdateHandle);\n"
1405            ));
1406            out.push_str("        }\n");
1407            out.push_str("        else\n        {\n");
1408            out.push_str(&format!(
1409                "            {opts_handle} = NativeMethods.{default_method}();\n"
1410            ));
1411            out.push_str("        }\n");
1412        } else {
1413            out.push_str("        // options-field bridge: build options handle via update pattern\n");
1414            out.push_str(&format!(
1415                "        var {opts_name}Json = JsonSerializer.Serialize({opts_name}, JsonOptions);\n"
1416            ));
1417            out.push_str(&format!(
1418                "        var {opts_name}UpdateHandle = NativeMethods.{update_from_json_method}({opts_name}Json);\n"
1419            ));
1420            out.push_str(&format!(
1421                "        var {opts_handle} = NativeMethods.{from_update_method}({opts_name}UpdateHandle);\n"
1422            ));
1423            out.push_str(&format!(
1424                "        NativeMethods.{update_free_method}({opts_name}UpdateHandle);\n"
1425            ));
1426        }
1427
1428        // Attach the visitor bridge to the options handle when the visitor is set.
1429        // `{opts_name}.{field_pascal}` is already a `{bridge_type}Bridge` — access _vtable directly
1430        // without double-wrapping.
1431        if opts_param.optional {
1432            out.push_str("        // options-field bridge: attach visitor when present\n");
1433            out.push_str(&format!(
1434                "        if ({opts_name} != null && {opts_name}.{field_pascal} != null)\n        {{\n"
1435            ));
1436            out.push_str(&format!(
1437                "            NativeMethods.{setter}({opts_handle}, {opts_name}.{field_pascal}._vtable);\n"
1438            ));
1439            out.push_str("        }\n");
1440        } else {
1441            out.push_str("        // options-field bridge: attach visitor when present\n");
1442            out.push_str(&format!(
1443                "        if ({opts_name}.{field_pascal} != null)\n        {{\n"
1444            ));
1445            out.push_str(&format!(
1446                "            NativeMethods.{setter}({opts_handle}, {opts_name}.{field_pascal}._vtable);\n"
1447            ));
1448            out.push_str("        }\n");
1449        }
1450
1451        // The options handle free is handled by emit_named_param_teardown since we
1452        // preserved the `{opts_name}Handle` variable name matching what teardown expects.
1453        let _ = default_method; // used implicitly via opts_handle in teardown
1454    }
1455
1456    // Method body - delegation to native method with proper marshalling
1457    let cs_native_name = to_csharp_name(&func.name);
1458
1459    if func.is_async {
1460        // Async: wrap in Task.Run for non-blocking execution. CS1997 disallows
1461        // `return await Task.Run(...)` in an `async Task` (non-generic) method,
1462        // so for unit returns we drop the `return`.
1463        if func.return_type == TypeRef::Unit {
1464            out.push_str("        await Task.Run(() =>\n        {\n");
1465        } else {
1466            out.push_str("        return await Task.Run(() =>\n        {\n");
1467        }
1468
1469        if func.return_type != TypeRef::Unit {
1470            out.push_str("            var nativeResult = ");
1471        } else {
1472            out.push_str("            ");
1473        }
1474
1475        out.push_str(&format!("NativeMethods.{}(", cs_native_name));
1476
1477        if visible_params.is_empty() {
1478            out.push_str(");\n");
1479        } else {
1480            out.push('\n');
1481            for (i, param) in visible_params.iter().enumerate() {
1482                let param_name = param.name.to_lower_camel_case();
1483                let arg = native_call_arg(&param.ty, &param_name, param.optional, true_opaque_types);
1484                out.push_str(&format!("                {arg}"));
1485                if i < visible_params.len() - 1 {
1486                    out.push(',');
1487                }
1488                out.push('\n');
1489            }
1490            out.push_str("            );\n");
1491        }
1492
1493        // Check for FFI error (null result means the call failed).
1494        if func.return_type != TypeRef::Unit {
1495            out.push_str(
1496                "            if (nativeResult == IntPtr.Zero)\n            {\n                var err = GetLastError();\n                if (err.Code != 0)\n                {\n                    throw err;\n                }\n            }\n",
1497            );
1498        }
1499
1500        emit_return_marshalling_indented(
1501            &mut out,
1502            &func.return_type,
1503            "            ",
1504            enum_names,
1505            true_opaque_types,
1506        );
1507        emit_named_param_teardown_indented(&mut out, &visible_params, "            ", true_opaque_types);
1508        emit_return_statement_indented(&mut out, &func.return_type, "            ");
1509        out.push_str("        });\n");
1510    } else {
1511        if func.return_type != TypeRef::Unit {
1512            out.push_str("        var nativeResult = ");
1513        } else {
1514            out.push_str("        ");
1515        }
1516
1517        out.push_str(&format!("NativeMethods.{}(", cs_native_name));
1518
1519        if visible_params.is_empty() {
1520            out.push_str(");\n");
1521        } else {
1522            out.push('\n');
1523            for (i, param) in visible_params.iter().enumerate() {
1524                let param_name = param.name.to_lower_camel_case();
1525                let arg = native_call_arg(&param.ty, &param_name, param.optional, true_opaque_types);
1526                out.push_str(&format!("            {arg}"));
1527                if i < visible_params.len() - 1 {
1528                    out.push(',');
1529                }
1530                out.push('\n');
1531            }
1532            out.push_str("        );\n");
1533        }
1534
1535        // Check for FFI error (null result means the call failed).
1536        // Only emit for pointer-returning functions — numeric returns (ulong, uint, bool)
1537        // don't use IntPtr.Zero as an error sentinel.
1538        if func.return_type != TypeRef::Unit && returns_ptr(&func.return_type) {
1539            out.push_str(
1540                "        if (nativeResult == IntPtr.Zero)\n        {\n            var err = GetLastError();\n            if (err.Code != 0)\n            {\n                throw err;\n            }\n        }\n",
1541            );
1542        }
1543
1544        emit_return_marshalling(&mut out, &func.return_type, enum_names, true_opaque_types);
1545        emit_named_param_teardown(&mut out, &visible_params, true_opaque_types);
1546        emit_return_statement(&mut out, &func.return_type);
1547    }
1548
1549    out.push_str("    }\n\n");
1550
1551    out
1552}
1553
1554#[allow(clippy::too_many_arguments)]
1555fn gen_wrapper_method(
1556    method: &MethodDef,
1557    _exception_name: &str,
1558    _prefix: &str,
1559    type_name: &str,
1560    enum_names: &HashSet<String>,
1561    true_opaque_types: &HashSet<String>,
1562    bridge_param_names: &HashSet<String>,
1563    bridge_type_aliases: &HashSet<String>,
1564) -> String {
1565    let mut out = String::with_capacity(1024);
1566
1567    // Collect visible params (non-bridge) for the public C# signature.
1568    let visible_params: Vec<alef_core::ir::ParamDef> = method
1569        .params
1570        .iter()
1571        .filter(|p| !is_bridge_param(p, bridge_param_names, bridge_type_aliases))
1572        .cloned()
1573        .collect();
1574
1575    // XML doc comment using shared doc emission
1576    doc_emission::emit_csharp_doc(&mut out, &method.doc, "    ");
1577    for param in &visible_params {
1578        if !method.doc.is_empty() {
1579            out.push_str(&format!(
1580                "    /// <param name=\"{}\">{}</param>\n",
1581                param.name.to_lower_camel_case(),
1582                if param.optional { "Optional." } else { "" }
1583            ));
1584        }
1585    }
1586
1587    // The wrapper class is always `static class`, so all methods must be static.
1588    out.push_str("    public static ");
1589
1590    // Return type — use async Task<T> for async methods
1591    if method.is_async {
1592        if method.return_type == TypeRef::Unit {
1593            out.push_str("async Task");
1594        } else {
1595            out.push_str(&format!("async Task<{}>", csharp_type(&method.return_type)));
1596        }
1597    } else if method.return_type == TypeRef::Unit {
1598        out.push_str("void");
1599    } else {
1600        out.push_str(&csharp_type(&method.return_type));
1601    }
1602
1603    // Prefix method name with type name to avoid collisions (e.g., MetadataConfigDefault)
1604    let method_cs_name = format!("{}{}", type_name, to_csharp_name(&method.name));
1605    out.push_str(&format!(" {method_cs_name}"));
1606    out.push('(');
1607
1608    // Non-static methods need a `handle` parameter that the wrapper threads to
1609    // the native receiver. Without this, the public method has no way to refer
1610    // to the instance and calls NativeMethods.{Method}() one argument short.
1611    let has_receiver = !method.is_static && method.receiver.is_some();
1612    if has_receiver {
1613        out.push_str("IntPtr handle");
1614        if !visible_params.is_empty() {
1615            out.push_str(", ");
1616        }
1617    }
1618
1619    // Parameters (bridge params stripped from public signature)
1620    for (i, param) in visible_params.iter().enumerate() {
1621        let param_name = param.name.to_lower_camel_case();
1622        let mapped = csharp_type(&param.ty);
1623        if param.optional && !mapped.ends_with('?') {
1624            out.push_str(&format!("{mapped}? {param_name}"));
1625        } else {
1626            out.push_str(&format!("{mapped} {param_name}"));
1627        }
1628
1629        if i < visible_params.len() - 1 {
1630            out.push_str(", ");
1631        }
1632    }
1633
1634    out.push_str(")\n    {\n");
1635
1636    // Null checks for required string/object parameters
1637    for param in &visible_params {
1638        if !param.optional && matches!(param.ty, TypeRef::String | TypeRef::Named(_) | TypeRef::Bytes) {
1639            let param_name = param.name.to_lower_camel_case();
1640            out.push_str(&format!("        ArgumentNullException.ThrowIfNull({param_name});\n"));
1641        }
1642    }
1643
1644    // Serialize Named (opaque handle) params to JSON and obtain native handles.
1645    emit_named_param_setup(&mut out, &visible_params, "        ", true_opaque_types);
1646
1647    // Method body - delegation to native method with proper marshalling.
1648    // Use the type-prefixed name to match the P/Invoke declaration, which includes the type
1649    // name to avoid collisions between different types with identically-named methods
1650    // (e.g. BrowserConfig::default and CrawlConfig::default).
1651    let cs_native_name = format!("{}{}", type_name.to_pascal_case(), to_csharp_name(&method.name));
1652
1653    if method.is_async {
1654        // Async: wrap in Task.Run. For unit returns drop the `return` so CS1997 (async Task
1655        // method can't `return await` of non-generic Task) does not fire.
1656        if method.return_type == TypeRef::Unit {
1657            out.push_str("        await Task.Run(() =>\n        {\n");
1658        } else {
1659            out.push_str("        return await Task.Run(() =>\n        {\n");
1660        }
1661
1662        if method.return_type != TypeRef::Unit {
1663            out.push_str("            var nativeResult = ");
1664        } else {
1665            out.push_str("            ");
1666        }
1667
1668        out.push_str(&format!("NativeMethods.{}(", cs_native_name));
1669
1670        if !has_receiver && visible_params.is_empty() {
1671            out.push_str(");\n");
1672        } else {
1673            out.push('\n');
1674            let total = if has_receiver {
1675                visible_params.len() + 1
1676            } else {
1677                visible_params.len()
1678            };
1679            let mut idx = 0usize;
1680            if has_receiver {
1681                out.push_str("                handle");
1682                if total > 1 {
1683                    out.push(',');
1684                }
1685                out.push('\n');
1686                idx += 1;
1687            }
1688            for param in visible_params.iter() {
1689                let param_name = param.name.to_lower_camel_case();
1690                let arg = native_call_arg(&param.ty, &param_name, param.optional, true_opaque_types);
1691                out.push_str(&format!("                {arg}"));
1692                if idx < total - 1 {
1693                    out.push(',');
1694                }
1695                out.push('\n');
1696                idx += 1;
1697            }
1698            out.push_str("            );\n");
1699        }
1700
1701        emit_return_marshalling_indented(
1702            &mut out,
1703            &method.return_type,
1704            "            ",
1705            enum_names,
1706            true_opaque_types,
1707        );
1708        emit_named_param_teardown_indented(&mut out, &visible_params, "            ", true_opaque_types);
1709        emit_return_statement_indented(&mut out, &method.return_type, "            ");
1710        out.push_str("        });\n");
1711    } else {
1712        if method.return_type != TypeRef::Unit {
1713            out.push_str("        var nativeResult = ");
1714        } else {
1715            out.push_str("        ");
1716        }
1717
1718        out.push_str(&format!("NativeMethods.{}(", cs_native_name));
1719
1720        if !has_receiver && visible_params.is_empty() {
1721            out.push_str(");\n");
1722        } else {
1723            out.push('\n');
1724            let total = if has_receiver {
1725                visible_params.len() + 1
1726            } else {
1727                visible_params.len()
1728            };
1729            let mut idx = 0usize;
1730            if has_receiver {
1731                out.push_str("            handle");
1732                if total > 1 {
1733                    out.push(',');
1734                }
1735                out.push('\n');
1736                idx += 1;
1737            }
1738            for param in visible_params.iter() {
1739                let param_name = param.name.to_lower_camel_case();
1740                let arg = native_call_arg(&param.ty, &param_name, param.optional, true_opaque_types);
1741                out.push_str(&format!("            {arg}"));
1742                if idx < total - 1 {
1743                    out.push(',');
1744                }
1745                out.push('\n');
1746                idx += 1;
1747            }
1748            out.push_str("        );\n");
1749        }
1750
1751        emit_return_marshalling(&mut out, &method.return_type, enum_names, true_opaque_types);
1752        emit_named_param_teardown(&mut out, &visible_params, true_opaque_types);
1753        emit_return_statement(&mut out, &method.return_type);
1754    }
1755
1756    out.push_str("    }\n\n");
1757
1758    out
1759}
1760
1761/// Emit the return-value marshalling code shared by both function and method wrappers.
1762///
1763/// This function emits the code to convert the raw P/Invoke `result` into the managed return
1764/// type and store it in a local variable `returnValue`.  It intentionally does **not** emit
1765/// the `return` statement so that callers can interpose cleanup (param handle teardown) between
1766/// the value computation and the return.
1767///
1768/// `enum_names`: the set of C# type names that are enums (not opaque handles).
1769/// `true_opaque_types`: types with `is_opaque = true` — wrapped in `new CsType(result)`.
1770///
1771/// Callers must invoke `emit_return_statement` after their cleanup to complete the method body.
1772fn emit_return_marshalling(
1773    out: &mut String,
1774    return_type: &TypeRef,
1775    enum_names: &HashSet<String>,
1776    true_opaque_types: &HashSet<String>,
1777) {
1778    if *return_type == TypeRef::Unit {
1779        // void — nothing to return
1780        return;
1781    }
1782
1783    if returns_string(return_type) {
1784        // IntPtr → string, then free the native buffer.
1785        out.push_str("        var returnValue = Marshal.PtrToStringUTF8(nativeResult) ?? string.Empty;\n");
1786        out.push_str("        NativeMethods.FreeString(nativeResult);\n");
1787    } else if returns_bool_via_int(return_type) {
1788        // C int → bool
1789        out.push_str("        var returnValue = nativeResult != 0;\n");
1790    } else if let TypeRef::Named(type_name) = return_type {
1791        let pascal = type_name.to_pascal_case();
1792        if true_opaque_types.contains(type_name) {
1793            // Truly opaque handle: wrap the IntPtr in the C# handle class.
1794            out.push_str(&format!("        var returnValue = new {pascal}(nativeResult);\n"));
1795        } else if !enum_names.contains(&pascal) {
1796            // Data struct with to_json: call to_json, deserialise, then free both.
1797            let to_json_method = format!("{pascal}ToJson");
1798            let free_method = format!("{pascal}Free");
1799            let cs_ty = csharp_type(return_type);
1800            out.push_str(&format!(
1801                "        var jsonPtr = NativeMethods.{to_json_method}(nativeResult);\n"
1802            ));
1803            out.push_str("        var json = Marshal.PtrToStringUTF8(jsonPtr);\n");
1804            out.push_str("        NativeMethods.FreeString(jsonPtr);\n");
1805            out.push_str(&format!("        NativeMethods.{free_method}(nativeResult);\n"));
1806            out.push_str(&format!(
1807                "        var returnValue = JsonSerializer.Deserialize<{}>(json ?? \"null\", JsonOptions)!;\n",
1808                cs_ty
1809            ));
1810        } else {
1811            // Enum returned as JSON string IntPtr.
1812            let cs_ty = csharp_type(return_type);
1813            out.push_str("        var json = Marshal.PtrToStringUTF8(nativeResult);\n");
1814            out.push_str("        NativeMethods.FreeString(nativeResult);\n");
1815            out.push_str(&format!(
1816                "        var returnValue = JsonSerializer.Deserialize<{}>(json ?? \"null\", JsonOptions)!;\n",
1817                cs_ty
1818            ));
1819        }
1820    } else if returns_json_object(return_type) {
1821        // IntPtr → JSON string → deserialized object, then free the native buffer.
1822        let cs_ty = csharp_type(return_type);
1823        out.push_str("        var json = Marshal.PtrToStringUTF8(nativeResult);\n");
1824        out.push_str("        NativeMethods.FreeString(nativeResult);\n");
1825        out.push_str(&format!(
1826            "        var returnValue = JsonSerializer.Deserialize<{}>(json ?? \"null\", JsonOptions)!;\n",
1827            cs_ty
1828        ));
1829    } else {
1830        // Numeric primitives — direct return.
1831        out.push_str("        var returnValue = nativeResult;\n");
1832    }
1833}
1834
1835/// Emit the final `return returnValue;` statement after cleanup.
1836fn emit_return_statement(out: &mut String, return_type: &TypeRef) {
1837    if *return_type != TypeRef::Unit {
1838        out.push_str("        return returnValue;\n");
1839    }
1840}
1841
1842/// Emit the return-value marshalling code with configurable indentation.
1843///
1844/// Like `emit_return_marshalling` this stores the value in `returnValue` without emitting
1845/// the final `return` statement.  Callers must call `emit_return_statement_indented` after.
1846fn emit_return_marshalling_indented(
1847    out: &mut String,
1848    return_type: &TypeRef,
1849    indent: &str,
1850    enum_names: &HashSet<String>,
1851    true_opaque_types: &HashSet<String>,
1852) {
1853    if *return_type == TypeRef::Unit {
1854        return;
1855    }
1856
1857    if returns_string(return_type) {
1858        out.push_str(&format!(
1859            "{indent}var returnValue = Marshal.PtrToStringUTF8(nativeResult) ?? string.Empty;\n"
1860        ));
1861        out.push_str(&format!("{indent}NativeMethods.FreeString(nativeResult);\n"));
1862    } else if returns_bool_via_int(return_type) {
1863        out.push_str(&format!("{indent}var returnValue = nativeResult != 0;\n"));
1864    } else if let TypeRef::Named(type_name) = return_type {
1865        let pascal = type_name.to_pascal_case();
1866        if true_opaque_types.contains(type_name) {
1867            // Truly opaque handle: wrap the IntPtr in the C# handle class.
1868            out.push_str(&format!("{indent}var returnValue = new {pascal}(nativeResult);\n"));
1869        } else if !enum_names.contains(&pascal) {
1870            // Data struct with to_json: call to_json, deserialise, then free both.
1871            let to_json_method = format!("{pascal}ToJson");
1872            let free_method = format!("{pascal}Free");
1873            let cs_ty = csharp_type(return_type);
1874            out.push_str(&format!(
1875                "{indent}var jsonPtr = NativeMethods.{to_json_method}(nativeResult);\n"
1876            ));
1877            out.push_str(&format!("{indent}var json = Marshal.PtrToStringUTF8(jsonPtr);\n"));
1878            out.push_str(&format!("{indent}NativeMethods.FreeString(jsonPtr);\n"));
1879            out.push_str(&format!("{indent}NativeMethods.{free_method}(nativeResult);\n"));
1880            out.push_str(&format!(
1881                "{indent}var returnValue = JsonSerializer.Deserialize<{}>(json ?? \"null\", JsonOptions)!;\n",
1882                cs_ty
1883            ));
1884        } else {
1885            // Enum returned as JSON string IntPtr.
1886            let cs_ty = csharp_type(return_type);
1887            out.push_str(&format!("{indent}var json = Marshal.PtrToStringUTF8(nativeResult);\n"));
1888            out.push_str(&format!("{indent}NativeMethods.FreeString(nativeResult);\n"));
1889            out.push_str(&format!(
1890                "{indent}var returnValue = JsonSerializer.Deserialize<{}>(json ?? \"null\", JsonOptions)!;\n",
1891                cs_ty
1892            ));
1893        }
1894    } else if returns_json_object(return_type) {
1895        let cs_ty = csharp_type(return_type);
1896        out.push_str(&format!("{indent}var json = Marshal.PtrToStringUTF8(nativeResult);\n"));
1897        out.push_str(&format!("{indent}NativeMethods.FreeString(nativeResult);\n"));
1898        out.push_str(&format!(
1899            "{indent}var returnValue = JsonSerializer.Deserialize<{}>(json ?? \"null\", JsonOptions)!;\n",
1900            cs_ty
1901        ));
1902    } else {
1903        out.push_str(&format!("{indent}var returnValue = nativeResult;\n"));
1904    }
1905}
1906
1907/// Emit the final `return returnValue;` with configurable indentation.
1908fn emit_return_statement_indented(out: &mut String, return_type: &TypeRef, indent: &str) {
1909    if *return_type != TypeRef::Unit {
1910        out.push_str(&format!("{indent}return returnValue;\n"));
1911    }
1912}
1913
1914fn gen_opaque_handle(typ: &TypeDef, namespace: &str) -> String {
1915    let mut out = csharp_file_header();
1916    out.push_str("using System;\n\n");
1917
1918    out.push_str(&format!("namespace {};\n\n", namespace));
1919
1920    // Generate doc comment if available
1921    if !typ.doc.is_empty() {
1922        out.push_str("/// <summary>\n");
1923        for line in typ.doc.lines() {
1924            out.push_str(&format!("/// {}\n", line));
1925        }
1926        out.push_str("/// </summary>\n");
1927    }
1928
1929    let class_name = typ.name.to_pascal_case();
1930    out.push_str(&format!("public sealed class {} : IDisposable\n", class_name));
1931    out.push_str("{\n");
1932    out.push_str("    internal IntPtr Handle { get; }\n\n");
1933    out.push_str(&format!("    internal {}(IntPtr handle)\n", class_name));
1934    out.push_str("    {\n");
1935    out.push_str("        Handle = handle;\n");
1936    out.push_str("    }\n\n");
1937    out.push_str("    public void Dispose()\n");
1938    out.push_str("    {\n");
1939    out.push_str("        // Native free will be called by the runtime\n");
1940    out.push_str("    }\n");
1941    out.push_str("}\n");
1942
1943    out
1944}
1945
1946fn gen_record_type(
1947    typ: &TypeDef,
1948    namespace: &str,
1949    enum_names: &HashSet<String>,
1950    complex_enums: &HashSet<String>,
1951    custom_converter_enums: &HashSet<String>,
1952    _lang_rename_all: &str,
1953    options_field_bridges: &[OptionsFieldBridgeInfo],
1954) -> String {
1955    // Find a bridge that targets this type (if any).
1956    let bridge_for_type = options_field_bridges.iter().find(|b| b.options_type == typ.name);
1957
1958    let mut out = csharp_file_header();
1959    out.push_str("using System;\n");
1960    out.push_str("using System.Collections.Generic;\n");
1961    out.push_str("using System.Text.Json;\n");
1962    out.push_str("using System.Text.Json.Serialization;\n\n");
1963
1964    out.push_str(&format!("namespace {};\n\n", namespace));
1965
1966    // Generate doc comment if available
1967    if !typ.doc.is_empty() {
1968        out.push_str("/// <summary>\n");
1969        for line in typ.doc.lines() {
1970            out.push_str(&format!("/// {}\n", line));
1971        }
1972        out.push_str("/// </summary>\n");
1973    }
1974
1975    out.push_str(&format!("public sealed class {}\n", typ.name.to_pascal_case()));
1976    out.push_str("{\n");
1977
1978    for field in &typ.fields {
1979        // Skip unnamed tuple struct fields (e.g., _0, _1, 0, 1, etc.)
1980        if is_tuple_field(field) {
1981            continue;
1982        }
1983
1984        // Skip bridge fields on options types — they are opaque handles managed via
1985        // the setter FFI and must not appear in JSON serialization.
1986        if let Some(b) = bridge_for_type {
1987            if field.name == b.field_name {
1988                continue;
1989            }
1990        }
1991
1992        // Doc comment for field
1993        if !field.doc.is_empty() {
1994            out.push_str("    /// <summary>\n");
1995            for line in field.doc.lines() {
1996                out.push_str(&format!("    /// {}\n", line));
1997            }
1998            out.push_str("    /// </summary>\n");
1999        }
2000
2001        // If the field's type is an enum with a custom converter, emit a property-level
2002        // [JsonConverter] attribute. This ensures the custom converter takes precedence
2003        // over the global JsonStringEnumConverter registered in JsonSerializerOptions.
2004        let field_base_type = match &field.ty {
2005            TypeRef::Named(n) => Some(n.to_pascal_case()),
2006            TypeRef::Optional(inner) => match inner.as_ref() {
2007                TypeRef::Named(n) => Some(n.to_pascal_case()),
2008                _ => None,
2009            },
2010            _ => None,
2011        };
2012        if let Some(ref base) = field_base_type {
2013            if custom_converter_enums.contains(base) {
2014                out.push_str(&format!("    [JsonConverter(typeof({base}JsonConverter))]\n"));
2015            }
2016        }
2017
2018        // [JsonPropertyName("json_name")]
2019        // FFI-based languages serialize to JSON that Rust serde deserializes.
2020        // Since Rust uses default snake_case, JSON property names must be snake_case.
2021        let json_name = field.name.clone();
2022        out.push_str(&format!("    [JsonPropertyName(\"{}\")]\n", json_name));
2023
2024        let cs_name = to_csharp_name(&field.name);
2025
2026        // Check if field type is a complex enum (tagged enum with data variants).
2027        // These can't be simple C# enums — use JsonElement for flexible deserialization.
2028        let is_complex = matches!(&field.ty, TypeRef::Named(n) if complex_enums.contains(&n.to_pascal_case()));
2029
2030        if field.optional {
2031            // Optional fields: nullable type, no `required`, default = null
2032            let mapped = if is_complex {
2033                "JsonElement".to_string()
2034            } else {
2035                csharp_type(&field.ty).to_string()
2036            };
2037            let field_type = if mapped.ends_with('?') {
2038                mapped
2039            } else {
2040                format!("{mapped}?")
2041            };
2042            out.push_str(&format!("    public {} {} {{ get; set; }}", field_type, cs_name));
2043            out.push_str(" = null;\n");
2044        } else if typ.has_default || field.default.is_some() {
2045            // Field with an explicit default value or part of a type with defaults.
2046            // Use typed_default from IR to get Rust-compatible defaults.
2047            use alef_core::ir::DefaultValue;
2048
2049            // First pass: determine what the default value will be
2050            let base_type = if is_complex {
2051                "JsonElement".to_string()
2052            } else {
2053                csharp_type(&field.ty).to_string()
2054            };
2055
2056            // Duration fields are mapped to ulong? so that 0 is distinguishable from
2057            // "not set". Always default to null here; Rust has its own default.
2058            if matches!(&field.ty, TypeRef::Duration) {
2059                // base_type is already "ulong?" (from csharp_type); don't add another "?"
2060                let nullable_type = if base_type.ends_with('?') {
2061                    base_type.clone()
2062                } else {
2063                    format!("{}?", base_type)
2064                };
2065                out.push_str(&format!(
2066                    "    public {} {} {{ get; set; }} = null;\n",
2067                    nullable_type, cs_name
2068                ));
2069                out.push('\n');
2070                continue;
2071            }
2072
2073            let default_val = match &field.typed_default {
2074                Some(DefaultValue::BoolLiteral(b)) => b.to_string(),
2075                Some(DefaultValue::IntLiteral(n)) => n.to_string(),
2076                Some(DefaultValue::FloatLiteral(f)) => {
2077                    let s = f.to_string();
2078                    let s = if s.contains('.') { s } else { format!("{s}.0") };
2079                    match &field.ty {
2080                        TypeRef::Primitive(PrimitiveType::F32) => format!("{}f", s),
2081                        _ => s,
2082                    }
2083                }
2084                Some(DefaultValue::StringLiteral(s)) => {
2085                    let escaped = s
2086                        .replace('\\', "\\\\")
2087                        .replace('"', "\\\"")
2088                        .replace('\n', "\\n")
2089                        .replace('\r', "\\r")
2090                        .replace('\t', "\\t");
2091                    format!("\"{}\"", escaped)
2092                }
2093                Some(DefaultValue::EnumVariant(v)) => {
2094                    // When the C# field type is `string` (the referenced enum was excluded /
2095                    // collapsed to its serde JSON tag), emit the variant tag as a string literal
2096                    // rather than `string.VariantName` which would resolve to a missing static.
2097                    if base_type == "string" || base_type == "string?" {
2098                        format!("\"{}\"", v.to_pascal_case())
2099                    } else {
2100                        format!("{}.{}", base_type, v.to_pascal_case())
2101                    }
2102                }
2103                Some(DefaultValue::None) => "null".to_string(),
2104                Some(DefaultValue::Empty) | None => match &field.ty {
2105                    TypeRef::Vec(_) => "[]".to_string(),
2106                    TypeRef::Map(k, v) => format!("new Dictionary<{}, {}>()", csharp_type(k), csharp_type(v)),
2107                    TypeRef::String | TypeRef::Char | TypeRef::Path => "\"\"".to_string(),
2108                    TypeRef::Json => "null".to_string(),
2109                    TypeRef::Bytes => "Array.Empty<byte>()".to_string(),
2110                    TypeRef::Primitive(p) => match p {
2111                        PrimitiveType::Bool => "false".to_string(),
2112                        PrimitiveType::F32 => "0.0f".to_string(),
2113                        PrimitiveType::F64 => "0.0".to_string(),
2114                        _ => "0".to_string(),
2115                    },
2116                    TypeRef::Named(name) => {
2117                        let pascal = name.to_pascal_case();
2118                        if complex_enums.contains(&pascal) {
2119                            // Taggedunions (complex enums) should default to null
2120                            "null".to_string()
2121                        } else if enum_names.contains(&pascal) {
2122                            // Plain enums with serde(default) but no explicit variant default:
2123                            // Default to null
2124                            "null".to_string()
2125                        } else {
2126                            "default!".to_string()
2127                        }
2128                    }
2129                    _ => "default!".to_string(),
2130                },
2131            };
2132
2133            // Second pass: determine field type based on the default value
2134            let field_type = if (default_val == "null" && !base_type.ends_with('?')) || is_complex {
2135                format!("{}?", base_type)
2136            } else {
2137                base_type
2138            };
2139
2140            out.push_str(&format!(
2141                "    public {} {} {{ get; set; }} = {};\n",
2142                field_type, cs_name, default_val
2143            ));
2144        } else {
2145            // Non-optional field without explicit default.
2146            // Use type-appropriate zero values instead of `required` to avoid
2147            // JSON deserialization failures when fields are omitted via serde skip_serializing_if.
2148            let field_type = if is_complex {
2149                "JsonElement".to_string()
2150            } else {
2151                csharp_type(&field.ty).to_string()
2152            };
2153            // Duration is mapped to ulong? so null is the correct "not set" default.
2154            if matches!(&field.ty, TypeRef::Duration) {
2155                out.push_str(&format!(
2156                    "    public {} {} {{ get; set; }} = null;\n",
2157                    field_type, cs_name
2158                ));
2159            } else {
2160                let default_val = match &field.ty {
2161                    TypeRef::String | TypeRef::Char | TypeRef::Path | TypeRef::Json => "\"\"",
2162                    TypeRef::Vec(_) => "[]",
2163                    TypeRef::Bytes => "Array.Empty<byte>()",
2164                    TypeRef::Primitive(PrimitiveType::Bool) => "false",
2165                    TypeRef::Primitive(PrimitiveType::F32) => "0.0f",
2166                    TypeRef::Primitive(PrimitiveType::F64) => "0.0",
2167                    TypeRef::Primitive(_) => "0",
2168                    _ => "default!",
2169                };
2170                out.push_str(&format!(
2171                    "    public {} {} {{ get; set; }} = {};\n",
2172                    field_type, cs_name, default_val
2173                ));
2174            }
2175        }
2176
2177        out.push('\n');
2178    }
2179
2180    // Inject a [JsonIgnore] bridge property for options-field bridges targeting this type.
2181    // The property uses the bridge type directly (e.g. `HtmlVisitorBridge?`) so callers
2182    // can set `options.Visitor = new HtmlVisitorBridge(myVisitorImpl)` before converting.
2183    // JSON serialization ignores it — the wrapper class reads it and calls the FFI setter.
2184    if let Some(b) = bridge_for_type {
2185        let prop_name = b.field_name.to_pascal_case();
2186        let bridge_type = &b.bridge_cs_type;
2187        out.push_str("    /// <summary>\n");
2188        out.push_str(&format!(
2189            "    /// Optional {bridge_type} bridge. When set, the native converter will call back\n"
2190        ));
2191        out.push_str("    /// into the managed implementation for each visited node.\n");
2192        out.push_str("    /// Not serialized to JSON — attached via the FFI setter before conversion.\n");
2193        out.push_str("    /// </summary>\n");
2194        out.push_str("    [JsonIgnore]\n");
2195        out.push_str(&format!(
2196            "    public {bridge_type}Bridge? {prop_name} {{ get; set; }} = null;\n\n"
2197        ));
2198    }
2199
2200    out.push_str("}\n");
2201
2202    out
2203}
2204
2205/// Apply a serde `rename_all` strategy to a variant name.
2206fn apply_rename_all(name: &str, rename_all: Option<&str>) -> String {
2207    match rename_all {
2208        Some("snake_case") => name.to_snake_case(),
2209        Some("camelCase") => name.to_lower_camel_case(),
2210        Some("PascalCase") => name.to_pascal_case(),
2211        Some("SCREAMING_SNAKE_CASE") => name.to_snake_case().to_uppercase(),
2212        Some("lowercase") => name.to_lowercase(),
2213        Some("UPPERCASE") => name.to_uppercase(),
2214        _ => name.to_lowercase(),
2215    }
2216}
2217
2218fn gen_enum(enum_def: &EnumDef, namespace: &str) -> String {
2219    let mut out = csharp_file_header();
2220    out.push_str("using System.Text.Json.Serialization;\n\n");
2221    let has_data_variants = enum_def.variants.iter().any(|v| !v.fields.is_empty());
2222
2223    // Tagged union: enum has a serde tag AND data variants → generate abstract record hierarchy
2224    if enum_def.serde_tag.is_some() && has_data_variants {
2225        return gen_tagged_union(enum_def, namespace);
2226    }
2227
2228    // If any variant has an explicit serde_rename whose value differs from what
2229    // SnakeCaseLower would produce (e.g. "og:image" vs "og_image"), the global
2230    // JsonStringEnumConverter(SnakeCaseLower) in KreuzcrawlLib.JsonOptions would
2231    // ignore [JsonPropertyName] and use the naming policy instead.
2232    // Also, the non-generic JsonStringEnumConverter does NOT support [JsonPropertyName]
2233    // on enum members at all. For these cases we generate a custom JsonConverter<T>
2234    // that explicitly maps each variant name.
2235    let needs_custom_converter = enum_def.variants.iter().any(|v| {
2236        if let Some(ref rename) = v.serde_rename {
2237            let snake = apply_rename_all(&v.name, enum_def.serde_rename_all.as_deref());
2238            rename != &snake
2239        } else {
2240            false
2241        }
2242    });
2243
2244    let enum_pascal = enum_def.name.to_pascal_case();
2245
2246    // Collect (json_name, pascal_name) pairs
2247    let variants: Vec<(String, String)> = enum_def
2248        .variants
2249        .iter()
2250        .map(|v| {
2251            let json_name = v
2252                .serde_rename
2253                .clone()
2254                .unwrap_or_else(|| apply_rename_all(&v.name, enum_def.serde_rename_all.as_deref()));
2255            let pascal_name = v.name.to_pascal_case();
2256            (json_name, pascal_name)
2257        })
2258        .collect();
2259
2260    out.push_str("using System;\n");
2261    out.push_str("using System.Text.Json;\n\n");
2262
2263    out.push_str(&format!("namespace {};\n\n", namespace));
2264
2265    // Generate doc comment if available
2266    if !enum_def.doc.is_empty() {
2267        out.push_str("/// <summary>\n");
2268        for line in enum_def.doc.lines() {
2269            out.push_str(&format!("/// {}\n", line));
2270        }
2271        out.push_str("/// </summary>\n");
2272    }
2273
2274    if needs_custom_converter {
2275        out.push_str(&format!("[JsonConverter(typeof({enum_pascal}JsonConverter))]\n"));
2276    }
2277    out.push_str(&format!("public enum {enum_pascal}\n"));
2278    out.push_str("{\n");
2279
2280    for (json_name, pascal_name) in &variants {
2281        // Find doc for this variant
2282        if let Some(v) = enum_def
2283            .variants
2284            .iter()
2285            .find(|v| v.name.to_pascal_case() == *pascal_name)
2286        {
2287            if !v.doc.is_empty() {
2288                out.push_str("    /// <summary>\n");
2289                for line in v.doc.lines() {
2290                    out.push_str(&format!("    /// {}\n", line));
2291                }
2292                out.push_str("    /// </summary>\n");
2293            }
2294        }
2295        out.push_str(&format!("    [JsonPropertyName(\"{json_name}\")]\n"));
2296        out.push_str(&format!("    {pascal_name},\n"));
2297    }
2298
2299    out.push_str("}\n");
2300
2301    // Generate custom converter class after the enum when needed
2302    if needs_custom_converter {
2303        out.push('\n');
2304        out.push_str(&format!(
2305            "/// <summary>Custom JSON converter for <see cref=\"{enum_pascal}\"/> that respects explicit variant names.</summary>\n"
2306        ));
2307        out.push_str(&format!(
2308            "internal sealed class {enum_pascal}JsonConverter : JsonConverter<{enum_pascal}>\n"
2309        ));
2310        out.push_str("{\n");
2311
2312        // Read
2313        out.push_str(&format!(
2314            "    public override {enum_pascal} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)\n"
2315        ));
2316        out.push_str("    {\n");
2317        out.push_str("        var value = reader.GetString();\n");
2318        out.push_str("        return value switch\n");
2319        out.push_str("        {\n");
2320        for (json_name, pascal_name) in &variants {
2321            out.push_str(&format!(
2322                "            \"{json_name}\" => {enum_pascal}.{pascal_name},\n"
2323            ));
2324        }
2325        out.push_str(&format!(
2326            "            _ => throw new JsonException($\"Unknown {enum_pascal} value: {{value}}\")\n"
2327        ));
2328        out.push_str("        };\n");
2329        out.push_str("    }\n\n");
2330
2331        // Write
2332        out.push_str(&format!(
2333            "    public override void Write(Utf8JsonWriter writer, {enum_pascal} value, JsonSerializerOptions options)\n"
2334        ));
2335        out.push_str("    {\n");
2336        out.push_str("        var str = value switch\n");
2337        out.push_str("        {\n");
2338        for (json_name, pascal_name) in &variants {
2339            out.push_str(&format!(
2340                "            {enum_pascal}.{pascal_name} => \"{json_name}\",\n"
2341            ));
2342        }
2343        out.push_str(&format!(
2344            "            _ => throw new JsonException($\"Unknown {enum_pascal} value: {{value}}\")\n"
2345        ));
2346        out.push_str("        };\n");
2347        out.push_str("        writer.WriteStringValue(str);\n");
2348        out.push_str("    }\n");
2349        out.push_str("}\n");
2350    }
2351
2352    out
2353}
2354
2355/// Generate a C# abstract record hierarchy for internally tagged enums.
2356///
2357/// Maps `#[serde(tag = "type_field", rename_all = "snake_case")]` Rust enums to
2358/// a custom `JsonConverter<T>` that buffers all JSON properties before resolving
2359/// the discriminator. This is more robust than `[JsonPolymorphic]` which requires
2360/// the discriminator to be the first property in the JSON object.
2361fn gen_tagged_union(enum_def: &EnumDef, namespace: &str) -> String {
2362    let tag_field = enum_def.serde_tag.as_deref().unwrap_or("type");
2363    let enum_pascal = enum_def.name.to_pascal_case();
2364    let converter_name = format!("{enum_pascal}JsonConverter");
2365    // Namespace prefix used to fully-qualify inner types when their short name is shadowed
2366    // by a nested record of the same name (e.g. ContentPart.ImageUrl shadows ImageUrl).
2367    let ns = namespace;
2368
2369    let mut out = csharp_file_header();
2370    out.push_str("using System;\n");
2371    out.push_str("using System.Collections.Generic;\n");
2372    out.push_str("using System.Text.Json;\n");
2373    out.push_str("using System.Text.Json.Serialization;\n\n");
2374    out.push_str(&format!("namespace {};\n\n", namespace));
2375
2376    // Doc comment
2377    if !enum_def.doc.is_empty() {
2378        out.push_str("/// <summary>\n");
2379        for line in enum_def.doc.lines() {
2380            out.push_str(&format!("/// {}\n", line));
2381        }
2382        out.push_str("/// </summary>\n");
2383    }
2384
2385    // Use custom converter instead of [JsonPolymorphic] to handle discriminator in any position
2386    out.push_str(&format!("[JsonConverter(typeof({converter_name}))]\n"));
2387    out.push_str(&format!("public abstract record {enum_pascal}\n"));
2388    out.push_str("{\n");
2389
2390    // Collect all variant pascal names to check for field-name-to-variant-name clashes
2391    let variant_names: std::collections::HashSet<String> =
2392        enum_def.variants.iter().map(|v| v.name.to_pascal_case()).collect();
2393
2394    // Nested sealed records for each variant
2395    for variant in &enum_def.variants {
2396        let pascal = variant.name.to_pascal_case();
2397
2398        if !variant.doc.is_empty() {
2399            out.push_str("    /// <summary>\n");
2400            for line in variant.doc.lines() {
2401                out.push_str(&format!("    /// {}\n", line));
2402            }
2403            out.push_str("    /// </summary>\n");
2404        }
2405
2406        if variant.fields.is_empty() {
2407            // Unit variant → sealed record with no fields
2408            out.push_str(&format!("    public sealed record {pascal}() : {enum_pascal};\n\n"));
2409        } else {
2410            // CS8910: when a single-field variant has a parameter whose TYPE equals the record name
2411            // (e.g., record ImageUrl(ImageUrl Value)), the primary constructor conflicts with the
2412            // synthesized copy constructor. Use a property-based record body instead.
2413            // This applies to both tuple fields and named fields that get renamed to "Value".
2414            let is_copy_ctor_clash = variant.fields.len() == 1 && {
2415                let field_cs_type = csharp_type(&variant.fields[0].ty);
2416                field_cs_type.as_ref() == pascal
2417            };
2418
2419            if is_copy_ctor_clash {
2420                let cs_type = csharp_type(&variant.fields[0].ty);
2421                // Fully qualify the inner type to avoid the nested record shadowing the
2422                // standalone type of the same name (e.g. `ContentPart.ImageUrl` would shadow
2423                // `LiterLlm.ImageUrl` within the `ContentPart` abstract record body).
2424                let qualified_cs_type = format!("global::{ns}.{cs_type}");
2425                out.push_str(&format!("    public sealed record {pascal} : {enum_pascal}\n"));
2426                out.push_str("    {\n");
2427                out.push_str(&format!(
2428                    "        public required {qualified_cs_type} Value {{ get; init; }}\n"
2429                ));
2430                out.push_str("    }\n\n");
2431            } else {
2432                // Data variant → sealed record with fields as constructor params
2433                out.push_str(&format!("    public sealed record {pascal}(\n"));
2434                for (i, field) in variant.fields.iter().enumerate() {
2435                    let cs_type = csharp_type(&field.ty);
2436                    let cs_type = if field.optional && !cs_type.ends_with('?') {
2437                        format!("{cs_type}?")
2438                    } else {
2439                        cs_type.to_string()
2440                    };
2441                    let comma = if i < variant.fields.len() - 1 { "," } else { "" };
2442                    if is_tuple_field(field) {
2443                        out.push_str(&format!("        {cs_type} Value{comma}\n"));
2444                    } else {
2445                        let json_name = field.name.trim_start_matches('_');
2446                        let cs_name = to_csharp_name(json_name);
2447                        // Check if this field name clashes with:
2448                        // 1. The variant pascal name (e.g., "Slide" variant with "slide" field → "Slide" param)
2449                        // 2. The field type name (e.g., "ImageUrl" type with "url" field → "Url" param matching a nested record)
2450                        // 3. Another variant pascal name (e.g., nested "Title" record with "title" field in "Slide" variant)
2451                        let clashes = cs_name == pascal || cs_name == cs_type || variant_names.contains(&cs_name);
2452                        if clashes {
2453                            // Rename to Value with JSON property mapping to preserve the original field name
2454                            out.push_str(&format!(
2455                                "        [property: JsonPropertyName(\"{json_name}\")] {cs_type} Value{comma}\n"
2456                            ));
2457                        } else {
2458                            out.push_str(&format!(
2459                                "        [property: JsonPropertyName(\"{json_name}\")] {cs_type} {cs_name}{comma}\n"
2460                            ));
2461                        }
2462                    }
2463                }
2464                out.push_str(&format!("    ) : {enum_pascal};\n\n"));
2465            }
2466        }
2467    }
2468
2469    out.push_str("}\n\n");
2470
2471    // Generate custom converter that buffers the JSON document before dispatching
2472    out.push_str(&format!(
2473        "/// <summary>Custom JSON converter for <see cref=\"{enum_pascal}\"/> that reads the \"{tag_field}\" discriminator from any position.</summary>\n"
2474    ));
2475    out.push_str(&format!(
2476        "internal sealed class {converter_name} : JsonConverter<{enum_pascal}>\n"
2477    ));
2478    out.push_str("{\n");
2479
2480    // Read method
2481    out.push_str(&format!(
2482        "    public override {enum_pascal} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)\n"
2483    ));
2484    out.push_str("    {\n");
2485    out.push_str("        using var doc = JsonDocument.ParseValue(ref reader);\n");
2486    out.push_str("        var root = doc.RootElement;\n");
2487    out.push_str(&format!(
2488        "        if (!root.TryGetProperty(\"{tag_field}\", out var tagEl))\n"
2489    ));
2490    out.push_str(&format!(
2491        "            throw new JsonException(\"{enum_pascal}: missing \\\"{tag_field}\\\" discriminator\");\n"
2492    ));
2493    out.push_str("        var tag = tagEl.GetString();\n");
2494    out.push_str("        var json = root.GetRawText();\n");
2495    out.push_str("        return tag switch\n");
2496    out.push_str("        {\n");
2497
2498    for variant in &enum_def.variants {
2499        let discriminator = variant
2500            .serde_rename
2501            .clone()
2502            .unwrap_or_else(|| apply_rename_all(&variant.name, enum_def.serde_rename_all.as_deref()));
2503        let pascal = variant.name.to_pascal_case();
2504        // Newtype/tuple variants have their inner type's fields inlined alongside the tag in JSON.
2505        // Deserialize the inner type from the full JSON object and wrap it in the record constructor.
2506        // Also treat single named-field variants whose parameter was renamed to "Value" (clash with
2507        // the variant name or the field's own type name) the same way.
2508        let is_tuple_newtype = variant.fields.len() == 1 && is_tuple_field(&variant.fields[0]);
2509        let is_named_clash_newtype = variant.fields.len() == 1 && !is_tuple_field(&variant.fields[0]) && {
2510            let f = &variant.fields[0];
2511            let cs_type = csharp_type(&f.ty);
2512            let cs_name = to_csharp_name(f.name.trim_start_matches('_'));
2513            cs_name == pascal || cs_name == cs_type
2514        };
2515        let is_newtype = is_tuple_newtype || is_named_clash_newtype;
2516        if is_newtype {
2517            let inner_cs_type = csharp_type(&variant.fields[0].ty);
2518            // CS8910: when inner type name equals variant name, use object initializer
2519            // (no primary constructor exists — property-based record was emitted)
2520            if inner_cs_type == pascal {
2521                out.push_str(&format!(
2522                    "            \"{discriminator}\" => new {enum_pascal}.{pascal} {{ Value = JsonSerializer.Deserialize<{inner_cs_type}>(json, options)!\n"
2523                ));
2524                out.push_str(&format!(
2525                    "                ?? throw new JsonException(\"Failed to deserialize {enum_pascal}.{pascal}.Value\") }},\n"
2526                ));
2527            } else {
2528                out.push_str(&format!(
2529                    "            \"{discriminator}\" => new {enum_pascal}.{pascal}(\n"
2530                ));
2531                out.push_str(&format!(
2532                    "                JsonSerializer.Deserialize<{inner_cs_type}>(json, options)!\n"
2533                ));
2534                out.push_str(&format!(
2535                    "                    ?? throw new JsonException(\"Failed to deserialize {enum_pascal}.{pascal}.Value\")),\n"
2536                ));
2537            }
2538        } else {
2539            out.push_str(&format!(
2540                "            \"{discriminator}\" => JsonSerializer.Deserialize<{enum_pascal}.{pascal}>(json, options)!\n"
2541            ));
2542            out.push_str(&format!(
2543                "                ?? throw new JsonException(\"Failed to deserialize {enum_pascal}.{pascal}\"),\n"
2544            ));
2545        }
2546    }
2547
2548    out.push_str(&format!(
2549        "            _ => throw new JsonException($\"Unknown {enum_pascal} discriminator: {{tag}}\")\n"
2550    ));
2551    out.push_str("        };\n");
2552    out.push_str("    }\n\n");
2553
2554    // Write method
2555    out.push_str(&format!(
2556        "    public override void Write(Utf8JsonWriter writer, {enum_pascal} value, JsonSerializerOptions options)\n"
2557    ));
2558    out.push_str("    {\n");
2559
2560    // Build options without this converter to avoid infinite recursion
2561    out.push_str("        // Serialize the concrete type, then inject the discriminator\n");
2562    out.push_str("        switch (value)\n");
2563    out.push_str("        {\n");
2564
2565    for variant in &enum_def.variants {
2566        let discriminator = variant
2567            .serde_rename
2568            .clone()
2569            .unwrap_or_else(|| apply_rename_all(&variant.name, enum_def.serde_rename_all.as_deref()));
2570        let pascal = variant.name.to_pascal_case();
2571        // Newtype/tuple variants: serialize the inner Value's fields inline alongside the tag.
2572        // Also applies to single named-field variants whose parameter was renamed to "Value" due
2573        // to a clash with the variant name or the field's own type name.
2574        let is_tuple_newtype = variant.fields.len() == 1 && is_tuple_field(&variant.fields[0]);
2575        let is_named_clash_newtype = variant.fields.len() == 1 && !is_tuple_field(&variant.fields[0]) && {
2576            let f = &variant.fields[0];
2577            let cs_type = csharp_type(&f.ty);
2578            let cs_name = to_csharp_name(f.name.trim_start_matches('_'));
2579            cs_name == pascal || cs_name == cs_type
2580        };
2581        let is_newtype = is_tuple_newtype || is_named_clash_newtype;
2582        // dotnet format expects switch-case block braces indented one level
2583        // deeper than the `case` keyword (the body's indent), not aligned to
2584        // it — otherwise it reformats every commit and breaks alef-verify.
2585        out.push_str(&format!("            case {enum_pascal}.{pascal} v:\n"));
2586        out.push_str("                {\n");
2587        if is_newtype {
2588            out.push_str("                    var doc = JsonSerializer.SerializeToDocument(v.Value, options);\n");
2589        } else {
2590            out.push_str("                    var doc = JsonSerializer.SerializeToDocument(v, options);\n");
2591        }
2592        out.push_str("                    writer.WriteStartObject();\n");
2593        out.push_str(&format!(
2594            "                    writer.WriteString(\"{tag_field}\", \"{discriminator}\");\n"
2595        ));
2596        out.push_str("                    foreach (var prop in doc.RootElement.EnumerateObject())\n");
2597        out.push_str(&format!(
2598            "                        if (prop.Name != \"{tag_field}\") prop.WriteTo(writer);\n"
2599        ));
2600        out.push_str("                    writer.WriteEndObject();\n");
2601        out.push_str("                    break;\n");
2602        out.push_str("                }\n");
2603    }
2604
2605    out.push_str(&format!(
2606        "            default: throw new JsonException($\"Unknown {enum_pascal} subtype: {{value.GetType().Name}}\");\n"
2607    ));
2608    out.push_str("        }\n");
2609    out.push_str("    }\n");
2610    out.push_str("}\n");
2611
2612    out
2613}
2614
2615/// Generate Directory.Build.props with Nullable=enable and LangVersion=latest.
2616/// This is auto-generated (overwritten on each build) so it doesn't require user maintenance.
2617fn gen_directory_build_props() -> String {
2618    "<!-- auto-generated by alef (generate_bindings) -->\n\
2619<Project>\n  \
2620<PropertyGroup>\n    \
2621<Nullable>enable</Nullable>\n    \
2622<LangVersion>latest</LangVersion>\n  \
2623</PropertyGroup>\n\
2624</Project>\n"
2625        .to_string()
2626}
2627
2628/// Delete stale visitor-related files when visitor_callbacks is disabled.
2629/// When visitor_callbacks transitions from true → false, these files remain on disk
2630/// and cause CS8632 warnings (nullable context not enabled in these files).
2631fn delete_stale_visitor_files(base_path: &std::path::Path) -> anyhow::Result<()> {
2632    let stale_files = vec!["IVisitor.cs", "VisitorCallbacks.cs", "NodeContext.cs", "VisitResult.cs"];
2633
2634    for filename in stale_files {
2635        let path = base_path.join(filename);
2636        if path.exists() {
2637            std::fs::remove_file(&path)
2638                .map_err(|e| anyhow::anyhow!("Failed to delete stale visitor file {}: {}", path.display(), e))?;
2639        }
2640    }
2641
2642    Ok(())
2643}