//! Templates are the `View` of the C# backend: each one binds to a
//! `render_csharp/*.txt` Askama file and renders a plan node as C#
//! source.
//!
//! Templates render the C# syntax from semantic plan facts and typed
//! `ast` primitives. Lowering avoids carrying pre-rendered method
//! bodies; template helpers own syntax-heavy shapes such as stream
//! batch decoding.
//!
//! Snapshot tests pin the rendered shape against curated plan
//! fixtures.
//!
//! Templates are instantiated and rendered by the `emit` module.

use askama::Template;

use super::ast::{CSharpComment, CSharpNamespace};
use super::plan::{
    CSharpAsyncCallbackFailurePlan, CSharpAsyncCallbackFaultPlan, CSharpAsyncCallbackSuccessPlan,
    CSharpCallablePlan, CSharpCallbackBridgeParamPlan, CSharpCallbackEntryPlan, CSharpCallbackPlan,
    CSharpCallbackProxyCallPlan, CSharpCallbackProxyPlan, CSharpCallbackResultCatchPlan,
    CSharpCallbackResultOkPlan, CSharpClassPlan, CSharpClosureInvokePlan, CSharpClosurePlan,
    CSharpConstructorKind, CSharpEnumPlan, CSharpFieldPlan, CSharpModulePlan, CSharpParamKind,
    CSharpRecordPlan, CSharpReturnKind, CSharpStreamDelivery, CSharpSyncCallbackOutInitializerPlan,
    CSharpSyncCallbackSuccessPlan,
};

/// Renders a `<summary>` doc block at `indent`, ending with a
/// trailing newline so the declaration that follows lands on the next
/// line. Returns the empty string when the comment is absent so the
/// declaration emits flush against the previous line; templates call
/// this inline with the declaration:
///
/// ```askama
/// {{ self::summary_doc_block(record.summary_doc, "    ") }}    public readonly record struct ...
/// ```
pub fn summary_doc_block(doc: &Option<CSharpComment>, indent: &str) -> String {
    let Some(comment) = doc.as_ref() else {
        return String::new();
    };
    let mut out = String::new();
    push_doc_line(&mut out, indent, "<summary>");
    for line in comment.lines() {
        push_text_line(&mut out, indent, line);
    }
    push_doc_line(&mut out, indent, "</summary>");
    out
}

/// Renders one `<param name="...">` block at `indent` for each field
/// that carries a doc comment. Used on positional records and
/// data-enum variants where individual fields have no separate
/// declaration to attach a `<summary>` to.
pub fn param_doc_block(fields: &[CSharpFieldPlan], indent: &str) -> String {
    let mut out = String::new();
    for field in fields {
        let Some(comment) = field.summary_doc.as_ref() else {
            continue;
        };
        let mut iter = comment.lines();
        let first = iter.next().unwrap_or("");
        let rest: Vec<&str> = iter.collect();
        if rest.is_empty() {
            push_doc_line(
                &mut out,
                indent,
                &format!("<param name=\"{}\">{}</param>", field.name, first),
            );
            continue;
        }
        push_doc_line(
            &mut out,
            indent,
            &format!("<param name=\"{}\">", field.name),
        );
        push_text_line(&mut out, indent, first);
        for line in rest {
            push_text_line(&mut out, indent, line);
        }
        push_doc_line(&mut out, indent, "</param>");
    }
    out
}

fn push_doc_line(out: &mut String, indent: &str, payload: &str) {
    out.push_str(indent);
    out.push_str("/// ");
    out.push_str(payload);
    out.push('\n');
}

fn push_text_line(out: &mut String, indent: &str, line: &str) {
    out.push_str(indent);
    if line.is_empty() {
        out.push_str("///\n");
    } else {
        out.push_str("/// ");
        out.push_str(line);
        out.push('\n');
    }
}

/// Renders the file header: auto-generated comment, `using` directives,
/// and namespace declaration.
#[derive(Template)]
#[template(path = "render_csharp/preamble.txt", escape = "none")]
pub struct PreambleTemplate<'a> {
    pub module: &'a CSharpModulePlan,
}

/// Renders a public callback interface.
#[derive(Template)]
#[template(path = "render_csharp/callback_interface.txt", escape = "none")]
pub struct CallbackInterfaceTemplate<'a> {
    pub callback: &'a CSharpCallbackPlan,
}

/// Renders the public owning proxy for callback handles returned from Rust.
#[derive(Template)]
#[template(path = "render_csharp/callback_proxy.txt", escape = "none")]
pub struct CallbackProxyTemplate<'a> {
    pub callback: &'a CSharpCallbackPlan,
}

/// Renders the managed vtable bridge for a callback interface.
#[derive(Template)]
#[template(path = "render_csharp/callback_bridge.txt", escape = "none")]
pub struct CallbackBridgeTemplate<'a> {
    pub callback: &'a CSharpCallbackPlan,
}

/// Renders a public closure delegate.
#[derive(Template)]
#[template(path = "render_csharp/closure_delegate.txt", escape = "none")]
pub struct ClosureDelegateTemplate<'a> {
    pub closure: &'a CSharpClosurePlan,
}

/// Renders the managed bridge used to pin and invoke a closure delegate.
#[derive(Template)]
#[template(path = "render_csharp/closure_bridge.txt", escape = "none")]
pub struct ClosureBridgeTemplate<'a> {
    pub closure: &'a CSharpClosurePlan,
}

/// Renders the public static wrapper class with methods that delegate
/// to the native P/Invoke declarations.
#[derive(Template)]
#[template(path = "render_csharp/functions.txt", escape = "none")]
pub struct FunctionsTemplate<'a> {
    pub module: &'a CSharpModulePlan,
}

/// Renders the `NativeMethods` static class containing `[DllImport]`
/// declarations for the C FFI functions.
#[derive(Template)]
#[template(path = "render_csharp/native.txt", escape = "none")]
pub struct NativeTemplate<'a> {
    pub module: &'a CSharpModulePlan,
}

/// Renders a single record as a standalone `.cs` file. Each record becomes
/// a `readonly record struct`, with a `[StructLayout(Sequential)]`
/// attribute for blittable records (passed directly across P/Invoke) and
/// wire encode/decode helpers for the wire-encoded path.
#[derive(Template)]
#[template(path = "render_csharp/record.txt", escape = "none")]
pub struct RecordTemplate<'a> {
    pub record: &'a CSharpRecordPlan,
    pub namespace: &'a CSharpNamespace,
}

/// Renders a single C-style enum as a standalone `.cs` file: the native
/// `public enum` declaration plus the `*Wire` static helper class that
/// supplies `Decode` and the `WireEncodeTo` extension method. The enum
/// itself passes across P/Invoke as its declared integral backing type;
/// the wire helpers exist so records and data-enum variants embedding the
/// enum can stay on the same `this.Field.WireEncodeTo(wire)` call shape
/// as records.
#[derive(Template)]
#[template(path = "render_csharp/enum_c_style.txt", escape = "none")]
pub struct EnumCStyleTemplate<'a> {
    pub enumeration: &'a CSharpEnumPlan,
    pub namespace: &'a CSharpNamespace,
}

/// Renders a data enum as an `abstract record` with nested `sealed record`
/// variants. Closed hierarchy (private constructor), value equality per
/// variant, and pattern-matching wire codec using switch expressions for
/// the pure paths and a switch statement for the side-effecting encode.
#[derive(Template)]
#[template(path = "render_csharp/enum_data.txt", escape = "none")]
pub struct EnumDataTemplate<'a> {
    pub enumeration: &'a CSharpEnumPlan,
    pub namespace: &'a CSharpNamespace,
}

/// Renders a Rust class as a sealed C# class implementing `IDisposable`
/// around an opaque `IntPtr` handle. The wrapper takes ownership of the
/// handle, frees it through `NativeMethods.{Class}Free` on `Dispose`,
/// and falls back to the finalizer if the consumer forgets.
#[derive(Template)]
#[template(path = "render_csharp/class.txt", escape = "none")]
pub struct ClassTemplate<'a> {
    pub class: &'a CSharpClassPlan,
    pub namespace: &'a CSharpNamespace,
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ir::definitions::StreamMode;
    use crate::render::csharp::ast::{
        CSharpArgumentList, CSharpBinaryOp, CSharpClassName, CSharpEnumUnderlyingType,
        CSharpExpression, CSharpIdentity, CSharpLiteral, CSharpLocalName, CSharpMethodName,
        CSharpParamName, CSharpPropertyName, CSharpStatement, CSharpType, CSharpTypeReference,
    };
    use crate::render::csharp::plan::{
        CFunctionName, CSharpAsyncCallPlan, CSharpClassPlan, CSharpConstructorKind,
        CSharpConstructorPlan, CSharpEnumKind, CSharpEnumPlan, CSharpEnumVariantPlan,
        CSharpFieldPlan, CSharpFunctionPlan, CSharpMethodPlan, CSharpParamKind, CSharpParamPlan,
        CSharpReceiver, CSharpRecordPlan, CSharpReturnKind, CSharpStreamDelivery, CSharpStreamPlan,
    };
    use boltffi_ffi_rules::naming::{LibraryName, Name};

    fn demo_namespace() -> CSharpNamespace {
        CSharpNamespace::from_source("demo")
    }

    fn record_type(name: &str) -> CSharpType {
        CSharpType::Record(CSharpClassName::from_source(name).into())
    }

    fn c_style_enum_type(name: &str) -> CSharpType {
        CSharpType::CStyleEnum(CSharpClassName::from_source(name).into())
    }

    fn data_enum_type(name: &str) -> CSharpType {
        CSharpType::DataEnum(CSharpClassName::from_source(name).into())
    }

    /// `name` is the property name as it appears in generated C#
    /// (PascalCase). The test fixtures pass it in already-shaped
    /// because the generated code is what they're pinning.
    fn record_field(
        name: &str,
        csharp_type: CSharpType,
        decode: CSharpExpression,
        size: CSharpExpression,
        encode: CSharpStatement,
    ) -> CSharpFieldPlan {
        CSharpFieldPlan {
            summary_doc: None,
            name: CSharpPropertyName::from_source(name),
            csharp_type,
            wire_decode_expr: decode,
            wire_size_expr: size,
            wire_encode_stmts: vec![encode],
        }
    }

    fn local_ident(name: &str) -> CSharpExpression {
        CSharpExpression::Identity(CSharpIdentity::Local(CSharpLocalName::new(name)))
    }

    /// `reader.{Method}()` — the canonical decode shape for primitives,
    /// `String`, and `Bytes`.
    fn read_call(method: &str) -> CSharpExpression {
        CSharpExpression::MethodCall {
            receiver: Box::new(local_ident("reader")),
            method: CSharpMethodName::from_source(method),
            type_args: vec![],
            args: CSharpArgumentList::default(),
        }
    }

    /// `{Class}.Decode(reader)` — the decode shape for record-typed and
    /// C-style-enum-wire-helper-typed fields.
    fn class_decode(class: &str) -> CSharpExpression {
        CSharpExpression::MethodCall {
            receiver: Box::new(CSharpExpression::TypeRef(CSharpTypeReference::Plain(
                CSharpClassName::new(class),
            ))),
            method: CSharpMethodName::from_source("decode"),
            type_args: vec![],
            args: vec![local_ident("reader")].into(),
        }
    }

    /// `n` — integer literal, the size shape for primitives and
    /// fixed-size composites.
    fn int_lit(n: i64) -> CSharpExpression {
        CSharpExpression::Literal(CSharpLiteral::Int(n))
    }

    /// `wire.{Method}(this.{Field})` as a statement — the encode shape
    /// for primitive-typed and string-typed record fields.
    fn wire_write_this(method: &str, field: &str) -> CSharpStatement {
        CSharpStatement::Expression(CSharpExpression::MethodCall {
            receiver: Box::new(local_ident("wire")),
            method: CSharpMethodName::from_source(method),
            type_args: vec![],
            args: vec![CSharpExpression::MemberAccess {
                receiver: Box::new(CSharpExpression::Identity(CSharpIdentity::This)),
                name: CSharpPropertyName::from_source(field),
            }]
            .into(),
        })
    }

    /// `wire.{Method}({local}.{Field})` as a statement — variant of
    /// [`wire_write_this`] for variant payloads bound through a local
    /// (e.g. `_v.Radius`).
    fn wire_write_local_field(method: &str, local: &str, field: &str) -> CSharpStatement {
        CSharpStatement::Expression(CSharpExpression::MethodCall {
            receiver: Box::new(local_ident("wire")),
            method: CSharpMethodName::from_source(method),
            type_args: vec![],
            args: vec![CSharpExpression::MemberAccess {
                receiver: Box::new(local_ident(local)),
                name: CSharpPropertyName::from_source(field),
            }]
            .into(),
        })
    }

    /// `this.{Field}.WireEncodeTo(wire)` — the encode shape for nested
    /// record fields and C-style-enum-typed fields.
    fn this_wire_encode(field: &str) -> CSharpStatement {
        CSharpStatement::Expression(CSharpExpression::MethodCall {
            receiver: Box::new(CSharpExpression::MemberAccess {
                receiver: Box::new(CSharpExpression::Identity(CSharpIdentity::This)),
                name: CSharpPropertyName::from_source(field),
            }),
            method: CSharpMethodName::new("WireEncodeTo"),
            type_args: vec![],
            args: vec![local_ident("wire")].into(),
        })
    }

    /// `wire.WriteString(this.{Field})` is the same shape as
    /// [`wire_write_this`] but the encoded-size contribution for a
    /// string field is the bespoke `(4 + Encoding.UTF8.GetByteCount(this.{Field}))`.
    fn string_size_this(field: &str) -> CSharpExpression {
        let utf8 = CSharpExpression::MemberAccess {
            receiver: Box::new(CSharpExpression::TypeRef(CSharpTypeReference::Plain(
                CSharpClassName::new("Encoding"),
            ))),
            name: CSharpPropertyName::from_source("UTF8"),
        };
        let byte_count = CSharpExpression::MethodCall {
            receiver: Box::new(utf8),
            method: CSharpMethodName::new("GetByteCount"),
            type_args: vec![],
            args: vec![CSharpExpression::MemberAccess {
                receiver: Box::new(CSharpExpression::Identity(CSharpIdentity::This)),
                name: CSharpPropertyName::from_source(field),
            }]
            .into(),
        };
        CSharpExpression::Paren(Box::new(CSharpExpression::Binary {
            op: CSharpBinaryOp::Add,
            left: Box::new(int_lit(4)),
            right: Box::new(byte_count),
        }))
    }

    /// Point: the canonical blittable record. Two f64 fields, `#[repr(C)]`
    /// in Rust. Carries `[StructLayout(Sequential)]` and still emits wire
    /// helpers so it can be embedded inside a non-blittable record's
    /// wire encode/decode path without a second code shape.
    #[test]
    fn snapshot_blittable_record_point() {
        let record = CSharpRecordPlan {
            summary_doc: None,
            class_name: CSharpClassName::from_source("point"),
            is_blittable: true,
            fields: vec![
                record_field(
                    "X",
                    CSharpType::Double,
                    read_call("read_f64"),
                    int_lit(8),
                    wire_write_this("write_f64", "X"),
                ),
                record_field(
                    "Y",
                    CSharpType::Double,
                    read_call("read_f64"),
                    int_lit(8),
                    wire_write_this("write_f64", "Y"),
                ),
            ],
            methods: vec![],
            is_error: false,
        };
        let template = RecordTemplate {
            record: &record,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// Person: the canonical non-blittable record: a string field (which
    /// forces the wire path) plus a primitive. No StructLayout attribute.
    /// Imports `System.Text` because the size expression uses
    /// `Encoding.UTF8.GetByteCount`.
    #[test]
    fn snapshot_non_blittable_record_person_with_string() {
        let record = CSharpRecordPlan {
            summary_doc: None,
            class_name: CSharpClassName::from_source("person"),
            is_blittable: false,
            fields: vec![
                record_field(
                    "Name",
                    CSharpType::String,
                    read_call("read_string"),
                    string_size_this("Name"),
                    wire_write_this("write_string", "Name"),
                ),
                record_field(
                    "Age",
                    CSharpType::UInt,
                    read_call("read_u32"),
                    int_lit(4),
                    wire_write_this("write_u32", "Age"),
                ),
            ],
            methods: vec![],
            is_error: false,
        };
        let template = RecordTemplate {
            record: &record,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// Line: a record whose fields are themselves records. The decode
    /// expression for a record-typed field is `Point.Decode(reader)` and
    /// the encode is `this.Start.WireEncodeTo(wire)`, the recursive
    /// glue that lets records compose.
    #[test]
    fn snapshot_nested_record_line() {
        let record = CSharpRecordPlan {
            summary_doc: None,
            class_name: CSharpClassName::from_source("line"),
            is_blittable: false,
            fields: vec![
                record_field(
                    "Start",
                    record_type("point"),
                    class_decode("Point"),
                    int_lit(16),
                    this_wire_encode("Start"),
                ),
                record_field(
                    "End",
                    record_type("point"),
                    class_decode("Point"),
                    int_lit(16),
                    this_wire_encode("End"),
                ),
            ],
            methods: vec![],
            is_error: false,
        };
        let template = RecordTemplate {
            record: &record,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// A fieldless record: the template must still produce valid C#.
    /// `WireEncodedSize` returns 0 and `WireEncodeTo` is an empty method.
    #[test]
    fn snapshot_empty_record() {
        let record = CSharpRecordPlan {
            summary_doc: None,
            class_name: CSharpClassName::from_source("unit"),
            is_blittable: true,
            fields: vec![],
            methods: vec![],
            is_error: false,
        };
        let template = RecordTemplate {
            record: &record,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// Flag: the canonical "blittable record with a C-style enum field."
    /// Status is an `enum : int` here, so embedding it alongside a `uint`
    /// keeps the record on the zero-copy P/Invoke path with
    /// `[StructLayout(Sequential)]`. The wire helpers are still emitted
    /// so non-blittable records that embed `Flag` can reach its
    /// wire encoder without a second rendering shape.
    #[test]
    fn snapshot_blittable_record_with_cstyle_enum_field() {
        let record = CSharpRecordPlan {
            summary_doc: None,
            class_name: CSharpClassName::from_source("flag"),
            is_blittable: true,
            fields: vec![
                record_field(
                    "Status",
                    c_style_enum_type("status"),
                    class_decode("StatusWire"),
                    int_lit(4),
                    this_wire_encode("Status"),
                ),
                record_field(
                    "Count",
                    CSharpType::UInt,
                    read_call("read_u32"),
                    int_lit(4),
                    wire_write_this("write_u32", "Count"),
                ),
            ],
            methods: vec![],
            is_error: false,
        };
        let template = RecordTemplate {
            record: &record,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// Blittable record with `#[data(impl)]` methods. Pins the two
    /// rendering shapes for record methods on a value-by-value receiver:
    ///
    /// - Static factory: `public static Point Origin()` returning the
    ///   struct directly across P/Invoke (no `this`).
    /// - Instance method on a blittable owner: `public double Distance()`
    ///   with `this` passed by value instead of wire-encoded — the
    ///   `owner_is_blittable` branch of `CSharpReceiver::InstanceNative`.
    #[test]
    fn snapshot_blittable_record_with_methods_point() {
        let methods = vec![
            method_with_owner(
                "Point",
                "Origin",
                "boltffi_point_origin",
                CSharpReceiver::Static,
                vec![],
                record_type("point"),
                CSharpReturnKind::Direct,
                true,
            ),
            method_with_owner(
                "Point",
                "Distance",
                "boltffi_point_distance",
                CSharpReceiver::InstanceNative,
                vec![],
                CSharpType::Double,
                CSharpReturnKind::Direct,
                true,
            ),
            method_with_owner(
                "Point",
                "Add",
                "boltffi_point_add",
                CSharpReceiver::InstanceNative,
                vec![param("other", record_type("point"))],
                record_type("point"),
                CSharpReturnKind::Direct,
                true,
            ),
        ];
        let record = CSharpRecordPlan {
            summary_doc: None,
            class_name: CSharpClassName::from_source("point"),
            is_blittable: true,
            fields: vec![
                record_field(
                    "X",
                    CSharpType::Double,
                    read_call("read_f64"),
                    int_lit(8),
                    wire_write_this("write_f64", "X"),
                ),
                record_field(
                    "Y",
                    CSharpType::Double,
                    read_call("read_f64"),
                    int_lit(8),
                    wire_write_this("write_f64", "Y"),
                ),
            ],
            methods,
            is_error: false,
        };
        let template = RecordTemplate {
            record: &record,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// Non-blittable record with a `#[data(impl)]` instance method.
    /// Pins the wire-encoded receiver path: the body wire-encodes `this`
    /// into a `(byte[] _selfBytes, UIntPtr selfLen)` pair via
    /// `_wire_self`, calls the native, and decodes the returned `FfiBuf`
    /// into a string. Same shape that `value_type_method.txt` produces
    /// for data-enum instance methods.
    #[test]
    fn snapshot_non_blittable_record_with_methods_service_config() {
        let describe = method_with_owner(
            "ServiceConfig",
            "Describe",
            "boltffi_service_config_describe",
            CSharpReceiver::InstanceNative,
            vec![],
            CSharpType::String,
            CSharpReturnKind::WireDecodeString,
            false,
        );
        let record = CSharpRecordPlan {
            summary_doc: None,
            class_name: CSharpClassName::from_source("service_config"),
            is_blittable: false,
            fields: vec![
                record_field(
                    "Name",
                    CSharpType::String,
                    read_call("read_string"),
                    string_size_this("Name"),
                    wire_write_this("write_string", "Name"),
                ),
                record_field(
                    "Retries",
                    CSharpType::Int,
                    read_call("read_i32"),
                    int_lit(4),
                    wire_write_this("write_i32", "Retries"),
                ),
            ],
            methods: vec![describe],
            is_error: false,
        };
        let template = RecordTemplate {
            record: &record,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// `owner_class_name` and `name` are already in their rendered C#
    /// form — the fixtures pin what the generated code looks like, so
    /// we don't run a source transform on them.
    ///
    /// Mirrors the lowerer's contract for `InstanceNative` receivers:
    /// the receiver's wire-encode block lives at `wire_writers[0]`, so
    /// the snapshot fixtures synthesize the same plan the lowerer would.
    fn method(
        owner_class_name: &str,
        name: &str,
        ffi_name: &str,
        receiver: CSharpReceiver,
        params: Vec<CSharpParamPlan>,
        return_type: CSharpType,
        return_kind: CSharpReturnKind,
    ) -> CSharpMethodPlan {
        method_with_owner(
            owner_class_name,
            name,
            ffi_name,
            receiver,
            params,
            return_type,
            return_kind,
            false,
        )
    }

    #[allow(clippy::too_many_arguments)]
    fn method_with_owner(
        owner_class_name: &str,
        name: &str,
        ffi_name: &str,
        receiver: CSharpReceiver,
        params: Vec<CSharpParamPlan>,
        return_type: CSharpType,
        return_kind: CSharpReturnKind,
        owner_is_blittable: bool,
    ) -> CSharpMethodPlan {
        let owner = CSharpClassName::from_source(owner_class_name);
        let method_name = CSharpMethodName::from_source(name);
        // Wire-encoded `InstanceNative` receivers (data enums, non-
        // blittable records) need a self_wire_writer to encode `this`
        // before the call. Blittable `InstanceNative` receivers don't —
        // they pass `this` directly across P/Invoke.
        let wire_writers =
            if matches!(receiver, CSharpReceiver::InstanceNative) && !owner_is_blittable {
                vec![crate::render::csharp::lower::self_wire_writer()]
            } else {
                vec![]
            };
        CSharpMethodPlan {
            summary_doc: None,
            native_method_name: CSharpMethodName::native_for_owner(&owner, &method_name),
            name: method_name,
            ffi_name: CFunctionName::new(ffi_name.to_string()),
            async_call: None,
            receiver,
            params,
            return_type,
            return_kind,
            wire_writers,
            owner_is_blittable,
        }
    }

    fn param(name: &str, csharp_type: CSharpType) -> CSharpParamPlan {
        CSharpParamPlan {
            name: CSharpParamName::from_source(name),
            csharp_type,
            kind: CSharpParamKind::Direct,
        }
    }

    fn method_name(source: &str) -> CSharpMethodName {
        CSharpMethodName::from_source(source)
    }

    fn csharp_class_name(source: &str) -> CSharpClassName {
        CSharpClassName::from_source(source)
    }

    fn c_function_name(name: &str) -> CFunctionName {
        CFunctionName::new(name.to_string())
    }

    fn param_with_kind(
        name: &str,
        csharp_type: CSharpType,
        kind: CSharpParamKind,
    ) -> CSharpParamPlan {
        CSharpParamPlan {
            name: CSharpParamName::from_source(name),
            csharp_type,
            kind,
        }
    }

    fn async_call_for(
        native_method_name: &CSharpMethodName,
        ffi_name: &CFunctionName,
    ) -> CSharpAsyncCallPlan {
        CSharpAsyncCallPlan {
            poll_ffi_name: CFunctionName::new(format!("{ffi_name}_poll")),
            complete_ffi_name: CFunctionName::new(format!("{ffi_name}_complete")),
            cancel_ffi_name: CFunctionName::new(format!("{ffi_name}_cancel")),
            free_ffi_name: CFunctionName::new(format!("{ffi_name}_free")),
            poll_method_name: CSharpMethodName::new(format!("{native_method_name}Poll")),
            complete_method_name: CSharpMethodName::new(format!("{native_method_name}Complete")),
            cancel_method_name: CSharpMethodName::new(format!("{native_method_name}Cancel")),
            free_method_name: CSharpMethodName::new(format!("{native_method_name}Free")),
        }
    }

    fn async_function(
        name: CSharpMethodName,
        ffi_name: CFunctionName,
        params: Vec<CSharpParamPlan>,
        return_type: CSharpType,
        return_kind: CSharpReturnKind,
    ) -> CSharpFunctionPlan {
        let async_call = async_call_for(&name, &ffi_name);
        CSharpFunctionPlan {
            summary_doc: None,
            name,
            params,
            return_type,
            return_kind,
            async_call: Some(async_call),
            ffi_name,
            wire_writers: vec![],
        }
    }

    #[allow(clippy::too_many_arguments)]
    fn async_method_with_owner(
        owner_class_name: CSharpClassName,
        name: CSharpMethodName,
        ffi_name: CFunctionName,
        receiver: CSharpReceiver,
        params: Vec<CSharpParamPlan>,
        return_type: CSharpType,
        return_kind: CSharpReturnKind,
        owner_is_blittable: bool,
    ) -> CSharpMethodPlan {
        let native_method_name = CSharpMethodName::native_for_owner(&owner_class_name, &name);
        let async_call = async_call_for(&native_method_name, &ffi_name);
        let wire_writers =
            if matches!(receiver, CSharpReceiver::InstanceNative) && !owner_is_blittable {
                vec![crate::render::csharp::lower::self_wire_writer()]
            } else {
                vec![]
            };
        CSharpMethodPlan {
            summary_doc: None,
            native_method_name,
            name,
            ffi_name,
            async_call: Some(async_call),
            receiver,
            params,
            return_type,
            return_kind,
            wire_writers,
            owner_is_blittable,
        }
    }

    fn module_with_async_functions(functions: Vec<CSharpFunctionPlan>) -> CSharpModulePlan {
        CSharpModulePlan {
            namespace: demo_namespace(),
            class_name: CSharpClassName::from_source("demo_lib"),
            lib_name: Name::<LibraryName>::new("demo".to_string()),
            free_buf_ffi_name: CFunctionName::new("boltffi_free_buf".to_string()),
            records: vec![],
            enums: vec![],
            functions,
            classes: vec![],
            callbacks: vec![],
            closures: vec![],
            builtins: crate::render::csharp::plan::CSharpBuiltinSet::default(),
        }
    }

    fn module_with_classes(classes: Vec<CSharpClassPlan>) -> CSharpModulePlan {
        let mut module = module_with_async_functions(vec![]);
        module.classes = classes;
        module
    }

    fn stream(owner: &CSharpClassName, name: &str, item_type: CSharpType) -> CSharpStreamPlan {
        let stream_name = CSharpMethodName::from_source(name);
        let subscribe_method_name = CSharpMethodName::native_for_owner(owner, &stream_name);
        CSharpStreamPlan {
            summary_doc: None,
            name: stream_name,
            item_type,
            mode: StreamMode::Async,
            delivery: CSharpStreamDelivery::Direct,
            subscribe_method_name: subscribe_method_name.clone(),
            subscribe_ffi_name: CFunctionName::new(format!("boltffi_event_bus_{name}")),
            pop_batch_method_name: CSharpMethodName::new(format!(
                "{subscribe_method_name}PopBatch"
            )),
            pop_batch_ffi_name: CFunctionName::new(format!("boltffi_event_bus_{name}_pop_batch")),
            wait_method_name: CSharpMethodName::new(format!("{subscribe_method_name}Wait")),
            wait_ffi_name: CFunctionName::new(format!("boltffi_event_bus_{name}_wait")),
            unsubscribe_method_name: CSharpMethodName::new(format!(
                "{subscribe_method_name}Unsubscribe"
            )),
            unsubscribe_ffi_name: CFunctionName::new(format!(
                "boltffi_event_bus_{name}_unsubscribe"
            )),
            free_method_name: CSharpMethodName::new(format!("{subscribe_method_name}Free")),
            free_ffi_name: CFunctionName::new(format!("boltffi_event_bus_{name}_free")),
        }
    }

    fn option_i32_decode_expr() -> CSharpExpression {
        CSharpExpression::Ternary {
            cond: Box::new(CSharpExpression::Binary {
                op: CSharpBinaryOp::Eq,
                left: Box::new(read_call("read_u8")),
                right: Box::new(CSharpExpression::Literal(CSharpLiteral::Int(0))),
            }),
            then: Box::new(CSharpExpression::Cast {
                target: CSharpType::Nullable(Box::new(CSharpType::Int)),
                inner: Box::new(CSharpExpression::Literal(CSharpLiteral::Null)),
            }),
            otherwise: Box::new(read_call("read_i32")),
        }
    }

    fn bolt_exception_from_reader() -> CSharpExpression {
        CSharpExpression::New {
            target: CSharpType::Record(CSharpTypeReference::Plain(CSharpClassName::new(
                "BoltException",
            ))),
            args: vec![read_call("read_string")].into(),
        }
    }

    #[test]
    fn snapshot_async_functions_task_overloads_and_return_shapes() {
        let functions = vec![
            async_function(
                method_name("async_add"),
                c_function_name("boltffi_async_add"),
                vec![param("a", CSharpType::Int), param("b", CSharpType::Int)],
                CSharpType::Int,
                CSharpReturnKind::Direct,
            ),
            async_function(
                method_name("async_notify"),
                c_function_name("boltffi_async_notify"),
                vec![],
                CSharpType::Void,
                CSharpReturnKind::Void,
            ),
            async_function(
                method_name("async_echo"),
                c_function_name("boltffi_async_echo"),
                vec![param_with_kind(
                    "value",
                    CSharpType::String,
                    CSharpParamKind::Utf8Bytes,
                )],
                CSharpType::String,
                CSharpReturnKind::WireDecodeString,
            ),
            async_function(
                method_name("async_double_all"),
                c_function_name("boltffi_async_double_all"),
                vec![param_with_kind(
                    "values",
                    CSharpType::Array(Box::new(CSharpType::Int)),
                    CSharpParamKind::DirectArray,
                )],
                CSharpType::Array(Box::new(CSharpType::Int)),
                CSharpReturnKind::WireDecodeBlittablePrimitiveArray {
                    method: CSharpMethodName::new("ReadBlittableArray"),
                    type_arg: Some(CSharpType::Int),
                },
            ),
            async_function(
                method_name("async_find_positive"),
                c_function_name("boltffi_async_find_positive"),
                vec![param_with_kind(
                    "values",
                    CSharpType::Array(Box::new(CSharpType::Int)),
                    CSharpParamKind::DirectArray,
                )],
                CSharpType::Nullable(Box::new(CSharpType::Int)),
                CSharpReturnKind::WireDecodeOption {
                    decode_expr: option_i32_decode_expr(),
                },
            ),
            async_function(
                method_name("try_compute_async"),
                c_function_name("boltffi_try_compute_async"),
                vec![param("value", CSharpType::Int)],
                CSharpType::Int,
                CSharpReturnKind::WireDecodeResult {
                    ok_decode_expr: Some(read_call("read_i32")),
                    err_throw_expr: bolt_exception_from_reader(),
                },
            ),
        ];
        let module = module_with_async_functions(functions);
        let template = FunctionsTemplate { module: &module };

        insta::assert_snapshot!(template.render().unwrap());
    }

    /// A module with no BoltFFI built-in value types must not carry any
    /// `Read{Builtin}` / `Write{Builtin}` helpers in the generated
    /// `WireReader` / `WireWriter`. Mirrors the gating Java added in
    /// PR #330 (commit 1a665b9): unused helpers would be dead code in
    /// every module that doesn't use Duration/SystemTime/UUID/URL,
    /// and on .NET they'd also drag a `global::System.Uri` reference
    /// into modules that have no need for it.
    #[test]
    fn native_runtime_omits_builtin_helpers_when_module_has_no_builtins() {
        use crate::render::csharp::plan::CSharpBuiltinSet;

        // A record (no builtin fields) is enough to switch on the
        // wire-reader/writer emission; the record itself doesn't
        // reference any builtin types, so the builtin helpers should
        // stay omitted from the runtime.
        let mut module = module_with_async_functions(vec![]);
        module.records.push(CSharpRecordPlan {
            summary_doc: None,
            class_name: CSharpClassName::from_source("placeholder"),
            is_blittable: false,
            fields: vec![],
            methods: vec![],
            is_error: false,
        });
        module.builtins = CSharpBuiltinSet::default();

        let source = NativeTemplate { module: &module }.render().unwrap();

        assert!(!source.contains("ReadDuration"));
        assert!(!source.contains("ReadDateTime"));
        assert!(!source.contains("ReadUuid"));
        assert!(!source.contains("ReadUri"));
        assert!(!source.contains("WriteDuration"));
        assert!(!source.contains("WriteDateTime"));
        assert!(!source.contains("WriteUuid"));
        assert!(!source.contains("WriteUri"));
        assert!(!source.contains("global::System.TimeSpan"));
        assert!(!source.contains("global::System.DateTime"));
        assert!(!source.contains("global::System.Guid"));
        assert!(!source.contains("global::System.Uri"));
    }

    /// When the contract uses all four built-in value types, every
    /// helper appears in the generated runtime and references go
    /// through `global::System.` so a user-defined same-name record
    /// can't shadow them.
    #[test]
    fn native_runtime_emits_fully_qualified_helpers_when_all_builtins_are_used() {
        use crate::ir::types::BuiltinKind;
        use crate::render::csharp::plan::CSharpBuiltinSet;

        let mut module = module_with_async_functions(vec![]);
        module.records.push(CSharpRecordPlan {
            summary_doc: None,
            class_name: CSharpClassName::from_source("placeholder"),
            is_blittable: false,
            fields: vec![],
            methods: vec![],
            is_error: false,
        });
        module.builtins = CSharpBuiltinSet::from_kinds(
            [
                BuiltinKind::Duration,
                BuiltinKind::SystemTime,
                BuiltinKind::Uuid,
                BuiltinKind::Url,
            ]
            .into_iter()
            .collect(),
        );

        let source = NativeTemplate { module: &module }.render().unwrap();

        assert!(source.contains("internal global::System.TimeSpan ReadDuration()"));
        assert!(source.contains("internal global::System.DateTime ReadDateTime()"));
        assert!(source.contains("internal global::System.Guid ReadUuid()"));
        assert!(source.contains("internal global::System.Uri ReadUri()"));
        assert!(source.contains("internal void WriteDuration(global::System.TimeSpan v)"));
        assert!(source.contains("internal void WriteDateTime(global::System.DateTime v)"));
        assert!(source.contains("internal void WriteUuid(global::System.Guid v)"));
        assert!(source.contains("internal void WriteUri(global::System.Uri v)"));
    }

    #[test]
    fn snapshot_async_native_runtime_and_pinvoke_declarations() {
        let module = module_with_async_functions(vec![async_function(
            method_name("async_add"),
            c_function_name("boltffi_async_add"),
            vec![param("a", CSharpType::Int), param("b", CSharpType::Int)],
            CSharpType::Int,
            CSharpReturnKind::Direct,
        )]);
        let template = NativeTemplate { module: &module };

        insta::assert_snapshot!(template.render().unwrap());
    }

    #[test]
    fn snapshot_class_counter_with_async_method() {
        let class_name = CSharpClassName::from_source("counter");
        let method = async_method_with_owner(
            csharp_class_name("counter"),
            CSharpMethodName::new("AsyncGet"),
            c_function_name("boltffi_counter_async_get"),
            CSharpReceiver::ClassInstance,
            vec![],
            CSharpType::Int,
            CSharpReturnKind::Direct,
            false,
        );
        let class = CSharpClassPlan {
            summary_doc: None,
            native_free_method_name: CSharpMethodName::native_for_owner(
                &class_name,
                &CSharpMethodName::new("Free"),
            ),
            class_name,
            ffi_free: CFunctionName::new("boltffi_counter_free".to_string()),
            constructors: vec![],
            methods: vec![method],
            streams: vec![],
        };
        let template = ClassTemplate {
            class: &class,
            namespace: &demo_namespace(),
        };

        insta::assert_snapshot!(template.render().unwrap());
    }

    /// Build a CSharpEnumPlan for the snapshot fixtures. Accepts the
    /// already-rendered class name and populates the derived
    /// `wire_class_name` and `methods_class_name` to match what the
    /// lowerer would produce.
    fn build_enum(
        class_source: &str,
        kind: CSharpEnumKind,
        underlying_type: Option<CSharpEnumUnderlyingType>,
        variants: Vec<CSharpEnumVariantPlan>,
        methods: Vec<CSharpMethodPlan>,
    ) -> CSharpEnumPlan {
        let class_name = CSharpClassName::from_source(class_source);
        let wire_class_name = CSharpClassName::wire_helper(&class_name);
        let methods_class_name = if methods.is_empty() {
            None
        } else {
            Some(CSharpClassName::methods_companion(&class_name))
        };
        CSharpEnumPlan {
            summary_doc: None,
            class_name,
            wire_class_name,
            methods_class_name,
            kind,
            underlying_type,
            variants,
            methods,
            is_error: false,
        }
    }

    fn variant(
        name: &str,
        tag: i32,
        wire_tag: i32,
        fields: Vec<CSharpFieldPlan>,
    ) -> CSharpEnumVariantPlan {
        CSharpEnumVariantPlan {
            summary_doc: None,
            name: CSharpClassName::from_source(name),
            tag,
            wire_tag,
            fields,
        }
    }

    /// Direction: C-style enum with a mix of static factories and
    /// instance methods. Renders alongside the `DirectionWire` helper
    /// plus a `DirectionMethods` companion static class; instance
    /// methods become C# extension methods (`this Direction self`) so
    /// `d.Opposite()` works without members on the enum itself.
    #[test]
    fn snapshot_c_style_enum_with_methods_direction() {
        let variants = vec![
            variant("North", 0, 0, vec![]),
            variant("South", 1, 1, vec![]),
            variant("East", 2, 2, vec![]),
            variant("West", 3, 3, vec![]),
        ];
        let methods = vec![
            method(
                "Direction",
                "FromDegrees",
                "boltffi_direction_from_degrees",
                CSharpReceiver::Static,
                vec![param("degrees", CSharpType::Double)],
                c_style_enum_type("direction"),
                CSharpReturnKind::Direct,
            ),
            method(
                "Direction",
                "Count",
                "boltffi_direction_count",
                CSharpReceiver::Static,
                vec![],
                CSharpType::UInt,
                CSharpReturnKind::Direct,
            ),
            method(
                "Direction",
                "Opposite",
                "boltffi_direction_opposite",
                CSharpReceiver::InstanceExtension,
                vec![],
                c_style_enum_type("direction"),
                CSharpReturnKind::Direct,
            ),
            method(
                "Direction",
                "IsHorizontal",
                "boltffi_direction_is_horizontal",
                CSharpReceiver::InstanceExtension,
                vec![],
                CSharpType::Bool,
                CSharpReturnKind::Direct,
            ),
            method(
                "Direction",
                "Label",
                "boltffi_direction_label",
                CSharpReceiver::InstanceExtension,
                vec![],
                CSharpType::String,
                CSharpReturnKind::WireDecodeString,
            ),
        ];
        let enumeration = build_enum(
            "direction",
            CSharpEnumKind::CStyle,
            Some(CSharpEnumUnderlyingType::Int),
            variants,
            methods,
        );
        let template = EnumCStyleTemplate {
            enumeration: &enumeration,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// Status: the canonical C-style enum. Three unit variants with
    /// ordinal-index tags. Renders as a native `public enum : int` plus
    /// the `StatusWire` static helper with the `WireEncodeTo` extension.
    #[test]
    fn snapshot_c_style_enum_status() {
        let variants = vec![
            variant("Active", 0, 0, vec![]),
            variant("Inactive", 1, 1, vec![]),
            variant("Pending", 2, 2, vec![]),
        ];
        let enumeration = build_enum(
            "status",
            CSharpEnumKind::CStyle,
            Some(CSharpEnumUnderlyingType::Int),
            variants,
            vec![],
        );
        let template = EnumCStyleTemplate {
            enumeration: &enumeration,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// LogLevel: a non-default C-style enum backing type. The C# surface
    /// must preserve the `byte` backing type so direct P/Invoke matches
    /// Rust's `#[repr(u8)]`, and the wire helper must use the 1-byte read /
    /// write ops rather than hard-coding `I32`.
    #[test]
    fn snapshot_c_style_enum_log_level_u8() {
        let variants = vec![
            variant("Trace", 0, 0, vec![]),
            variant("Debug", 1, 1, vec![]),
            variant("Info", 2, 2, vec![]),
            variant("Warn", 3, 3, vec![]),
            variant("Error", 4, 4, vec![]),
        ];
        let enumeration = build_enum(
            "log_level",
            CSharpEnumKind::CStyle,
            Some(CSharpEnumUnderlyingType::Byte),
            variants,
            vec![],
        );
        let template = EnumCStyleTemplate {
            enumeration: &enumeration,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// Shape: the canonical data enum exercising every payload shape:
    /// a single-field variant (Circle), a multi-field variant (Rectangle),
    /// and a unit variant (Point). Renders as `abstract record Shape`
    /// with nested `sealed record` variants, switch-expression Decode,
    /// and pattern-match WireEncodeTo. Field wire expressions reference
    /// the switch-bound local `_v`, not `this`.
    #[test]
    fn snapshot_data_enum_shape() {
        let variants = vec![
            variant(
                "Circle",
                0,
                0,
                vec![record_field(
                    "Radius",
                    CSharpType::Double,
                    read_call("read_f64"),
                    int_lit(8),
                    wire_write_local_field("write_f64", "_v", "Radius"),
                )],
            ),
            variant(
                "Rectangle",
                1,
                1,
                vec![
                    record_field(
                        "Width",
                        CSharpType::Double,
                        read_call("read_f64"),
                        int_lit(8),
                        wire_write_local_field("write_f64", "_v", "Width"),
                    ),
                    record_field(
                        "Height",
                        CSharpType::Double,
                        read_call("read_f64"),
                        int_lit(8),
                        wire_write_local_field("write_f64", "_v", "Height"),
                    ),
                ],
            ),
            variant("Point", 2, 2, vec![]),
        ];
        let enumeration = build_enum("shape", CSharpEnumKind::Data, None, variants, vec![]);
        let template = EnumDataTemplate {
            enumeration: &enumeration,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// Shape with methods: a data enum carrying both static factories
    /// (UnitCircle, VariantCount) and instance methods (Area, Describe).
    /// Methods render inside the abstract record body. Instance ones
    /// wire-encode `this` into `_selfBytes` before the native call.
    #[test]
    fn snapshot_data_enum_with_methods_shape() {
        let variants = vec![variant(
            "Circle",
            0,
            0,
            vec![record_field(
                "Radius",
                CSharpType::Double,
                read_call("read_f64"),
                int_lit(8),
                wire_write_local_field("write_f64", "_v", "Radius"),
            )],
        )];
        let methods = vec![
            method(
                "Shape",
                "UnitCircle",
                "boltffi_shape_unit_circle",
                CSharpReceiver::Static,
                vec![],
                data_enum_type("shape"),
                CSharpReturnKind::WireDecodeObject {
                    class_name: CSharpClassName::from_source("shape"),
                },
            ),
            method(
                "Shape",
                "VariantCount",
                "boltffi_shape_variant_count",
                CSharpReceiver::Static,
                vec![],
                CSharpType::UInt,
                CSharpReturnKind::Direct,
            ),
            method(
                "Shape",
                "Area",
                "boltffi_shape_area",
                CSharpReceiver::InstanceNative,
                vec![],
                CSharpType::Double,
                CSharpReturnKind::Direct,
            ),
            method(
                "Shape",
                "Describe",
                "boltffi_shape_describe",
                CSharpReceiver::InstanceNative,
                vec![],
                CSharpType::String,
                CSharpReturnKind::WireDecodeString,
            ),
        ];
        let enumeration = build_enum("shape", CSharpEnumKind::Data, None, variants, methods);
        let template = EnumDataTemplate {
            enumeration: &enumeration,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// Inventory: a Rust class with no constructors or methods exposed
    /// in the plan yet. Pins the bare IDisposable wrapper: the handle
    /// is held in a private `IntPtr` and freed exactly once through
    /// `NativeMethods.InventoryFree`, with the finalizer as a safety
    /// net for callers that forget to dispose.
    #[test]
    fn snapshot_class_inventory_idisposable_wrapper() {
        let class_name = CSharpClassName::from_source("inventory");
        let class = CSharpClassPlan {
            summary_doc: None,
            native_free_method_name: CSharpMethodName::native_for_owner(
                &class_name,
                &CSharpMethodName::new("Free"),
            ),
            class_name,
            ffi_free: CFunctionName::new("boltffi_inventory_free".to_string()),
            constructors: vec![],
            methods: vec![],
            streams: vec![],
        };
        let template = ClassTemplate {
            class: &class,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// Inventory with both a `Default` constructor (`new`) and a
    /// `NamedInit` constructor (`with_capacity(u32)`). Pins the two
    /// rendering shapes side by side: the primary lifts to a real C#
    /// instance constructor delegating through a private static
    /// helper, and the named-init lifts to a `public static` factory
    /// that wraps the returned `IntPtr`.
    #[test]
    fn snapshot_class_inventory_with_constructors() {
        let class_name = CSharpClassName::from_source("inventory");
        let primary = CSharpConstructorPlan {
            summary_doc: None,
            kind: CSharpConstructorKind::Primary {
                helper_method_name: CSharpMethodName::new("InventoryNewHandle"),
            },
            native_method_name: CSharpMethodName::native_for_owner(
                &class_name,
                &CSharpMethodName::new("New"),
            ),
            ffi_name: CFunctionName::new("boltffi_inventory_new".to_string()),
            is_fallible: false,
            params: vec![],
            wire_writers: vec![],
        };
        let with_capacity_name = CSharpMethodName::from_source("with_capacity");
        let factory = CSharpConstructorPlan {
            summary_doc: None,
            kind: CSharpConstructorKind::StaticFactory {
                name: with_capacity_name.clone(),
            },
            native_method_name: CSharpMethodName::native_for_owner(
                &class_name,
                &with_capacity_name,
            ),
            ffi_name: CFunctionName::new("boltffi_inventory_with_capacity".to_string()),
            is_fallible: false,
            params: vec![CSharpParamPlan {
                name: CSharpParamName::from_source("capacity"),
                csharp_type: CSharpType::UInt,
                kind: CSharpParamKind::Direct,
            }],
            wire_writers: vec![],
        };
        let class = CSharpClassPlan {
            summary_doc: None,
            native_free_method_name: CSharpMethodName::native_for_owner(
                &class_name,
                &CSharpMethodName::new("Free"),
            ),
            class_name,
            ffi_free: CFunctionName::new("boltffi_inventory_free".to_string()),
            constructors: vec![primary, factory],
            methods: vec![],
            streams: vec![],
        };
        let template = ClassTemplate {
            class: &class,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// Counter with two instance methods (a getter returning a
    /// primitive and a void mutator) and a static method. Pins the
    /// three rendering shapes the class template emits for methods:
    ///
    /// - Static: `public static {ReturnType} {Name}(...)` body.
    /// - ClassInstance void: `NativeMethods.{Name}(_handle, ...)`.
    /// - ClassInstance primitive return: `return NativeMethods.{Name}(_handle, ...)`.
    #[test]
    fn snapshot_class_counter_with_methods() {
        let class_name = CSharpClassName::from_source("counter");
        let get_name = CSharpMethodName::from_source("get");
        let get = CSharpMethodPlan {
            summary_doc: None,
            name: get_name.clone(),
            native_method_name: CSharpMethodName::native_for_owner(&class_name, &get_name),
            ffi_name: CFunctionName::new("boltffi_counter_get".to_string()),
            async_call: None,
            receiver: CSharpReceiver::ClassInstance,
            params: vec![],
            return_type: CSharpType::Int,
            return_kind: CSharpReturnKind::Direct,
            wire_writers: vec![],
            owner_is_blittable: false,
        };
        let increment_name = CSharpMethodName::from_source("increment");
        let increment = CSharpMethodPlan {
            summary_doc: None,
            name: increment_name.clone(),
            native_method_name: CSharpMethodName::native_for_owner(&class_name, &increment_name),
            ffi_name: CFunctionName::new("boltffi_counter_increment".to_string()),
            async_call: None,
            receiver: CSharpReceiver::ClassInstance,
            params: vec![],
            return_type: CSharpType::Void,
            return_kind: CSharpReturnKind::Void,
            wire_writers: vec![],
            owner_is_blittable: false,
        };
        let zero_name = CSharpMethodName::from_source("zero");
        let zero = CSharpMethodPlan {
            summary_doc: None,
            name: zero_name.clone(),
            native_method_name: CSharpMethodName::native_for_owner(&class_name, &zero_name),
            ffi_name: CFunctionName::new("boltffi_counter_zero".to_string()),
            async_call: None,
            receiver: CSharpReceiver::Static,
            params: vec![],
            return_type: CSharpType::Int,
            return_kind: CSharpReturnKind::Direct,
            wire_writers: vec![],
            owner_is_blittable: false,
        };
        let class = CSharpClassPlan {
            summary_doc: None,
            native_free_method_name: CSharpMethodName::native_for_owner(
                &class_name,
                &CSharpMethodName::new("Free"),
            ),
            class_name,
            ffi_free: CFunctionName::new("boltffi_counter_free".to_string()),
            constructors: vec![],
            methods: vec![get, increment, zero],
            streams: vec![],
        };
        let template = ClassTemplate {
            class: &class,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    #[test]
    fn snapshot_class_event_bus_with_streams() {
        let class_name = CSharpClassName::from_source("event_bus");
        let class = CSharpClassPlan {
            summary_doc: None,
            native_free_method_name: CSharpMethodName::native_for_owner(
                &class_name,
                &CSharpMethodName::new("Free"),
            ),
            class_name: class_name.clone(),
            ffi_free: CFunctionName::new("boltffi_event_bus_free".to_string()),
            constructors: vec![],
            methods: vec![],
            streams: vec![
                stream(&class_name, "subscribe_values", CSharpType::Int),
                stream(&class_name, "subscribe_points", record_type("point")),
            ],
        };
        let template = ClassTemplate {
            class: &class,
            namespace: &demo_namespace(),
        };

        insta::assert_snapshot!(template.render().unwrap());
    }

    #[test]
    fn snapshot_native_stream_declarations() {
        let class_name = CSharpClassName::from_source("event_bus");
        let class = CSharpClassPlan {
            summary_doc: None,
            native_free_method_name: CSharpMethodName::native_for_owner(
                &class_name,
                &CSharpMethodName::new("Free"),
            ),
            class_name: class_name.clone(),
            ffi_free: CFunctionName::new("boltffi_event_bus_free".to_string()),
            constructors: vec![],
            methods: vec![],
            streams: vec![stream(&class_name, "subscribe_values", CSharpType::Int)],
        };
        let module = module_with_classes(vec![class]);
        let template = NativeTemplate { module: &module };

        insta::assert_snapshot!(template.render().unwrap());
    }

    fn doc(text: &str) -> Option<CSharpComment> {
        CSharpComment::from_str_option(Some(text))
    }

    /// Person with docs on the record itself and on each field. Pins the
    /// `<summary>` block above the declaration and one `<param name="...">`
    /// per documented field, both indented to four spaces (record) and
    /// kept above the positional record's `(...)` parameter list. The
    /// multi-line summary preserves blank lines as bare `///` separators,
    /// and `<` / `&` in the body render as `&lt;` / `&amp;` since the
    /// helper escapes XML special characters at construction.
    #[test]
    fn snapshot_record_person_with_docs() {
        let record = CSharpRecordPlan {
            summary_doc: doc("A person record.\n\nWraps Vec<String> & friends."),
            class_name: CSharpClassName::from_source("person"),
            is_blittable: false,
            fields: vec![
                CSharpFieldPlan {
                    summary_doc: doc("The display name."),
                    ..record_field(
                        "Name",
                        CSharpType::String,
                        read_call("read_string"),
                        string_size_this("Name"),
                        wire_write_this("write_string", "Name"),
                    )
                },
                CSharpFieldPlan {
                    summary_doc: doc("Age in years."),
                    ..record_field(
                        "Age",
                        CSharpType::UInt,
                        read_call("read_u32"),
                        int_lit(4),
                        wire_write_this("write_u32", "Age"),
                    )
                },
            ],
            methods: vec![],
            is_error: false,
        };
        let template = RecordTemplate {
            record: &record,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// Status with a class-level doc and per-variant docs. Pins the
    /// `<summary>` block above `public enum Status` (indent 4) and above
    /// each variant line (indent 8), with the helper-companion still
    /// emitted alongside.
    #[test]
    fn snapshot_c_style_enum_status_with_docs() {
        let variants = vec![
            CSharpEnumVariantPlan {
                summary_doc: doc("In active use."),
                ..variant("Active", 0, 0, vec![])
            },
            CSharpEnumVariantPlan {
                summary_doc: doc("Soft-deleted."),
                ..variant("Inactive", 1, 1, vec![])
            },
            CSharpEnumVariantPlan {
                summary_doc: doc("Awaiting review."),
                ..variant("Pending", 2, 2, vec![])
            },
        ];
        let mut enumeration = build_enum(
            "status",
            CSharpEnumKind::CStyle,
            Some(CSharpEnumUnderlyingType::Int),
            variants,
            vec![],
        );
        enumeration.summary_doc = doc("Lifecycle status of an item.");
        let template = EnumCStyleTemplate {
            enumeration: &enumeration,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// Shape with docs on the data enum, on each variant, and on the
    /// payload fields. Pins the three doc paths the data-enum template
    /// touches: namespace-indented `<summary>` above
    /// `public abstract record Shape`, variant-indented `<summary>`
    /// above each `public sealed record …`, and `<param name="…">`
    /// blocks for the positional payload fields.
    #[test]
    fn snapshot_data_enum_shape_with_docs() {
        let variants = vec![
            CSharpEnumVariantPlan {
                summary_doc: doc("A round shape."),
                ..variant(
                    "Circle",
                    0,
                    0,
                    vec![CSharpFieldPlan {
                        summary_doc: doc("Distance from the center."),
                        ..record_field(
                            "Radius",
                            CSharpType::Double,
                            read_call("read_f64"),
                            int_lit(8),
                            wire_write_local_field("write_f64", "_v", "Radius"),
                        )
                    }],
                )
            },
            CSharpEnumVariantPlan {
                summary_doc: doc("A degenerate point."),
                ..variant("Point", 1, 1, vec![])
            },
        ];
        let mut enumeration = build_enum("shape", CSharpEnumKind::Data, None, variants, vec![]);
        enumeration.summary_doc = doc("A 2D shape.");
        let template = EnumDataTemplate {
            enumeration: &enumeration,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// Counter with docs on the class, on a primary constructor, and on
    /// instance + static methods. Pins the doc block above the class
    /// declaration, above the `public Counter(...)` ctor, and above each
    /// of the three method shapes (static, ClassInstance void,
    /// ClassInstance returning a primitive).
    #[test]
    fn snapshot_class_counter_with_docs() {
        let class_name = CSharpClassName::from_source("counter");
        let primary = CSharpConstructorPlan {
            summary_doc: doc("Creates a counter starting at zero."),
            kind: CSharpConstructorKind::Primary {
                helper_method_name: CSharpMethodName::new("CounterNewHandle"),
            },
            native_method_name: CSharpMethodName::native_for_owner(
                &class_name,
                &CSharpMethodName::new("New"),
            ),
            ffi_name: CFunctionName::new("boltffi_counter_new".to_string()),
            is_fallible: false,
            params: vec![],
            wire_writers: vec![],
        };
        let get_name = CSharpMethodName::from_source("get");
        let get = CSharpMethodPlan {
            summary_doc: doc("Returns the current value."),
            name: get_name.clone(),
            native_method_name: CSharpMethodName::native_for_owner(&class_name, &get_name),
            ffi_name: CFunctionName::new("boltffi_counter_get".to_string()),
            async_call: None,
            receiver: CSharpReceiver::ClassInstance,
            params: vec![],
            return_type: CSharpType::Int,
            return_kind: CSharpReturnKind::Direct,
            wire_writers: vec![],
            owner_is_blittable: false,
        };
        let increment_name = CSharpMethodName::from_source("increment");
        let increment = CSharpMethodPlan {
            summary_doc: doc("Adds one to the current value."),
            name: increment_name.clone(),
            native_method_name: CSharpMethodName::native_for_owner(&class_name, &increment_name),
            ffi_name: CFunctionName::new("boltffi_counter_increment".to_string()),
            async_call: None,
            receiver: CSharpReceiver::ClassInstance,
            params: vec![],
            return_type: CSharpType::Void,
            return_kind: CSharpReturnKind::Void,
            wire_writers: vec![],
            owner_is_blittable: false,
        };
        let zero_name = CSharpMethodName::from_source("zero");
        let zero = CSharpMethodPlan {
            summary_doc: doc("Static factory returning zero."),
            name: zero_name.clone(),
            native_method_name: CSharpMethodName::native_for_owner(&class_name, &zero_name),
            ffi_name: CFunctionName::new("boltffi_counter_zero".to_string()),
            async_call: None,
            receiver: CSharpReceiver::Static,
            params: vec![],
            return_type: CSharpType::Int,
            return_kind: CSharpReturnKind::Direct,
            wire_writers: vec![],
            owner_is_blittable: false,
        };
        let class = CSharpClassPlan {
            summary_doc: doc("Mutable counter held over FFI."),
            native_free_method_name: CSharpMethodName::native_for_owner(
                &class_name,
                &CSharpMethodName::new("Free"),
            ),
            class_name,
            ffi_free: CFunctionName::new("boltffi_counter_free".to_string()),
            constructors: vec![primary],
            methods: vec![get, increment, zero],
            streams: vec![],
        };
        let template = ClassTemplate {
            class: &class,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// `error` parameter expression used as the receiver of typed-
    /// exception decode arguments. Mirrors what the lowerer emits for
    /// `error.Decode(reader)` inside the `WireDecodeResult.err_throw_expr`
    /// for typed-exception paths.
    fn error_local() -> CSharpExpression {
        CSharpExpression::Identity(CSharpIdentity::Local(CSharpLocalName::new("error")))
    }

    /// MathError as an `is_error` C-style enum: pins the typed-exception
    /// `MathErrorException` rendered alongside the enum, with the
    /// `error.ToString()` message expression and the `Error` property
    /// exposing the wrapped variant.
    #[test]
    fn snapshot_c_style_enum_with_error_math_error() {
        let variants = vec![
            variant("DivisionByZero", 0, 0, vec![]),
            variant("NegativeInput", 1, 1, vec![]),
            variant("Overflow", 2, 2, vec![]),
        ];
        let mut enumeration = build_enum(
            "math_error",
            CSharpEnumKind::CStyle,
            Some(CSharpEnumUnderlyingType::Int),
            variants,
            vec![],
        );
        enumeration.is_error = true;
        let template = EnumCStyleTemplate {
            enumeration: &enumeration,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// ComputeError as an `is_error` data enum: the typed exception
    /// emits beneath the abstract record. Pins the `ComputeErrorException`
    /// shape and the `error.ToString()` message expression for data enums
    /// (which produce auto-generated record formatting at runtime).
    #[test]
    fn snapshot_data_enum_with_error_compute_error() {
        let variants = vec![
            variant(
                "InvalidInput",
                0,
                0,
                vec![record_field(
                    "Value",
                    CSharpType::Int,
                    read_call("read_i32"),
                    int_lit(4),
                    wire_write_local_field("write_i32", "_v", "Value"),
                )],
            ),
            variant(
                "Overflow",
                1,
                1,
                vec![
                    record_field(
                        "Value",
                        CSharpType::Int,
                        read_call("read_i32"),
                        int_lit(4),
                        wire_write_local_field("write_i32", "_v", "Value"),
                    ),
                    record_field(
                        "Limit",
                        CSharpType::Int,
                        read_call("read_i32"),
                        int_lit(4),
                        wire_write_local_field("write_i32", "_v", "Limit"),
                    ),
                ],
            ),
        ];
        let mut enumeration = build_enum(
            "compute_error",
            CSharpEnumKind::Data,
            None,
            variants,
            vec![],
        );
        enumeration.is_error = true;
        let template = EnumDataTemplate {
            enumeration: &enumeration,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// AppError as an `is_error` record with a `Message: string` field.
    /// Pins the `Exception.Message` forwarding path: the constructor
    /// passes `error.Message` to the base `Exception` ctor so consumers
    /// see a focused message rather than the auto-generated record
    /// formatting.
    #[test]
    fn snapshot_record_with_error_app_error_message_field() {
        let record = CSharpRecordPlan {
            summary_doc: None,
            class_name: CSharpClassName::from_source("app_error"),
            is_blittable: false,
            fields: vec![
                record_field(
                    "Code",
                    CSharpType::Int,
                    read_call("read_i32"),
                    int_lit(4),
                    wire_write_this("write_i32", "Code"),
                ),
                record_field(
                    "Message",
                    CSharpType::String,
                    read_call("read_string"),
                    string_size_this("Message"),
                    wire_write_this("write_string", "Message"),
                ),
            ],
            methods: vec![],
            is_error: true,
        };
        let template = RecordTemplate {
            record: &record,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// An `is_error` record without a `Message: string` field falls
    /// back to passing `error.ToString()` to the base `Exception`
    /// constructor. Pins the fallback path so the absence of `Message`
    /// produces a syntactically valid wrapper instead of a missing
    /// field reference.
    #[test]
    fn snapshot_record_with_error_no_message_field() {
        let record = CSharpRecordPlan {
            summary_doc: None,
            class_name: CSharpClassName::from_source("boundary_error"),
            is_blittable: false,
            fields: vec![
                record_field(
                    "Lo",
                    CSharpType::Int,
                    read_call("read_i32"),
                    int_lit(4),
                    wire_write_this("write_i32", "Lo"),
                ),
                record_field(
                    "Hi",
                    CSharpType::Int,
                    read_call("read_i32"),
                    int_lit(4),
                    wire_write_this("write_i32", "Hi"),
                ),
            ],
            methods: vec![],
            is_error: true,
        };
        let template = RecordTemplate {
            record: &record,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// Class with a `WireDecodeResult` method whose Err is `String` and
    /// Ok is a primitive: pins the throwing wrapper body shape
    /// (`if (reader.ReadU8() != 0) throw new BoltException(...);
    /// return reader.ReadI32();`) end-to-end through the class template.
    /// This is the shape the demo crate's `Counter::try_get_positive`
    /// emits.
    #[test]
    fn snapshot_class_counter_with_throwing_method_string_err() {
        let class_name = CSharpClassName::from_source("counter");
        let try_get_positive_name = CSharpMethodName::from_source("try_get_positive");
        let err_throw_expr = CSharpExpression::New {
            target: CSharpType::Record(CSharpTypeReference::Plain(CSharpClassName::new(
                "BoltException",
            ))),
            args: vec![read_call("read_string")].into(),
        };
        let try_get_positive = CSharpMethodPlan {
            summary_doc: None,
            name: try_get_positive_name.clone(),
            native_method_name: CSharpMethodName::native_for_owner(
                &class_name,
                &try_get_positive_name,
            ),
            ffi_name: CFunctionName::new("boltffi_counter_try_get_positive".to_string()),
            async_call: None,
            receiver: CSharpReceiver::ClassInstance,
            params: vec![],
            return_type: CSharpType::Int,
            return_kind: CSharpReturnKind::WireDecodeResult {
                ok_decode_expr: Some(read_call("read_i32")),
                err_throw_expr,
            },
            wire_writers: vec![],
            owner_is_blittable: false,
        };
        let class = CSharpClassPlan {
            summary_doc: None,
            native_free_method_name: CSharpMethodName::native_for_owner(
                &class_name,
                &CSharpMethodName::new("Free"),
            ),
            class_name,
            ffi_free: CFunctionName::new("boltffi_counter_free".to_string()),
            constructors: vec![],
            methods: vec![try_get_positive],
            streams: vec![],
        };
        let template = ClassTemplate {
            class: &class,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// Class with a `WireDecodeResult` method whose Err is a typed
    /// `#[error]` enum: the throw expression decodes the wire-encoded
    /// error and constructs the typed `<Name>Exception`. Pins the
    /// `throw new MathErrorException(MathErrorWire.Decode(reader))`
    /// shape that the demo crate's `CheckedDivide`-on-a-class flavor
    /// would emit if a class method exposed a typed error.
    #[test]
    fn snapshot_class_with_throwing_method_typed_exception_err() {
        let class_name = CSharpClassName::from_source("calculator");
        let divide_name = CSharpMethodName::from_source("divide");
        let error_decode = CSharpExpression::MethodCall {
            receiver: Box::new(CSharpExpression::TypeRef(CSharpTypeReference::Plain(
                CSharpClassName::new("MathErrorWire"),
            ))),
            method: CSharpMethodName::new("Decode"),
            type_args: vec![],
            args: vec![local_ident("reader")].into(),
        };
        let err_throw_expr = CSharpExpression::New {
            target: CSharpType::Record(CSharpTypeReference::Plain(CSharpClassName::new(
                "MathErrorException",
            ))),
            args: vec![error_decode].into(),
        };
        // Suppress unused-fn warning: error_local is part of the test
        // helper surface for typed-exception fixtures, even if this
        // particular test happens not to invoke it.
        let _ = error_local();
        let divide = CSharpMethodPlan {
            summary_doc: None,
            name: divide_name.clone(),
            native_method_name: CSharpMethodName::native_for_owner(&class_name, &divide_name),
            ffi_name: CFunctionName::new("boltffi_calculator_divide".to_string()),
            async_call: None,
            receiver: CSharpReceiver::ClassInstance,
            params: vec![param("b", CSharpType::Int)],
            return_type: CSharpType::Int,
            return_kind: CSharpReturnKind::WireDecodeResult {
                ok_decode_expr: Some(read_call("read_i32")),
                err_throw_expr,
            },
            wire_writers: vec![],
            owner_is_blittable: false,
        };
        let class = CSharpClassPlan {
            summary_doc: None,
            native_free_method_name: CSharpMethodName::native_for_owner(
                &class_name,
                &CSharpMethodName::new("Free"),
            ),
            class_name,
            ffi_free: CFunctionName::new("boltffi_calculator_free".to_string()),
            constructors: vec![],
            methods: vec![divide],
            streams: vec![],
        };
        let template = ClassTemplate {
            class: &class,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }

    /// Class method with `WireDecodeResult { ok_decode_expr: None, .. }`
    /// — the `Result<(), E>` Ok-Void shape. Pins that the wrapper body
    /// ends after the throw branch with no `return` statement, matching
    /// the `void` public signature.
    #[test]
    fn snapshot_class_with_throwing_method_void_ok() {
        let class_name = CSharpClassName::from_source("validator");
        let validate_name = CSharpMethodName::from_source("validate");
        let err_throw_expr = CSharpExpression::New {
            target: CSharpType::Record(CSharpTypeReference::Plain(CSharpClassName::new(
                "BoltException",
            ))),
            args: vec![read_call("read_string")].into(),
        };
        let validate = CSharpMethodPlan {
            summary_doc: None,
            name: validate_name.clone(),
            native_method_name: CSharpMethodName::native_for_owner(&class_name, &validate_name),
            ffi_name: CFunctionName::new("boltffi_validator_validate".to_string()),
            async_call: None,
            receiver: CSharpReceiver::ClassInstance,
            params: vec![],
            return_type: CSharpType::Void,
            return_kind: CSharpReturnKind::WireDecodeResult {
                ok_decode_expr: None,
                err_throw_expr,
            },
            wire_writers: vec![],
            owner_is_blittable: false,
        };
        let class = CSharpClassPlan {
            summary_doc: None,
            native_free_method_name: CSharpMethodName::native_for_owner(
                &class_name,
                &CSharpMethodName::new("Free"),
            ),
            class_name,
            ffi_free: CFunctionName::new("boltffi_validator_free".to_string()),
            constructors: vec![],
            methods: vec![validate],
            streams: vec![],
        };
        let template = ClassTemplate {
            class: &class,
            namespace: &demo_namespace(),
        };
        insta::assert_snapshot!(template.render().unwrap());
    }
}
