//! C# e2e test generator using xUnit.
//!
//! Generates `e2e/csharp/E2eTests.csproj` and `tests/{Category}Tests.cs`
//! files from JSON fixtures, driven entirely by `E2eConfig` and `CallConfig`.

use crate::core::backend::GeneratedFile;
use crate::core::config::ResolvedCrateConfig;
use crate::core::hash::{self, CommentStyle};
use crate::core::template_versions as tv;
use crate::e2e::config::E2eConfig;
use crate::e2e::escape::{escape_csharp, sanitize_filename, sanitize_ident};
use crate::e2e::field_access::FieldResolver;
use crate::e2e::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup, HttpFixture, ValidationErrorExpectation};
use anyhow::Result;
use heck::{ToLowerCamelCase, ToUpperCamelCase};
use std::collections::HashMap;
use std::fmt::Write as FmtWrite;
use std::hash::{Hash, Hasher};
use std::path::PathBuf;

use super::E2eCodegen;
use super::client;

/// C# e2e code generator.
pub struct CSharpCodegen;

impl E2eCodegen for CSharpCodegen {
    fn generate(
        &self,
        groups: &[FixtureGroup],
        e2e_config: &E2eConfig,
        config: &ResolvedCrateConfig,
        type_defs: &[crate::core::ir::TypeDef],
        enums: &[crate::core::ir::EnumDef],
    ) -> Result<Vec<GeneratedFile>> {
        let lang = self.language_name();
        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);

        let mut files = Vec::new();

        // Resolve call config with overrides.
        let call = &e2e_config.call;
        let overrides = call.overrides.get(lang);
        let function_name = overrides
            .and_then(|o| o.function.as_ref())
            .cloned()
            .unwrap_or_else(|| call.function.to_upper_camel_case());
        let class_name = overrides
            .and_then(|o| o.class.as_ref())
            .cloned()
            .unwrap_or_else(|| format!("{}Lib", config.name.to_upper_camel_case()));
        // The exception class is always {CrateName}Exception, generated by the C# backend.
        let exception_class = format!("{}Exception", config.name.to_upper_camel_case());
        let namespace = overrides
            .and_then(|o| o.module.as_ref())
            .cloned()
            .or_else(|| config.csharp.as_ref().and_then(|cs| cs.namespace.clone()))
            .unwrap_or_else(|| {
                if call.module.is_empty() {
                    config.name.to_upper_camel_case()
                } else {
                    call.module.to_upper_camel_case()
                }
            });
        let result_is_simple = call.result_is_simple || overrides.is_some_and(|o| o.result_is_simple);
        let result_var = &call.result_var;
        let is_async = call.r#async;

        // Resolve package config.
        let cs_pkg = e2e_config.resolve_package("csharp");
        let pkg_name = cs_pkg
            .as_ref()
            .and_then(|p| p.name.as_ref())
            .cloned()
            .unwrap_or_else(|| config.name.to_upper_camel_case());
        // Alef scaffolds C# packages as packages/csharp/<Namespace>/<Namespace>.csproj.
        let pkg_path = cs_pkg
            .as_ref()
            .and_then(|p| p.path.as_ref())
            .cloned()
            .unwrap_or_else(|| format!("../../packages/csharp/{pkg_name}/{pkg_name}.csproj"));
        let pkg_version = cs_pkg
            .as_ref()
            .and_then(|p| p.version.as_ref())
            .cloned()
            .or_else(|| config.resolved_version())
            .unwrap_or_else(|| "0.1.0".to_string());

        // Generate the .csproj using a unique name derived from the package name so
        // it does not conflict with any hand-written project files in the same directory.
        let csproj_name = format!("{pkg_name}.E2eTests.csproj");
        files.push(GeneratedFile {
            path: output_base.join(&csproj_name),
            content: render_csproj(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
            generated_header: false,
        });

        // Detect whether any fixture needs the mock-server (HTTP fixtures or
        // fixtures with a `mock_response`). When present, the generated
        // TestSetup.cs will spawn the mock-server via [ModuleInitializer]
        // before any test loads — mirroring the Ruby spec_helper / Python
        // conftest spawn pattern. Without this, every fixture-bound test
        // failed with `SampleLlmException : builder error` because reqwest
        // rejected the relative URL when MOCK_SERVER_URL was unset.
        let needs_mock_server = groups
            .iter()
            .flat_map(|g| g.fixtures.iter())
            .any(|f| f.needs_mock_server());

        // Emit a TestSetup.cs whose ModuleInitializer chdirs to test_documents
        // so fixture-relative paths like "docx/fake.docx" resolve correctly when
        // dotnet test runs from bin/{Configuration}/{Tfm}, and (when fixtures
        // need it) spawns the mock-server binary.
        files.push(GeneratedFile {
            path: output_base.join("TestSetup.cs"),
            content: render_test_setup(needs_mock_server, &e2e_config.test_documents_dir, &namespace),
            generated_header: true,
        });

        // Collect all distinct sealed-union type names declared in `assert_enum_fields`
        // across all call configs for this language.  For each such type we emit a
        // `{TypeName}Display.cs` helper that pattern-matches on variants from the IR;
        // projects that declare no `assert_enum_fields` get no extra helper files.
        let sealed_display_types: std::collections::BTreeSet<String> = std::iter::once(&e2e_config.call)
            .chain(e2e_config.calls.values())
            .filter_map(|c| c.overrides.get(lang))
            .flat_map(|o| o.assert_enum_fields.values().cloned())
            .collect();

        let tests_base = output_base.join("tests");
        for type_name in &sealed_display_types {
            if let Some(enum_def) = enums.iter().find(|e| &e.name == type_name) {
                files.push(GeneratedFile {
                    path: tests_base.join(format!("{type_name}Display.cs")),
                    content: render_sealed_display(type_name, enum_def, type_defs, &namespace),
                    generated_header: true,
                });
            }
        }

        // Generate test files per category.
        let field_resolver = FieldResolver::new(
            &e2e_config.fields,
            &e2e_config.fields_optional,
            &e2e_config.result_fields,
            &e2e_config.fields_array,
            &std::collections::HashSet::new(),
        );

        // Resolve enum_fields, assert_enum_fields and nested_types from C# override config,
        // falling back to top-level call config.
        static EMPTY_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
        static EMPTY_ASSERT_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> =
            std::sync::LazyLock::new(HashMap::new);
        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&EMPTY_ENUM_FIELDS);
        let assert_enum_fields = overrides
            .and_then(|o| {
                if o.assert_enum_fields.is_empty() {
                    None
                } else {
                    Some(&o.assert_enum_fields)
                }
            })
            .unwrap_or(&EMPTY_ASSERT_ENUM_FIELDS);

        // Build effective nested_types from C# override config (top-level doesn't have nested_types).
        let mut effective_nested_types: HashMap<String, String> = HashMap::new();
        if let Some(overrides_map) = overrides.map(|o| &o.nested_types) {
            effective_nested_types.extend(overrides_map.clone());
        }

        for group in groups {
            let active: Vec<&Fixture> = group
                .fixtures
                .iter()
                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
                .collect();

            if active.is_empty() {
                continue;
            }

            let test_class = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
            let filename = format!("{test_class}.cs");
            let content = render_test_file(
                &group.category,
                &active,
                &namespace,
                &class_name,
                &function_name,
                &exception_class,
                result_var,
                &test_class,
                &e2e_config.call.args,
                &field_resolver,
                result_is_simple,
                is_async,
                e2e_config,
                enum_fields,
                assert_enum_fields,
                &effective_nested_types,
                &config.adapters,
                config,
                type_defs,
            );
            files.push(GeneratedFile {
                path: tests_base.join(filename),
                content,
                generated_header: true,
            });
        }

        Ok(files)
    }

    fn language_name(&self) -> &'static str {
        "csharp"
    }
}

// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------

fn render_csproj(
    pkg_name: &str,
    pkg_path: &str,
    pkg_version: &str,
    dep_mode: crate::e2e::config::DependencyMode,
) -> String {
    let pkg_ref = match dep_mode {
        crate::e2e::config::DependencyMode::Registry => {
            format!("    <PackageReference Include=\"{pkg_name}\" Version=\"{pkg_version}\" />")
        }
        crate::e2e::config::DependencyMode::Local => {
            format!("    <ProjectReference Include=\"{pkg_path}\" />")
        }
    };
    crate::e2e::template_env::render(
        "csharp/csproj.jinja",
        minijinja::context! {
            pkg_ref => pkg_ref,
            microsoft_net_test_sdk_version => tv::nuget::MICROSOFT_NET_TEST_SDK,
            xunit_version => tv::nuget::XUNIT,
            xunit_runner_version => tv::nuget::XUNIT_RUNNER_VISUALSTUDIO,
        },
    )
}

fn render_test_setup(needs_mock_server: bool, test_documents_dir: &str, namespace: &str) -> String {
    let mut out = String::new();
    out.push_str(&hash::header(CommentStyle::DoubleSlash));
    out.push_str("using System;\n");
    out.push_str("using System.IO;\n");
    if needs_mock_server {
        out.push_str("using System.Diagnostics;\n");
    }
    out.push_str("using System.Runtime.CompilerServices;\n\n");
    let _ = writeln!(out, "namespace {namespace};\n");
    out.push_str("internal static class TestSetup\n");
    out.push_str("{\n");
    if needs_mock_server {
        out.push_str("    private static Process? _mockServer;\n\n");
    }
    out.push_str("    [ModuleInitializer]\n");
    out.push_str("    internal static void Init()\n");
    out.push_str("    {\n");
    let _ = writeln!(
        out,
        "        // Walk up from the assembly directory until we find the repo root."
    );
    let _ = writeln!(
        out,
        "        // Prefer a sibling {test_documents_dir}/ directory (chdir into it so that"
    );
    out.push_str("        // fixture paths like \"docx/fake.docx\" resolve relative to it). If that\n");
    out.push_str("        // is absent (web-crawler-style repos with no document fixtures), fall\n");
    out.push_str("        // back to a sibling alef.toml or fixtures/ marker as the repo root.\n");
    out.push_str("        var dir = new DirectoryInfo(AppContext.BaseDirectory);\n");
    out.push_str("        DirectoryInfo? repoRoot = null;\n");
    out.push_str("        while (dir != null)\n");
    out.push_str("        {\n");
    let _ = writeln!(
        out,
        "            var documentsCandidate = Path.Combine(dir.FullName, \"{test_documents_dir}\");"
    );
    out.push_str("            if (Directory.Exists(documentsCandidate))\n");
    out.push_str("            {\n");
    out.push_str("                repoRoot = dir;\n");
    out.push_str("                Directory.SetCurrentDirectory(documentsCandidate);\n");
    out.push_str("                break;\n");
    out.push_str("            }\n");
    out.push_str("            if (File.Exists(Path.Combine(dir.FullName, \"alef.toml\"))\n");
    out.push_str("                || Directory.Exists(Path.Combine(dir.FullName, \"fixtures\")))\n");
    out.push_str("            {\n");
    out.push_str("                repoRoot = dir;\n");
    out.push_str("                break;\n");
    out.push_str("            }\n");
    out.push_str("            dir = dir.Parent;\n");
    out.push_str("        }\n");
    if needs_mock_server {
        out.push('\n');
        out.push_str("        // Spawn the mock-server binary before any test loads, mirroring the\n");
        out.push_str("        // Ruby spec_helper / Python conftest pattern. Honors a pre-set\n");
        out.push_str("        // MOCK_SERVER_URL (e.g. set by `task` or CI) only if reachable.\n");
        out.push_str("        // If preset URL fails TCP connect within 100ms, treat it as stale\n");
        out.push_str("        // (e.g., .mock-server.env from previous run) and spawn fresh.\n");
        out.push_str("        var preset = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\");\n");
        out.push_str("        if (!string.IsNullOrEmpty(preset))\n");
        out.push_str("        {\n");
        out.push_str("            // TCP probe: short timeout (100ms) to detect stale presets.\n");
        out.push_str("            try\n");
        out.push_str("            {\n");
        out.push_str("                var presetUri = new System.Uri(preset);\n");
        out.push_str("                using var probe = new System.Net.Sockets.TcpClient();\n");
        out.push_str("                var task = probe.ConnectAsync(presetUri.Host, presetUri.Port);\n");
        out.push_str("                if (task.Wait(100) && probe.Connected)\n");
        out.push_str("                {\n");
        out.push_str("                    // Preset URL is reachable; use it and return.\n");
        out.push_str("                    return;\n");
        out.push_str("                }\n");
        out.push_str("            }\n");
        out.push_str("            catch (System.Exception) { }\n");
        out.push_str("            // Preset is unreachable or invalid; spawn fresh server.\n");
        out.push_str("        }\n");
        out.push_str("        if (repoRoot == null)\n");
        out.push_str("        {\n");
        let _ = writeln!(
            out,
            "            throw new InvalidOperationException(\"TestSetup: could not locate repo root ({test_documents_dir}/, alef.toml, or fixtures/ not found in any ancestor of \" + AppContext.BaseDirectory + \")\");"
        );
        out.push_str("        }\n");
        out.push_str("        var bin = Path.Combine(\n");
        out.push_str("            repoRoot.FullName,\n");
        out.push_str("            \"e2e\", \"rust\", \"target\", \"release\", \"mock-server\");\n");
        out.push_str("        if (OperatingSystem.IsWindows())\n");
        out.push_str("        {\n");
        out.push_str("            bin += \".exe\";\n");
        out.push_str("        }\n");
        out.push_str("        var fixturesDir = Path.Combine(repoRoot.FullName, \"fixtures\");\n");
        out.push_str("        if (!File.Exists(bin))\n");
        out.push_str("        {\n");
        out.push_str("            throw new InvalidOperationException(\n");
        out.push_str("                $\"TestSetup: mock-server binary not found at {bin} — run: cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release\");\n");
        out.push_str("        }\n");
        out.push_str("        var psi = new ProcessStartInfo\n");
        out.push_str("        {\n");
        out.push_str("            FileName = bin,\n");
        out.push_str("            Arguments = $\"\\\"{fixturesDir}\\\"\",\n");
        out.push_str("            RedirectStandardInput = true,\n");
        out.push_str("            RedirectStandardOutput = true,\n");
        out.push_str("            RedirectStandardError = true,\n");
        out.push_str("            UseShellExecute = false,\n");
        out.push_str("        };\n");
        out.push_str("        _mockServer = Process.Start(psi)\n");
        out.push_str(
            "            ?? throw new InvalidOperationException(\"TestSetup: failed to start mock-server\");\n",
        );
        out.push_str("        // The mock-server prints MOCK_SERVER_URL=<url>, then optionally\n");
        out.push_str("        // MOCK_SERVERS={...} for host-root fixtures. Read up to 16 lines.\n");
        out.push_str("        string? url = null;\n");
        out.push_str("        for (int i = 0; i < 16; i++)\n");
        out.push_str("        {\n");
        out.push_str("            var line = _mockServer.StandardOutput.ReadLine();\n");
        out.push_str("            if (line == null)\n");
        out.push_str("            {\n");
        out.push_str("                break;\n");
        out.push_str("            }\n");
        out.push_str("            const string urlPrefix = \"MOCK_SERVER_URL=\";\n");
        out.push_str("            const string serversPrefix = \"MOCK_SERVERS=\";\n");
        out.push_str("            if (line.StartsWith(urlPrefix, StringComparison.Ordinal))\n");
        out.push_str("            {\n");
        out.push_str("                url = line.Substring(urlPrefix.Length).Trim();\n");
        out.push_str("            }\n");
        out.push_str("            else if (line.StartsWith(serversPrefix, StringComparison.Ordinal))\n");
        out.push_str("            {\n");
        out.push_str("                var jsonVal = line.Substring(serversPrefix.Length).Trim();\n");
        out.push_str("                Environment.SetEnvironmentVariable(\"MOCK_SERVERS\", jsonVal);\n");
        out.push_str("                // Parse JSON map and set per-fixture env vars (MOCK_SERVER_<FIXTURE_ID>).\n");
        out.push_str("                var matches = System.Text.RegularExpressions.Regex.Matches(\n");
        out.push_str("                    jsonVal, \"\\\"([^\\\"]+)\\\":\\\"([^\\\"]+)\\\"\");\n");
        out.push_str("                foreach (System.Text.RegularExpressions.Match m in matches)\n");
        out.push_str("                {\n");
        out.push_str("                    Environment.SetEnvironmentVariable(\n");
        out.push_str("                        \"MOCK_SERVER_\" + m.Groups[1].Value.ToUpperInvariant(),\n");
        out.push_str("                        m.Groups[2].Value);\n");
        out.push_str("                }\n");
        out.push_str("                break;\n");
        out.push_str("            }\n");
        out.push_str("            else if (url != null)\n");
        out.push_str("            {\n");
        out.push_str("                break;\n");
        out.push_str("            }\n");
        out.push_str("        }\n");
        out.push_str("        if (string.IsNullOrEmpty(url))\n");
        out.push_str("        {\n");
        out.push_str("            try { _mockServer.Kill(true); } catch { }\n");
        out.push_str("            throw new InvalidOperationException(\"TestSetup: mock-server did not emit MOCK_SERVER_URL\");\n");
        out.push_str("        }\n");
        out.push_str("        Environment.SetEnvironmentVariable(\"MOCK_SERVER_URL\", url);\n");
        out.push_str("        // TCP-readiness probe: ensure axum::serve is accepting before tests start.\n");
        out.push_str("        // The mock-server binds the TcpListener synchronously then prints the URL\n");
        out.push_str("        // before tokio::spawn(axum::serve(...)) is polled, so under xUnit\n");
        out.push_str("        // class-parallel default tests can race startup. Poll-connect (max 5s,\n");
        out.push_str("        // 50ms backoff) until success.\n");
        out.push_str("        var healthUri = new System.Uri(url);\n");
        out.push_str("        var deadline = System.Diagnostics.Stopwatch.StartNew();\n");
        out.push_str("        while (deadline.ElapsedMilliseconds < 5000)\n");
        out.push_str("        {\n");
        out.push_str("            try\n");
        out.push_str("            {\n");
        out.push_str("                using var probe = new System.Net.Sockets.TcpClient();\n");
        out.push_str("                var task = probe.ConnectAsync(healthUri.Host, healthUri.Port);\n");
        out.push_str("                if (task.Wait(100) && probe.Connected) { break; }\n");
        out.push_str("            }\n");
        out.push_str("            catch (System.Exception) { }\n");
        out.push_str("            System.Threading.Thread.Sleep(50);\n");
        out.push_str("        }\n");
        out.push_str("        // Drain stdout/stderr so the child does not block on a full pipe.\n");
        out.push_str("        var server = _mockServer;\n");
        out.push_str("        var stdoutThread = new System.Threading.Thread(() =>\n");
        out.push_str("        {\n");
        out.push_str("            try { server.StandardOutput.ReadToEnd(); } catch { }\n");
        out.push_str("        }) { IsBackground = true };\n");
        out.push_str("        stdoutThread.Start();\n");
        out.push_str("        var stderrThread = new System.Threading.Thread(() =>\n");
        out.push_str("        {\n");
        out.push_str("            try { server.StandardError.ReadToEnd(); } catch { }\n");
        out.push_str("        }) { IsBackground = true };\n");
        out.push_str("        stderrThread.Start();\n");
        out.push_str("        // Tear the child down on assembly unload / process exit by closing\n");
        out.push_str("        // its stdin (the mock-server treats stdin EOF as a shutdown signal).\n");
        out.push_str("        AppDomain.CurrentDomain.ProcessExit += (_, _) =>\n");
        out.push_str("        {\n");
        out.push_str("            try { _mockServer.StandardInput.Close(); } catch { }\n");
        out.push_str("            try { if (!_mockServer.WaitForExit(2000)) { _mockServer.Kill(true); } } catch { }\n");
        out.push_str("        };\n");
    }
    out.push_str("    }\n");
    out.push_str("}\n");
    out
}

#[allow(clippy::too_many_arguments)]
fn render_test_file(
    category: &str,
    fixtures: &[&Fixture],
    namespace: &str,
    class_name: &str,
    function_name: &str,
    exception_class: &str,
    result_var: &str,
    test_class: &str,
    args: &[crate::e2e::config::ArgMapping],
    field_resolver: &FieldResolver,
    result_is_simple: bool,
    is_async: bool,
    e2e_config: &E2eConfig,
    enum_fields: &HashMap<String, String>,
    assert_enum_fields: &HashMap<String, String>,
    nested_types: &HashMap<String, String>,
    adapters: &[crate::core::config::extras::AdapterConfig],
    config: &ResolvedCrateConfig,
    type_defs: &[crate::core::ir::TypeDef],
) -> String {
    // Collect using imports
    let mut using_imports = String::new();
    using_imports.push_str("using System;\n");
    using_imports.push_str("using System.Collections.Generic;\n");
    using_imports.push_str("using System.Linq;\n");
    using_imports.push_str("using System.Net.Http;\n");
    using_imports.push_str("using System.Text;\n");
    using_imports.push_str("using System.Text.Json;\n");
    using_imports.push_str("using System.Text.Json.Serialization;\n");
    using_imports.push_str("using System.Threading.Tasks;\n");
    using_imports.push_str("using Xunit;\n");
    using_imports.push_str(&format!("using {namespace};\n"));
    using_imports.push_str(&format!("using static {namespace}.{class_name};\n"));

    // Shared options field
    let config_options_field = "    private static readonly JsonSerializerOptions ConfigOptions = new() { Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault };";

    // Visitor class declarations accumulated across all fixtures — emitted as
    // private nested classes inside the test class but outside any method body.
    // C# does not allow local class declarations inside method bodies.
    let mut visitor_class_decls: Vec<String> = Vec::new();

    // Build fixtures body
    let mut fixtures_body = String::new();
    for (i, fixture) in fixtures.iter().enumerate() {
        render_test_method(
            &mut fixtures_body,
            &mut visitor_class_decls,
            fixture,
            class_name,
            function_name,
            exception_class,
            result_var,
            args,
            field_resolver,
            result_is_simple,
            is_async,
            e2e_config,
            enum_fields,
            assert_enum_fields,
            nested_types,
            adapters,
            config,
            type_defs,
        );
        if i + 1 < fixtures.len() {
            fixtures_body.push('\n');
        }
    }

    // Build visitor classes string
    let mut visitor_classes_str = String::new();
    for (i, decl) in visitor_class_decls.iter().enumerate() {
        if i > 0 {
            visitor_classes_str.push('\n');
        }
        visitor_classes_str.push('\n');
        // Indent each line by 4 spaces to nest inside the test class
        for line in decl.lines() {
            visitor_classes_str.push_str("    ");
            visitor_classes_str.push_str(line);
            visitor_classes_str.push('\n');
        }
    }

    let ctx = minijinja::context! {
        header => hash::header(CommentStyle::DoubleSlash),
        using_imports => using_imports,
        category => category,
        namespace => namespace,
        test_class => test_class,
        config_options_field => config_options_field,
        fixtures_body => fixtures_body,
        visitor_class_decls => visitor_classes_str,
    };

    crate::e2e::template_env::render("csharp/test_file.jinja", ctx)
}

// ---------------------------------------------------------------------------
// HTTP test rendering — shared-driver integration
// ---------------------------------------------------------------------------

/// Renderer that emits xUnit `[Fact] public async Task Test_*()` methods using
/// `System.Net.Http.HttpClient` against the mock server at `MOCK_SERVER_URL`.
/// Satisfies [`client::TestClientRenderer`] so the shared
/// [`client::http_call::render_http_test`] driver drives the call sequence.
struct CSharpTestClientRenderer;

/// C# HttpMethod static properties are PascalCase (Get, Post, Put, Delete, …).
fn to_csharp_http_method(method: &str) -> String {
    let lower = method.to_ascii_lowercase();
    let mut chars = lower.chars();
    match chars.next() {
        Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
        None => String::new(),
    }
}

/// Headers that belong to `request.Content.Headers` rather than `request.Headers`.
///
/// Adding these to `request.Headers` causes .NET to throw "Misused header name".
const CSHARP_RESTRICTED_REQUEST_HEADERS: &[&str] = &[
    "content-length",
    "host",
    "connection",
    "expect",
    "transfer-encoding",
    "upgrade",
    // Content-Type is owned by request.Content.Headers and is set when
    // StringContent is constructed; adding it to request.Headers throws.
    "content-type",
    // Other entity headers also belong to request.Content.Headers.
    "content-encoding",
    "content-language",
    "content-location",
    "content-md5",
    "content-range",
    "content-disposition",
];

/// Whether `name` (any case) belongs to `response.Content.Headers` rather than
/// `response.Headers`. Picking the wrong collection causes .NET to throw
/// "Misused header name".
fn is_csharp_content_header(name: &str) -> bool {
    matches!(
        name.to_ascii_lowercase().as_str(),
        "content-type"
            | "content-length"
            | "content-encoding"
            | "content-language"
            | "content-location"
            | "content-md5"
            | "content-range"
            | "content-disposition"
            | "expires"
            | "last-modified"
            | "allow"
    )
}

impl client::TestClientRenderer for CSharpTestClientRenderer {
    fn language_name(&self) -> &'static str {
        "csharp"
    }

    /// Convert a fixture id to the PascalCase identifier used in `Test_{name}`.
    fn sanitize_test_name(&self, id: &str) -> String {
        id.to_upper_camel_case()
    }

    /// Emit `[Fact]` (or `[Fact(Skip = "…")]` for skipped tests), the method
    /// signature, the opening brace, and the description comment.
    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
        let escaped_reason = skip_reason.map(escape_csharp);
        let rendered = crate::e2e::template_env::render(
            "csharp/http_test_open.jinja",
            minijinja::context! {
                fn_name => fn_name,
                description => description,
                skip_reason => escaped_reason,
            },
        );
        out.push_str(&rendered);
    }

    /// Emit the closing `}` for a test method.
    fn render_test_close(&self, out: &mut String) {
        let rendered = crate::e2e::template_env::render("csharp/http_test_close.jinja", minijinja::context! {});
        out.push_str(&rendered);
    }

    /// Emit the `HttpRequestMessage` construction, headers, cookies, body, and
    /// `var response = await client.SendAsync(request)`.
    ///
    /// The fixture path follows the mock-server convention `/fixtures/<id>`.
    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
        let method = to_csharp_http_method(ctx.method);
        let path = escape_csharp(ctx.path);

        out.push_str("        var baseUrl = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? \"http://localhost:8080\";\n");
        // Disable auto-follow so redirect-status fixtures (3xx) can assert the
        // server's status code rather than the followed-target's status.
        out.push_str(
            "        using var handler = new System.Net.Http.HttpClientHandler { AllowAutoRedirect = false };\n",
        );
        out.push_str("        using var client = new System.Net.Http.HttpClient(handler);\n");
        out.push_str(&format!("        var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.{method}, $\"{{baseUrl}}{path}\");\n"));

        // Set body + Content-Type when a request body is present.
        if let Some(body) = ctx.body {
            let content_type = ctx.content_type.unwrap_or("application/json");
            let json_str = serde_json::to_string(body).unwrap_or_default();
            let escaped = escape_csharp(&json_str);
            out.push_str(&format!("        request.Content = new System.Net.Http.StringContent(\"{escaped}\", System.Text.Encoding.UTF8, \"{content_type}\");\n"));
        }

        // Add request headers (skip restricted headers that belong to Content.Headers).
        for (name, value) in ctx.headers {
            if CSHARP_RESTRICTED_REQUEST_HEADERS.contains(&name.to_lowercase().as_str()) {
                continue;
            }
            let escaped_name = escape_csharp(name);
            let escaped_value = escape_csharp(value);
            out.push_str(&format!(
                "        request.Headers.Add(\"{escaped_name}\", \"{escaped_value}\");\n"
            ));
        }

        // Combine cookies into a single `Cookie` header.
        if !ctx.cookies.is_empty() {
            let mut pairs: Vec<String> = ctx.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
            pairs.sort();
            let cookie_header = escape_csharp(&pairs.join("; "));
            out.push_str(&format!(
                "        request.Headers.Add(\"Cookie\", \"{cookie_header}\");\n"
            ));
        }

        out.push_str("        var response = await client.SendAsync(request);\n");
    }

    /// Emit `Assert.Equal(status, (int)response.StatusCode)`.
    fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
        out.push_str(&format!("        Assert.Equal({status}, (int)response.StatusCode);\n"));
    }

    /// Emit a response-header assertion.
    ///
    /// Handles special tokens: `<<present>>`, `<<absent>>`, `<<uuid>>`.
    /// Picks `response.Content.Headers` vs `response.Headers` based on the header name.
    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
        let target = if is_csharp_content_header(name) {
            "response.Content.Headers"
        } else {
            "response.Headers"
        };
        let escaped_name = escape_csharp(name);
        match expected {
            "<<present>>" => {
                out.push_str(&format!("        Assert.True({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be present\");\n"));
            }
            "<<absent>>" => {
                out.push_str(&format!("        Assert.False({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be absent\");\n"));
            }
            "<<uuid>>" => {
                // UUID regex: 8-4-4-4-12 hex groups.
                out.push_str(&format!("        Assert.True({target}.TryGetValues(\"{escaped_name}\", out var _uuidHdr) && System.Text.RegularExpressions.Regex.IsMatch(string.Join(\", \", _uuidHdr), @\"^[0-9a-fA-F]{{8}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{12}}$\"), \"header {escaped_name} is not a UUID\");\n"));
            }
            literal => {
                // Use a deterministic local-variable name derived from the header name so
                // multiple header assertions in the same method body do not redeclare.
                let var_name = format!("hdr{}", sanitize_ident(name));
                let escaped_value = escape_csharp(literal);
                out.push_str(&format!("        Assert.True({target}.TryGetValues(\"{escaped_name}\", out var {var_name}) && {var_name}.Any(v => v.Contains(\"{escaped_value}\")), \"header {escaped_name} mismatch\");\n"));
            }
        }
    }

    /// Emit a JSON body equality assertion via `JsonDocument`.
    ///
    /// Plain-string bodies are compared with `Assert.Equal` after trimming.
    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
        match expected {
            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
                let json_str = serde_json::to_string(expected).unwrap_or_default();
                let escaped = escape_csharp(&json_str);
                out.push_str("        var bodyText = await response.Content.ReadAsStringAsync();\n");
                out.push_str("        var body = JsonDocument.Parse(bodyText).RootElement;\n");
                out.push_str(&format!(
                    "        var expectedBody = JsonDocument.Parse(\"{escaped}\").RootElement;\n"
                ));
                out.push_str("        Assert.Equal(expectedBody.GetRawText(), body.GetRawText());\n");
            }
            serde_json::Value::String(s) => {
                let escaped = escape_csharp(s);
                out.push_str("        var bodyText = await response.Content.ReadAsStringAsync();\n");
                out.push_str(&format!("        Assert.Equal(\"{escaped}\", bodyText.Trim());\n"));
            }
            other => {
                let escaped = escape_csharp(&other.to_string());
                out.push_str("        var bodyText = await response.Content.ReadAsStringAsync();\n");
                out.push_str(&format!("        Assert.Equal(\"{escaped}\", bodyText.Trim());\n"));
            }
        }
    }

    /// Emit per-field equality assertions for a partial body match.
    ///
    /// Uses a separate `partialBodyText` local so it does not collide with
    /// `bodyText` if `render_assert_json_body` was also called.
    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
        if let Some(obj) = expected.as_object() {
            out.push_str("        var partialBodyText = await response.Content.ReadAsStringAsync();\n");
            out.push_str("        var partialBody = JsonDocument.Parse(partialBodyText).RootElement;\n");
            for (key, val) in obj {
                let escaped_key = escape_csharp(key);
                let json_str = serde_json::to_string(val).unwrap_or_default();
                let escaped_val = escape_csharp(&json_str);
                let var_name = format!("expected{}", key.to_upper_camel_case());
                out.push_str(&format!(
                    "        var {var_name} = JsonDocument.Parse(\"{escaped_val}\").RootElement;\n"
                ));
                out.push_str(&format!("        Assert.True(partialBody.TryGetProperty(\"{escaped_key}\", out var _partialProp{var_name}) && _partialProp{var_name}.GetRawText() == {var_name}.GetRawText(), \"partial body field '{escaped_key}' mismatch\");\n"));
            }
        }
    }

    /// Emit validation-error assertions by checking each expected `msg` string
    /// appears in the JSON-encoded body.
    fn render_assert_validation_errors(
        &self,
        out: &mut String,
        _response_var: &str,
        errors: &[ValidationErrorExpectation],
    ) {
        out.push_str("        var validationBodyText = await response.Content.ReadAsStringAsync();\n");
        for err in errors {
            let escaped_msg = escape_csharp(&err.msg);
            out.push_str(&format!(
                "        Assert.Contains(\"{escaped_msg}\", validationBodyText);\n"
            ));
        }
    }
}

/// Render an HTTP server test method using the shared [`client::http_call::render_http_test`]
/// driver via [`CSharpTestClientRenderer`].
fn render_http_test_method(out: &mut String, fixture: &Fixture, _http: &HttpFixture) {
    client::http_call::render_http_test(out, &CSharpTestClientRenderer, fixture);
}

#[allow(clippy::too_many_arguments)]
fn render_test_method(
    out: &mut String,
    visitor_class_decls: &mut Vec<String>,
    fixture: &Fixture,
    class_name: &str,
    _function_name: &str,
    exception_class: &str,
    _result_var: &str,
    _args: &[crate::e2e::config::ArgMapping],
    _field_resolver: &FieldResolver,
    result_is_simple: bool,
    _is_async: bool,
    e2e_config: &E2eConfig,
    enum_fields: &HashMap<String, String>,
    assert_enum_fields: &HashMap<String, String>,
    nested_types: &HashMap<String, String>,
    adapters: &[crate::core::config::extras::AdapterConfig],
    config: &ResolvedCrateConfig,
    type_defs: &[crate::core::ir::TypeDef],
) {
    let method_name = fixture.id.to_upper_camel_case();
    let description = &fixture.description;

    // HTTP fixtures: generate real HTTP client tests using System.Net.Http.
    if let Some(http) = &fixture.http {
        render_http_test_method(out, fixture, http);
        return;
    }

    // Non-HTTP fixtures with no mock_response: skip only if the C# binding
    // does not have a callable for this function via [e2e.call.overrides.csharp].
    if fixture.mock_response.is_none() && !fixture_has_csharp_callable(fixture, e2e_config) {
        let skip_reason =
            "non-HTTP fixture: C# binding does not expose a callable for the configured `[e2e.call]` function";
        let ctx = minijinja::context! {
            is_skipped => true,
            skip_reason => skip_reason,
            description => description,
            method_name => method_name,
        };
        let rendered = crate::e2e::template_env::render("csharp/test_method.jinja", ctx);
        out.push_str(&rendered);
        return;
    }

    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");

    // Resolve call config per-fixture so named calls (e.g. "parse") use the
    // correct function name, result variable, and async flag.
    // Use resolve_call_for_fixture to support auto-routing via select_when.
    let mut call_config = e2e_config.resolve_call_for_fixture(
        fixture.call.as_deref(),
        &fixture.id,
        &fixture.resolved_category(),
        &fixture.tags,
        &fixture.input,
    );
    // Fallback: if the resolved call has required args missing from input,
    // try to find a better-matching call from the named calls.
    call_config = super::select_best_matching_call(call_config, e2e_config, fixture);
    // Per-call field resolver: overrides the top-level resolver when this call
    // declares its own result_fields / fields / fields_optional / fields_array.
    // Without this, fields like `pages.length` on a `crawl` call would be skipped
    // because the default `result_fields` (configured for the top-level `scrape`
    // call) does not contain `pages`.
    let call_field_resolver = FieldResolver::new(
        e2e_config.effective_fields(call_config),
        e2e_config.effective_fields_optional(call_config),
        e2e_config.effective_result_fields(call_config),
        e2e_config.effective_fields_array(call_config),
        &std::collections::HashSet::new(),
    );
    let field_resolver = &call_field_resolver;
    let lang = "csharp";
    let cs_overrides = call_config.overrides.get(lang);

    // Streaming branch: chat_stream returns IAsyncEnumerable<ChatCompletionChunk>,
    // not Task<T>. Emit `await foreach` over the stream, building local
    // aggregator vars (`chunks`, `streamContent`, `streamComplete`, ...) and
    // asserting on those locals — never on response pseudo-fields.
    let raw_function_name = cs_overrides
        .and_then(|o| o.function.as_ref())
        .cloned()
        .unwrap_or_else(|| call_config.function.clone());
    if raw_function_name == "chat_stream" {
        render_chat_stream_test_method(
            out,
            fixture,
            class_name,
            call_config,
            cs_overrides,
            e2e_config,
            enum_fields,
            assert_enum_fields,
            nested_types,
            exception_class,
            adapters,
            config,
            type_defs,
        );
        return;
    }

    let _is_streaming = call_config.streaming.unwrap_or(false);
    let effective_function_name = {
        let mut name = cs_overrides
            .and_then(|o| o.function.as_ref())
            .cloned()
            .unwrap_or_else(|| call_config.function.to_upper_camel_case());
        if call_config.r#async && !name.ends_with("Async") {
            name.push_str("Async");
        }
        name
    };
    let effective_result_var = &call_config.result_var;
    let effective_is_async = call_config.r#async;
    let function_name = effective_function_name.as_str();
    let result_var = effective_result_var.as_str();
    let is_async = effective_is_async;
    let args = fixture.resolved_args(call_config);

    // Per-call overrides: result shape, void returns, extra trailing args.
    // Pull `result_is_simple` from the per-call config first (call-level value
    // wins, then per-language override, then the top-level call's value).
    let per_call_result_is_simple = call_config.result_is_simple || cs_overrides.is_some_and(|o| o.result_is_simple);
    // result_is_bytes: when set, the call returns a raw byte[] in C# (not a
    // struct with named fields). Mirrors the C codegen flag added in 50e1d309.
    // Treat byte-buffer returns like result_is_simple (no struct accessors) and
    // emit byte-specific assertions for `not_empty`/`is_empty`.
    let per_call_result_is_bytes = call_config.result_is_bytes || cs_overrides.is_some_and(|o| o.result_is_bytes);
    let effective_result_is_simple = result_is_simple || per_call_result_is_simple || per_call_result_is_bytes;
    let effective_result_is_bytes = per_call_result_is_bytes;

    // Determine if the method returns void. Start with explicit config setting, then fall back
    // to inferring from method name patterns for trait bridge registration/management methods.
    // Methods like `register_*`, `unregister_*`, `clear_*` are typically void-returning wrappers
    // around trait bridge operations that return meaningful values internally but expose no
    // result to the caller (e.g., SampleCrateLib.RegisterOcrBackend returns void).
    let returns_void = if call_config.returns_void {
        true
    } else {
        let fn_name = call_config.function.as_str();
        fn_name.starts_with("register_")
            || fn_name.starts_with("unregister_")
            || fn_name.starts_with("clear_")
            || fn_name == "initialize"
            || fn_name == "shutdown"
    };
    let extra_args_slice: &[String] = cs_overrides.map_or(&[], |o| o.extra_args.as_slice());
    // options_type: prefer per-call override, fall back to top-level csharp override.
    let top_level_options_type = e2e_config
        .call
        .overrides
        .get("csharp")
        .and_then(|o| o.options_type.as_deref());
    let effective_options_type = cs_overrides
        .and_then(|o| o.options_type.as_deref())
        .or(top_level_options_type);

    // options_via: how to construct the options object. Supported values:
    //   "kwargs" (default) — emit a C# object initializer (`new T { ... }`).
    //   "from_json"        — emit `JsonSerializer.Deserialize<T>(json, ConfigOptions)!`,
    //                        sidestepping per-field type ambiguity for fields like
    //                        `JsonElement?` (untagged unions) or `List<NamedRecord>`
    //                        (where the codegen would otherwise default to `List<string>`).
    let top_level_options_via = e2e_config
        .call
        .overrides
        .get("csharp")
        .and_then(|o| o.options_via.as_deref());
    let effective_options_via = cs_overrides
        .and_then(|o| o.options_via.as_deref())
        .or(top_level_options_via);

    let adapter_request_type_owned: Option<String> = adapters
        .iter()
        .find(|a| a.name == call_config.function.as_str())
        .and_then(|a| a.request_type.as_deref())
        .map(|rt| rt.rsplit("::").next().unwrap_or(rt).to_string());

    // Build effective nested_types for this call, merging top-level with per-call C# overrides
    let mut effective_call_nested_types: std::collections::HashMap<String, String> = nested_types.clone();
    if let Some(o) = cs_overrides {
        for (k, v) in &o.nested_types {
            effective_call_nested_types.insert(k.clone(), v.clone());
        }
    }

    let (mut setup_lines, mut args_str) = build_args_and_setup(
        &fixture.input,
        args,
        class_name,
        effective_options_type,
        effective_options_via,
        enum_fields,
        &effective_call_nested_types,
        fixture,
        adapter_request_type_owned.as_deref(),
        config,
        type_defs,
        visitor_class_decls,
    );

    // For streaming methods with mock_url_list (batch_crawl_stream), wrap the URL list
    // in the request type before passing to the method.
    if _is_streaming && adapter_request_type_owned.is_some() {
        // Check if any arg is a mock_url_list (needs wrapping) but doesn't have its own
        // adapter_request_type wrapping (mock_url args auto-wrap via adapter_request_type)
        let has_mock_url_list = args.iter().any(|arg| arg.arg_type == "mock_url_list");
        if has_mock_url_list {
            if let Some(req_type) = &adapter_request_type_owned {
                // Find the urls argument part in args_str and wrap it in the request type
                // The args_str typically looks like "engine, urls" where urls is List<string>
                // We need to change it to "req" where req is a BatchCrawlStreamRequest wrapping urls
                let parts: Vec<&str> = args_str.split(", ").collect();
                if parts.len() >= 2 {
                    let urls_var = parts[parts.len() - 1]; // Last arg is the URLs
                    let req_var = format!("{}Req", urls_var);
                    setup_lines.push(format!("var {req_var} = new {req_type} {{ Urls = {urls_var} }};"));
                    // Replace the urls arg with the wrapped request
                    args_str = parts[..parts.len() - 1].join(", ");
                    if !args_str.is_empty() {
                        args_str.push_str(", ");
                    }
                    args_str.push_str(&req_var);
                }
            }
        }
    }

    // Build visitor if present: instantiate in method body, declare class at file scope.
    let mut visitor_arg = String::new();
    let has_visitor = fixture.visitor.is_some();
    if let Some(visitor_spec) = &fixture.visitor {
        visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_class_decls, &fixture.id, visitor_spec);
    }

    // When a visitor is present, embed it in the options object instead of passing as a separate arg.
    // args_str should contain the function arguments with null for missing options (e.g., "html, null").
    // We need to replace that null with a ConversionOptions instance that has Visitor set.
    let final_args = if has_visitor && !visitor_arg.is_empty() {
        let opts_type = effective_options_type.unwrap_or("ConversionOptions");
        if args_str.contains("JsonSerializer.Deserialize") {
            // Deserialize form: extract the deserialized object and set Visitor on it
            setup_lines.push(format!("var options = {args_str};"));
            setup_lines.push(format!("options.Visitor = {visitor_arg};"));
            "options".to_string()
        } else if args_str.ends_with(", null") {
            // Replace trailing ", null" with options
            setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
            let trimmed = args_str[..args_str.len() - 6].to_string(); // Remove ", null" (6 chars including space)
            format!("{trimmed}, options")
        } else if args_str.contains(", null,") {
            // Options parameter is null in the middle; replace it
            setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
            args_str.replace(", null,", ", options,")
        } else if args_str.is_empty() {
            // No options were provided; create new instance with Visitor
            setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
            "options".to_string()
        } else {
            // Fall back to appending options
            setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
            format!("{args_str}, options")
        }
    } else if extra_args_slice.is_empty() {
        args_str
    } else if args_str.is_empty() {
        extra_args_slice.join(", ")
    } else {
        format!("{args_str}, {}", extra_args_slice.join(", "))
    };

    // Always use the base function name (Convert) regardless of visitor presence
    // The visitor is now handled internally via options.Visitor
    let effective_function_name = function_name.to_string();

    let return_type = if is_async { "async Task" } else { "void" };
    let await_kw = if is_async { "await " } else { "" };

    // Client factory: when set, create a client instance and call methods on it
    // rather than using static class calls.
    let client_factory = cs_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
        e2e_config
            .call
            .overrides
            .get("csharp")
            .and_then(|o| o.client_factory.as_deref())
    });
    // For streaming methods, call instance methods on the engine object, not static methods
    let call_target = if client_factory.is_some() {
        "client".to_string()
    } else if _is_streaming {
        // Streaming methods are instance methods on the engine; extract the engine
        // variable name from args. Default to "engine" if not found.
        args.iter()
            .find(|arg| arg.arg_type == "handle")
            .map(|arg| arg.name.clone())
            .unwrap_or_else(|| "engine".to_string())
    } else {
        class_name.to_string()
    };

    // Build client factory setup code. For fixtures whose env block sets
    // an `api_key_var` AND that have neither `mock_response` nor an `http`
    // override (live-smoke tests against real provider APIs), prepend an
    // early-return when the env var is unset so CI without API keys does
    // not fail with `not found: No mock route for ...`. Mirrors the
    // Elixir / Python skip pattern documented in CHANGELOG v0.15.9.
    let mut client_factory_setup = String::new();
    if let Some(factory) = client_factory {
        let factory_name = factory.to_upper_camel_case();
        let fixture_id = &fixture.id;
        let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
        let api_key_var_opt = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
        let is_live_smoke = !has_mock && api_key_var_opt.is_some();
        if let Some(api_key_var) = api_key_var_opt.filter(|_| has_mock) {
            client_factory_setup.push_str(&format!(
                "        var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
            ));
            client_factory_setup.push_str(&format!(
                "        var baseUrl = string.IsNullOrEmpty(apiKey)\n            ? (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\"\n            : null;\n"
            ));
            client_factory_setup.push_str(&format!(
                "        Console.WriteLine($\"{fixture_id}: \" + (baseUrl == null ? \"using real API ({api_key_var} is set)\" : \"using mock server ({api_key_var} not set)\"));\n"
            ));
            client_factory_setup.push_str(&format!(
                "        var client = {class_name}.{factory_name}(string.IsNullOrEmpty(apiKey) ? \"test-key\" : apiKey, baseUrl, null, null, null);\n"
            ));
        } else if let Some(api_key_var) = api_key_var_opt.filter(|_| is_live_smoke) {
            client_factory_setup.push_str(&format!(
                "        var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
            ));
            client_factory_setup.push_str("        if (string.IsNullOrEmpty(apiKey)) { return; }\n");
            client_factory_setup.push_str(&format!(
                "        var client = {class_name}.{factory_name}(apiKey, null, null, null, null);\n"
            ));
        } else if fixture.has_host_root_route() {
            let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
            client_factory_setup.push_str(&format!(
                "        var _perFixtureUrl = System.Environment.GetEnvironmentVariable(\"{env_key}\");\n"
            ));
            client_factory_setup.push_str(&format!("        var baseUrl = !string.IsNullOrEmpty(_perFixtureUrl) ? _perFixtureUrl : (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"));
            client_factory_setup.push_str(&format!(
                "        var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
            ));
        } else {
            client_factory_setup.push_str(&format!("        var baseUrl = (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"));
            client_factory_setup.push_str(&format!(
                "        var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
            ));
        }
    }

    // Build call expression. For streaming methods, skip the engine argument since
    // we're calling an instance method on the engine object.
    let call_expr = if _is_streaming {
        // Remove the first (engine/handle) argument from final_args
        let args_parts: Vec<&str> = final_args.split(", ").collect();
        let args_without_engine = if args_parts.len() > 1 {
            args_parts[1..].join(", ")
        } else {
            String::new()
        };
        format!("{}({})", effective_function_name, args_without_engine)
    } else {
        format!("{}({})", effective_function_name, final_args)
    };

    // Merge per-call C# `enum_fields` keys with the global file-level
    // `fields_enum` set so call-specific enum-typed result fields (e.g.
    // BatchObject's `status` → BatchStatus) trigger enum coercion in
    // assertions even when the global set does not list them. The
    // file-level `enum_fields` argument carries the default-call's override;
    // `cs_overrides.enum_fields` carries the per-fixture-call's override
    // (e.g. retrieve_batch.overrides.csharp.enum_fields).
    let mut effective_enum_fields: std::collections::HashSet<String> = e2e_config.fields_enum.clone();
    for k in enum_fields.keys() {
        effective_enum_fields.insert(k.clone());
    }
    if let Some(o) = cs_overrides {
        for k in o.enum_fields.keys() {
            effective_enum_fields.insert(k.clone());
        }
    }

    // Merge per-call C# `assert_enum_fields` keys with the global file-level
    // so call-specific sealed-union fields trigger display wrapper usage.
    let mut effective_assert_enum_fields: std::collections::HashMap<String, String> = assert_enum_fields.clone();
    if let Some(o) = cs_overrides {
        for (k, v) in &o.assert_enum_fields {
            effective_assert_enum_fields.insert(k.clone(), v.clone());
        }
    }

    // Build assertions body for non-error cases
    let mut assertions_body = String::new();
    if !expects_error && !returns_void {
        for assertion in &fixture.assertions {
            render_assertion(
                &mut assertions_body,
                assertion,
                result_var,
                class_name,
                exception_class,
                field_resolver,
                effective_result_is_simple,
                call_config.result_is_vec || cs_overrides.is_some_and(|o| o.result_is_vec),
                call_config.result_is_array,
                effective_result_is_bytes,
                &effective_enum_fields,
                &effective_assert_enum_fields,
            );
        }
    }

    let ctx = minijinja::context! {
        is_skipped => false,
        expects_error => expects_error,
        description => description,
        return_type => return_type,
        method_name => method_name,
        async_kw => await_kw,
        call_target => call_target,
        setup_lines => setup_lines.clone(),
        call_expr => call_expr,
        exception_class => exception_class,
        client_factory_setup => client_factory_setup,
        has_usable_assertion => !expects_error && !returns_void,
        result_var => result_var,
        assertions_body => assertions_body,
        is_streaming => _is_streaming,
    };

    let rendered = crate::e2e::template_env::render("csharp/test_method.jinja", ctx);
    // Indent each line by 4 spaces to nest inside the test class
    for line in rendered.lines() {
        out.push_str("    ");
        out.push_str(line);
        out.push('\n');
    }
}

/// Render a `chat_stream` test method. The C# binding emits
/// `IAsyncEnumerable<ChatCompletionChunk> ChatStream(req)` (not `Task<T>`), so
/// the test body uses `await foreach` to drive the stream and aggregates
/// per-chunk data into local vars (`chunks`, `streamContent`, `streamComplete`,
/// optional `lastFinishReason`/`toolCallsJson`/`toolCalls0FunctionName`/`totalTokens`).
/// Assertions then run against those locals — never against pseudo-fields on a
/// response object.
#[allow(clippy::too_many_arguments)]
fn render_chat_stream_test_method(
    out: &mut String,
    fixture: &Fixture,
    class_name: &str,
    call_config: &crate::e2e::config::CallConfig,
    cs_overrides: Option<&crate::e2e::config::CallOverride>,
    e2e_config: &E2eConfig,
    enum_fields: &HashMap<String, String>,
    _assert_enum_fields: &HashMap<String, String>,
    nested_types: &HashMap<String, String>,
    exception_class: &str,
    adapters: &[crate::core::config::extras::AdapterConfig],
    config: &ResolvedCrateConfig,
    type_defs: &[crate::core::ir::TypeDef],
) {
    let method_name = fixture.id.to_upper_camel_case();
    let description = &fixture.description;
    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");

    // Streaming methods return IAsyncEnumerable<T> and carry the conventional
    // `Async` suffix to match the binding's generated DefaultClient surface
    // (which appends Async to every async-shaped method, streaming included).
    let effective_function_name = {
        let mut name = cs_overrides
            .and_then(|o| o.function.as_ref())
            .cloned()
            .unwrap_or_else(|| call_config.function.to_upper_camel_case());
        if !name.ends_with("Async") {
            name.push_str("Async");
        }
        name
    };
    let function_name = effective_function_name.as_str();
    let args = fixture.resolved_args(call_config);

    let top_level_options_type = e2e_config
        .call
        .overrides
        .get("csharp")
        .and_then(|o| o.options_type.as_deref());
    let effective_options_type = cs_overrides
        .and_then(|o| o.options_type.as_deref())
        .or(top_level_options_type);
    let top_level_options_via = e2e_config
        .call
        .overrides
        .get("csharp")
        .and_then(|o| o.options_via.as_deref());
    let effective_options_via = cs_overrides
        .and_then(|o| o.options_via.as_deref())
        .or(top_level_options_via);

    let adapter_request_type_cs: Option<String> = adapters
        .iter()
        .find(|a| a.name == call_config.function.as_str())
        .and_then(|a| a.request_type.as_deref())
        .map(|rt| rt.rsplit("::").next().unwrap_or(rt).to_string());
    let mut _chat_stream_class_decls: Vec<String> = Vec::new();
    let (setup_lines, args_str) = build_args_and_setup(
        &fixture.input,
        args,
        class_name,
        effective_options_type,
        effective_options_via,
        enum_fields,
        nested_types,
        fixture,
        adapter_request_type_cs.as_deref(),
        config,
        type_defs,
        &mut _chat_stream_class_decls,
    );

    let client_factory = cs_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
        e2e_config
            .call
            .overrides
            .get("csharp")
            .and_then(|o| o.client_factory.as_deref())
    });
    let mut client_factory_setup = String::new();
    if let Some(factory) = client_factory {
        let factory_name = factory.to_upper_camel_case();
        let fixture_id = &fixture.id;
        let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
        let api_key_var_opt = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
        let is_live_smoke = !has_mock && api_key_var_opt.is_some();
        if let Some(api_key_var) = api_key_var_opt.filter(|_| has_mock) {
            client_factory_setup.push_str(&format!(
                "        var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
            ));
            client_factory_setup.push_str(&format!(
                "        var baseUrl = string.IsNullOrEmpty(apiKey)\n            ? (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\"\n            : null;\n"
            ));
            client_factory_setup.push_str(&format!(
                "        Console.WriteLine($\"{fixture_id}: \" + (baseUrl == null ? \"using real API ({api_key_var} is set)\" : \"using mock server ({api_key_var} not set)\"));\n"
            ));
            client_factory_setup.push_str(&format!(
                "        var client = {class_name}.{factory_name}(string.IsNullOrEmpty(apiKey) ? \"test-key\" : apiKey, baseUrl, null, null, null);\n"
            ));
        } else if let Some(api_key_var) = api_key_var_opt.filter(|_| is_live_smoke) {
            client_factory_setup.push_str(&format!(
                "        var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
            ));
            client_factory_setup.push_str("        if (string.IsNullOrEmpty(apiKey)) { return; }\n");
            client_factory_setup.push_str(&format!(
                "        var client = {class_name}.{factory_name}(apiKey, null, null, null, null);\n"
            ));
        } else if fixture.has_host_root_route() {
            let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
            client_factory_setup.push_str(&format!(
                "        var _perFixtureUrl = System.Environment.GetEnvironmentVariable(\"{env_key}\");\n"
            ));
            client_factory_setup.push_str(&format!("        var baseUrl = !string.IsNullOrEmpty(_perFixtureUrl) ? _perFixtureUrl : (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"));
            client_factory_setup.push_str(&format!(
                "        var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
            ));
        } else {
            client_factory_setup.push_str(&format!(
                "        var baseUrl = (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"
            ));
            client_factory_setup.push_str(&format!(
                "        var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
            ));
        }
    }

    let call_target = if client_factory.is_some() { "client" } else { class_name };
    let call_expr = format!("{call_target}.{function_name}({args_str})");

    // Detect which aggregators a fixture's assertions actually need.
    let mut needs_finish_reason = false;
    let mut needs_tool_calls_json = false;
    let mut needs_tool_calls_0_function_name = false;
    let mut needs_total_tokens = false;
    for a in &fixture.assertions {
        if let Some(f) = a.field.as_deref() {
            match f {
                "finish_reason" => needs_finish_reason = true,
                "tool_calls" => needs_tool_calls_json = true,
                "tool_calls[0].function.name" => needs_tool_calls_0_function_name = true,
                "usage.total_tokens" => needs_total_tokens = true,
                _ => {}
            }
        }
    }

    let mut body = String::new();
    let _ = writeln!(body, "    [Fact]");
    let _ = writeln!(body, "    public async Task Test_{method_name}()");
    let _ = writeln!(body, "    {{");
    let _ = writeln!(body, "        // {description}");
    if !client_factory_setup.is_empty() {
        body.push_str(&client_factory_setup);
    }
    for line in &setup_lines {
        let _ = writeln!(body, "        {line}");
    }

    if expects_error {
        // Wrap the foreach in a lambda so the IAsyncEnumerable is actually
        // consumed (otherwise the producer never runs and no exception is raised).
        let _ = writeln!(
            body,
            "        await Assert.ThrowsAnyAsync<{exception_class}>(async () => {{"
        );
        let _ = writeln!(body, "            await foreach (var _chunk in {call_expr}) {{ }}");
        body.push_str("        });\n");
        body.push_str("    }\n");
        for line in body.lines() {
            out.push_str("    ");
            out.push_str(line);
            out.push('\n');
        }
        return;
    }

    body.push_str("        var chunks = new List<ChatCompletionChunk>();\n");
    body.push_str("        var streamContent = new System.Text.StringBuilder();\n");
    body.push_str("        var streamComplete = false;\n");
    if needs_finish_reason {
        body.push_str("        string? lastFinishReason = null;\n");
    }
    if needs_tool_calls_json {
        body.push_str("        string? toolCallsJson = null;\n");
    }
    if needs_tool_calls_0_function_name {
        body.push_str("        string? toolCalls0FunctionName = null;\n");
    }
    if needs_total_tokens {
        body.push_str("        long? totalTokens = null;\n");
    }
    let _ = writeln!(body, "        await foreach (var chunk in {call_expr})");
    body.push_str("        {\n");
    body.push_str("            chunks.Add(chunk);\n");
    body.push_str(
        "            var choice = chunk.Choices != null && chunk.Choices.Count > 0 ? chunk.Choices[0] : null;\n",
    );
    body.push_str("            if (choice != null)\n");
    body.push_str("            {\n");
    body.push_str("                var delta = choice.Delta;\n");
    body.push_str("                if (delta != null && !string.IsNullOrEmpty(delta.Content))\n");
    body.push_str("                {\n");
    body.push_str("                    streamContent.Append(delta.Content);\n");
    body.push_str("                }\n");
    if needs_finish_reason {
        // Streaming accumulator must use the wire-form snake_case representation
        // (e.g. `tool_calls`) so equality assertions against the fixture-side
        // string match. `.ToString().ToLower()` collapses compound PascalCase
        // names like `ToolCalls` to `toolcalls` (no underscore), causing
        // assertion failures. `JsonNamingPolicy.SnakeCaseLower.ConvertName`
        // mirrors the policy used by the global `JsonStringEnumConverter`,
        // matching exactly what serde would emit on the wire.
        body.push_str("                if (choice.FinishReason != null)\n");
        body.push_str("                {\n");
        body.push_str(
            "                    lastFinishReason = JsonNamingPolicy.SnakeCaseLower.ConvertName(choice.FinishReason.ToString()!);\n",
        );
        body.push_str("                }\n");
    }
    if needs_tool_calls_json || needs_tool_calls_0_function_name {
        body.push_str("                var tcs = delta?.ToolCalls;\n");
        body.push_str("                if (tcs != null && tcs.Count > 0)\n");
        body.push_str("                {\n");
        if needs_tool_calls_json {
            body.push_str(
                "                    toolCallsJson ??= JsonSerializer.Serialize(tcs.Select(tc => new { function = new { name = tc.Function?.Name } }));\n",
            );
        }
        if needs_tool_calls_0_function_name {
            body.push_str("                    toolCalls0FunctionName ??= tcs[0].Function?.Name;\n");
        }
        body.push_str("                }\n");
    }
    body.push_str("            }\n");
    if needs_total_tokens {
        body.push_str("            if (chunk.Usage != null)\n");
        body.push_str("            {\n");
        body.push_str("                totalTokens = chunk.Usage.TotalTokens;\n");
        body.push_str("            }\n");
    }
    body.push_str("        }\n");
    body.push_str("        streamComplete = true;\n");

    // Emit assertions on local aggregator vars.
    let mut had_explicit_complete = false;
    for assertion in &fixture.assertions {
        if assertion.field.as_deref() == Some("stream_complete") {
            had_explicit_complete = true;
        }
        emit_chat_stream_assertion(&mut body, assertion);
    }
    if !had_explicit_complete {
        body.push_str("        Assert.True(streamComplete);\n");
    }

    body.push_str("    }\n");

    for line in body.lines() {
        out.push_str("    ");
        out.push_str(line);
        out.push('\n');
    }
}

/// Map a streaming fixture assertion to an `Assert` call on the local aggregator
/// variable produced by `render_chat_stream_test_method`. Pseudo-fields like
/// `chunks` / `stream_content` / `stream_complete` resolve to in-method locals.
fn emit_chat_stream_assertion(out: &mut String, assertion: &Assertion) {
    let atype = assertion.assertion_type.as_str();
    if atype == "not_error" || atype == "error" {
        return;
    }
    let field = assertion.field.as_deref().unwrap_or("");

    enum Kind {
        Chunks,
        Bool,
        Str,
        IntTokens,
        Json,
        Unsupported,
    }

    let (expr, kind) = match field {
        "chunks" => ("chunks", Kind::Chunks),
        "stream_content" => ("streamContent.ToString()", Kind::Str),
        "stream_complete" => ("streamComplete", Kind::Bool),
        "no_chunks_after_done" => ("streamComplete", Kind::Bool),
        "finish_reason" => ("lastFinishReason", Kind::Str),
        "tool_calls" => ("toolCallsJson", Kind::Json),
        "tool_calls[0].function.name" => ("toolCalls0FunctionName", Kind::Str),
        "usage.total_tokens" => ("totalTokens", Kind::IntTokens),
        _ => ("", Kind::Unsupported),
    };

    if matches!(kind, Kind::Unsupported) {
        let _ = writeln!(
            out,
            "        // skipped: streaming assertion on unsupported field '{field}'"
        );
        return;
    }

    match (atype, &kind) {
        ("count_min", Kind::Chunks) => {
            if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
                let _ = writeln!(
                    out,
                    "        Assert.True(chunks.Count >= {n}, \"expected at least {n} chunks\");"
                );
            }
        }
        ("count_equals", Kind::Chunks) => {
            if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
                let _ = writeln!(out, "        Assert.Equal({n}, chunks.Count);");
            }
        }
        ("equals", Kind::Str) => {
            if let Some(val) = &assertion.value {
                let cs_val = json_to_csharp(val);
                let _ = writeln!(out, "        Assert.Equal({cs_val}, {expr});");
            }
        }
        ("contains", Kind::Str) => {
            if let Some(val) = &assertion.value {
                let cs_val = json_to_csharp(val);
                let _ = writeln!(out, "        Assert.Contains({cs_val}, {expr} ?? string.Empty);");
            }
        }
        ("not_empty", Kind::Str) => {
            let _ = writeln!(
                out,
                "        Assert.False(string.IsNullOrEmpty({expr} ?? string.Empty));"
            );
        }
        ("not_empty", Kind::Json) => {
            let _ = writeln!(out, "        Assert.NotNull({expr});");
        }
        ("is_empty", Kind::Str) => {
            let _ = writeln!(
                out,
                "        Assert.True(string.IsNullOrEmpty({expr} ?? string.Empty));"
            );
        }
        ("is_true", Kind::Bool) => {
            let _ = writeln!(out, "        Assert.True({expr});");
        }
        ("is_false", Kind::Bool) => {
            let _ = writeln!(out, "        Assert.False({expr});");
        }
        ("greater_than_or_equal", Kind::IntTokens) => {
            if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
                let _ = writeln!(out, "        Assert.True({expr} >= {n}, \"expected >= {n}\");");
            }
        }
        ("equals", Kind::IntTokens) => {
            if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
                let _ = writeln!(out, "        Assert.Equal((long?){n}, {expr});");
            }
        }
        _ => {
            let _ = writeln!(
                out,
                "        // skipped: streaming assertion '{atype}' on field '{field}' not supported"
            );
        }
    }
}

/// Build setup lines (e.g. handle creation) and the argument list for the function call.
///
/// Returns `(setup_lines, args_string)`.
#[allow(clippy::too_many_arguments)]
fn build_args_and_setup(
    input: &serde_json::Value,
    args: &[crate::e2e::config::ArgMapping],
    class_name: &str,
    options_type: Option<&str>,
    options_via: Option<&str>,
    enum_fields: &HashMap<String, String>,
    nested_types: &HashMap<String, String>,
    fixture: &crate::e2e::fixture::Fixture,
    adapter_request_type: Option<&str>,
    config: &ResolvedCrateConfig,
    type_defs: &[crate::core::ir::TypeDef],
    class_decls: &mut Vec<String>,
) -> (Vec<String>, String) {
    let fixture_id = &fixture.id;
    if args.is_empty() {
        return (Vec::new(), String::new());
    }

    let mut setup_lines: Vec<String> = Vec::new();
    let mut parts: Vec<String> = Vec::new();

    for arg in args {
        if arg.arg_type == "bytes" {
            // bytes args must be passed as byte[] in C#.
            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
            let val = input.get(field);
            match val {
                None | Some(serde_json::Value::Null) if arg.optional => {
                    parts.push("null".to_string());
                }
                None | Some(serde_json::Value::Null) => {
                    parts.push("System.Array.Empty<byte>()".to_string());
                }
                Some(v) => {
                    // Classify the value to determine how to interpret it:
                    // - File paths (like "pdf/fake.pdf") → File.ReadAllBytes(path)
                    // - Inline text → System.Text.Encoding.UTF8.GetBytes()
                    // - Base64 → Convert.FromBase64String()
                    if let Some(s) = v.as_str() {
                        let bytes_code = classify_bytes_value_csharp(s);
                        parts.push(bytes_code);
                    } else {
                        // Literal arrays or other non-string types: use as-is
                        let cs_str = json_to_csharp(v);
                        parts.push(format!("System.Text.Encoding.UTF8.GetBytes({cs_str})"));
                    }
                }
            }
            continue;
        }

        if arg.arg_type == "mock_url" {
            if fixture.has_host_root_route() {
                let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
                setup_lines.push(format!(
                    "var _pfUrl_{name} = Environment.GetEnvironmentVariable(\"{env_key}\");",
                    name = arg.name,
                ));
                setup_lines.push(format!(
                    "var {} = !string.IsNullOrEmpty(_pfUrl_{name}) ? _pfUrl_{name} : Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
                    arg.name,
                    name = arg.name,
                ));
            } else {
                setup_lines.push(format!(
                    "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
                    arg.name,
                ));
            }
            if let Some(req_type) = adapter_request_type {
                let req_var = format!("{}Req", arg.name);
                setup_lines.push(format!("var {req_var} = new {req_type} {{ Url = {} }};", arg.name));
                parts.push(req_var);
            } else {
                parts.push(arg.name.clone());
            }
            continue;
        }

        if arg.arg_type == "mock_url_list" {
            // List<string> of URLs: each element is either a bare path (`/seed1`) — prefixed
            // with the per-fixture mock-server URL at runtime — or an absolute URL kept as-is.
            // Mirrors `mock_url` resolution: `MOCK_SERVER_<FIXTURE_ID>` first, then
            // `MOCK_SERVER_URL/fixtures/<id>`. Emitted as a typed `List<string>` so it matches
            // the C# binding signature (`Task<BatchScrapeResults> BatchScrapeAsync(handle, List<string> urls)`),
            // which does not accept `string[]`.
            let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
            // Try both the declared field and common aliases (batch_urls, urls, etc.)
            let val = if let Some(v) = input.get(field).filter(|v| !v.is_null()) {
                v.clone()
            } else {
                super::resolve_urls_field(input, &arg.field).clone()
            };
            let paths: Vec<String> = if let Some(arr) = val.as_array() {
                arr.iter()
                    .filter_map(|v| v.as_str().map(|s| format!("\"{}\"", escape_csharp(s))))
                    .collect()
            } else {
                Vec::new()
            };
            let paths_literal = paths.join(", ");
            let name = &arg.name;
            setup_lines.push(format!(
                "var _pfBase_{name} = Environment.GetEnvironmentVariable(\"{env_key}\");"
            ));
            setup_lines.push(format!(
                "var _base_{name} = !string.IsNullOrEmpty(_pfBase_{name}) ? _pfBase_{name} : Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";"
            ));
            setup_lines.push(format!(
                "var {name} = new System.Collections.Generic.List<string>(new string[] {{ {paths_literal} }}.Select(p => p.StartsWith(\"http\") ? p : _base_{name} + p));"
            ));
            parts.push(name.clone());
            continue;
        }

        if arg.arg_type == "handle" {
            // Generate a CreateEngine (or equivalent) call and pass the variable.
            let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
            if config_value.is_null()
                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
            {
                setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
            } else {
                // Sort discriminator fields ("type") to appear first in nested objects so
                // System.Text.Json [JsonPolymorphic] can find the type discriminator before
                // reading other properties (a requirement as of .NET 8).
                let sorted = sort_discriminator_first(config_value.clone());
                let json_str = serde_json::to_string(&sorted).unwrap_or_default();
                let name = &arg.name;
                setup_lines.push(format!(
                    "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
                    escape_csharp(&json_str),
                ));
                setup_lines.push(format!(
                    "var {} = {class_name}.{constructor_name}({name}Config);",
                    arg.name,
                    name = name,
                ));
            }
            parts.push(arg.name.clone());
            continue;
        }

        if arg.arg_type == "test_backend" {
            if let Some(trait_name) = &arg.trait_name {
                if let Some(trait_bridge) = config.trait_bridges.iter().find(|tb| tb.trait_name == *trait_name) {
                    // Collect methods from both the main trait and its super-trait (if present).
                    // The super-trait methods are needed so stubs implement the full interface.
                    let mut methods: Vec<&crate::core::ir::MethodDef> = type_defs
                        .iter()
                        .find(|t| t.name == *trait_name)
                        .map(|t| t.methods.iter().collect())
                        .unwrap_or_default();

                    // If there's a super-trait, also collect its methods.
                    if let Some(super_trait) = &trait_bridge.super_trait {
                        // Extract the simple name from the full path (e.g., "Plugin" from "sample_core::plugins::Plugin").
                        let super_trait_simple = super_trait.rsplit("::").next().unwrap_or(super_trait.as_str());
                        if let Some(super_type) = type_defs.iter().find(|t| t.name == super_trait_simple) {
                            for method in &super_type.methods {
                                // Only add if not already present (avoid duplicates).
                                if !methods.iter().any(|m| m.name == method.name) {
                                    methods.push(method);
                                }
                            }
                        }
                    }

                    let emission = crate::e2e::codegen::emit_test_backend("csharp", trait_bridge, &methods, fixture);
                    // setup_block is a private nested class declaration — must be at class
                    // scope in C#, not inside the method body.
                    class_decls.push(emission.setup_block);
                    parts.push(emission.arg_expr);
                    continue;
                }
            }
            let emission = crate::e2e::codegen::TestBackendEmission::unimplemented("csharp");
            setup_lines.push(format!("// {}", emission.arg_expr));
            parts.push("null".to_string());
            continue;
        }

        // When field is exactly "input", treat the entire input object as the value.
        // This matches the convention used by other language generators (e.g. Go).
        let val: Option<&serde_json::Value> = if arg.field == "input" {
            Some(input)
        } else {
            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
            input.get(field)
        };
        match val {
            None | Some(serde_json::Value::Null) if arg.optional => {
                // Optional arg with no fixture value: pass null explicitly since
                // C# nullable parameters still require an argument at the call site.
                parts.push("null".to_string());
                continue;
            }
            None | Some(serde_json::Value::Null) => {
                // Required arg with no fixture value: pass a language-appropriate default.
                // For json_object args with a known options_type, use `new OptionsType()`
                // so the generated code compiles when the method parameter is non-nullable.
                let default_val = match arg.arg_type.as_str() {
                    "string" => "\"\"".to_string(),
                    "int" | "integer" => "0".to_string(),
                    "float" | "number" => "0.0d".to_string(),
                    "bool" | "boolean" => "false".to_string(),
                    "json_object" => {
                        if let Some(opts_type) = options_type {
                            format!("new {opts_type}()")
                        } else {
                            "null".to_string()
                        }
                    }
                    _ => "null".to_string(),
                };
                parts.push(default_val);
            }
            Some(v) => {
                if arg.arg_type == "json_object" {
                    // `options_via = "from_json"`: deserialize the entire value (object,
                    // array, or scalar) as the options type. This sidesteps per-field
                    // type ambiguity — e.g. `JsonElement?` (untagged unions) or
                    // `List<NamedRecord>` whose element type cannot be inferred from
                    // JSON shape alone — by delegating to System.Text.Json.
                    if options_via == Some("from_json")
                        && let Some(opts_type) = options_type
                    {
                        let sorted = sort_discriminator_first(v.clone());
                        let json_str = serde_json::to_string(&sorted).unwrap_or_default();
                        let escaped = escape_csharp(&json_str);
                        // Use the binding-emitted `<Type>.FromJson(...)` factory so any
                        // System.Text.Json deserialization failure is wrapped in
                        // `<Crate>Exception`, allowing error fixtures asserting
                        // `Assert.ThrowsAny<<Crate>Exception>(...)` to catch the parse
                        // failure (e.g. `Unknown FilePurpose value: invalid-purpose`).
                        parts.push(format!("{opts_type}.FromJson(\"{escaped}\")",));
                        continue;
                    }
                    // Array value: generate a typed List<T> based on element_type.
                    if let Some(arr) = v.as_array() {
                        parts.push(json_array_to_csharp_list(arr, arg.element_type.as_deref()));
                        continue;
                    }
                    // Object value with known type: generate idiomatic C# object initializer.
                    if let Some(opts_type) = options_type {
                        if let Some(obj) = v.as_object() {
                            parts.push(csharp_object_initializer(obj, opts_type, enum_fields, nested_types));
                            continue;
                        }
                    }
                }
                parts.push(json_to_csharp(v));
            }
        }
    }

    (setup_lines, parts.join(", "))
}

/// Convert a JSON array to a typed C# `List<T>` expression.
///
/// Mapping from `ArgMapping::element_type`:
/// - `None` or any string type → `List<string>`
/// - `"f32"` → `List<float>` with `(float)` casts
/// - `"(String, String)"` → `List<List<string>>` for key-value pair arrays
/// - `"BatchBytesItem"` / `"BatchFileItem"` → array of batch item instances
fn json_array_to_csharp_list(arr: &[serde_json::Value], element_type: Option<&str>) -> String {
    match element_type {
        Some("BatchBytesItem") => {
            let items: Vec<String> = arr
                .iter()
                .filter_map(|v| v.as_object())
                .map(|obj| {
                    let content = obj.get("content").and_then(|v| v.as_array());
                    let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
                    let content_code = if let Some(arr) = content {
                        let bytes: Vec<String> = arr
                            .iter()
                            .filter_map(|v| v.as_u64().map(|n| format!("(byte){}", n)))
                            .collect();
                        format!("new byte[] {{ {} }}", bytes.join(", "))
                    } else {
                        "new byte[] { }".to_string()
                    };
                    format!(
                        "new BatchBytesItem {{ Content = {}, MimeType = \"{}\" }}",
                        content_code, mime_type
                    )
                })
                .collect();
            format!("new List<BatchBytesItem>() {{ {} }}", items.join(", "))
        }
        Some("BatchFileItem") => {
            let items: Vec<String> = arr
                .iter()
                .filter_map(|v| v.as_object())
                .map(|obj| {
                    let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
                    format!("new BatchFileItem {{ Path = \"{}\" }}", path)
                })
                .collect();
            format!("new List<BatchFileItem>() {{ {} }}", items.join(", "))
        }
        Some("f32") => {
            let items: Vec<String> = arr.iter().map(|v| format!("(float){}", json_to_csharp(v))).collect();
            format!("new List<float>() {{ {} }}", items.join(", "))
        }
        Some("(String, String)") => {
            let items: Vec<String> = arr
                .iter()
                .map(|v| {
                    let strs: Vec<String> = v
                        .as_array()
                        .map_or_else(Vec::new, |a| a.iter().map(json_to_csharp).collect());
                    format!("new List<string>() {{ {} }}", strs.join(", "))
                })
                .collect();
            format!("new List<List<string>>() {{ {} }}", items.join(", "))
        }
        Some(et)
            if et != "f32"
                && et != "(String, String)"
                && et != "string"
                && et != "BatchBytesItem"
                && et != "BatchFileItem" =>
        {
            // Class/record types: deserialize each element from JSON
            let items: Vec<String> = arr
                .iter()
                .map(|v| {
                    let json_str = serde_json::to_string(v).unwrap_or_default();
                    let escaped = escape_csharp(&json_str);
                    format!("JsonSerializer.Deserialize<{et}>(\"{escaped}\", ConfigOptions)!")
                })
                .collect();
            format!("new List<{et}>() {{ {} }}", items.join(", "))
        }
        _ => {
            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
            format!("new List<string>() {{ {} }}", items.join(", "))
        }
    }
}

/// Detect if a field path accesses a discriminated union variant in C#.
/// Pattern: `metadata.format.<variant_name>.<field_name>`
/// Returns: Some((accessor, variant_name, inner_field)) if matched, otherwise None
fn parse_discriminated_union_access(field: &str) -> Option<(String, String, String)> {
    let parts: Vec<&str> = field.split('.').collect();
    if parts.len() >= 3 && parts.len() <= 4 {
        // Check if this is metadata.format.{variant}.{field} pattern
        if parts[0] == "metadata" && parts[1] == "format" {
            let variant_name = parts[2];
            // Known C# discriminated union variants (lowercase in fixture paths)
            let known_variants = [
                "pdf",
                "docx",
                "excel",
                "email",
                "pptx",
                "archive",
                "image",
                "xml",
                "text",
                "html",
                "ocr",
                "csv",
                "bibtex",
                "citation",
                "fiction_book",
                "dbf",
                "jats",
                "epub",
                "pst",
                "code",
            ];
            if known_variants.contains(&variant_name) {
                let variant_pascal = variant_name.to_upper_camel_case();
                if parts.len() == 4 {
                    let inner_field = parts[3];
                    return Some((
                        format!("result.Metadata.Format! as FormatMetadata.{}", variant_pascal),
                        variant_pascal,
                        inner_field.to_string(),
                    ));
                } else if parts.len() == 3 {
                    // Just accessing the variant itself (no inner field)
                    return Some((
                        format!("result.Metadata.Format! as FormatMetadata.{}", variant_pascal),
                        variant_pascal,
                        String::new(),
                    ));
                }
            }
        }
    }
    None
}

/// Render an assertion against a discriminated union variant's inner field.
/// `variant_var` is the unwrapped union variant (e.g., `variant` from pattern match).
/// `inner_field` is the field to access on the variant's Value (e.g., `sheet_count`).
fn render_discriminated_union_assertion(
    out: &mut String,
    assertion: &Assertion,
    variant_var: &str,
    inner_field: &str,
    _result_is_vec: bool,
    assert_enum_fields: &std::collections::HashMap<String, String>,
) {
    if inner_field.is_empty() {
        return; // No field to assert on
    }

    let field_pascal = inner_field.to_upper_camel_case();
    let mut field_expr = format!("{variant_var}.Value.{field_pascal}");

    // Wrap enum fields with display helper
    if assert_enum_fields.contains_key(&field_pascal) {
        let type_name = assert_enum_fields.get(&field_pascal).unwrap();
        field_expr = format!("{type_name}Display.ToDisplayString({field_expr})");
    }

    match assertion.assertion_type.as_str() {
        "equals" => {
            if let Some(expected) = &assertion.value {
                let cs_val = json_to_csharp(expected);
                if expected.is_string() {
                    let _ = writeln!(out, "            Assert.Equal({cs_val}, {field_expr}!.Trim());");
                } else if expected.as_bool() == Some(true) {
                    let _ = writeln!(out, "            Assert.True({field_expr});");
                } else if expected.as_bool() == Some(false) {
                    let _ = writeln!(out, "            Assert.False({field_expr});");
                } else if expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0) {
                    let _ = writeln!(out, "            Assert.True({field_expr} == {cs_val});");
                } else {
                    let _ = writeln!(out, "            Assert.Equal({cs_val}, {field_expr});");
                }
            }
        }
        "greater_than_or_equal" => {
            if let Some(val) = &assertion.value {
                let cs_val = json_to_csharp(val);
                let _ = writeln!(
                    out,
                    "            Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
                );
            }
        }
        "contains_all" => {
            if let Some(values) = &assertion.values {
                let field_as_str = format!("JsonSerializer.Serialize({field_expr})");
                for val in values {
                    let lower_val = val.as_str().map(|s| s.to_lowercase());
                    let cs_val = lower_val
                        .as_deref()
                        .map(|s| format!("\"{}\"", escape_csharp(s)))
                        .unwrap_or_else(|| json_to_csharp(val));
                    let _ = writeln!(out, "            Assert.Contains({cs_val}, {field_as_str}.ToLower());");
                }
            }
        }
        "contains" => {
            if let Some(expected) = &assertion.value {
                let field_as_str = format!("JsonSerializer.Serialize({field_expr})");
                let lower_expected = expected.as_str().map(|s| s.to_lowercase());
                let cs_val = lower_expected
                    .as_deref()
                    .map(|s| format!("\"{}\"", escape_csharp(s)))
                    .unwrap_or_else(|| json_to_csharp(expected));
                let _ = writeln!(out, "            Assert.Contains({cs_val}, {field_as_str}.ToLower());");
            }
        }
        "not_empty" => {
            let _ = writeln!(out, "            Assert.NotEmpty({field_expr});");
        }
        "is_empty" => {
            let _ = writeln!(out, "            Assert.Empty({field_expr});");
        }
        _ => {
            let _ = writeln!(
                out,
                "            // skipped: assertion type '{}' not yet supported for discriminated union fields",
                assertion.assertion_type
            );
        }
    }
}

#[allow(clippy::too_many_arguments)]
fn render_assertion(
    out: &mut String,
    assertion: &Assertion,
    result_var: &str,
    class_name: &str,
    exception_class: &str,
    field_resolver: &FieldResolver,
    result_is_simple: bool,
    result_is_vec: bool,
    result_is_array: bool,
    result_is_bytes: bool,
    fields_enum: &std::collections::HashSet<String>,
    assert_enum_fields: &std::collections::HashMap<String, String>,
) {
    // Byte-buffer returns: emit length-based assertions instead of struct-field
    // accessors. The result is a `byte[]` and has no named fields like
    // `result.Audio` or `result.Content`.
    if result_is_bytes {
        match assertion.assertion_type.as_str() {
            "not_empty" => {
                let _ = writeln!(out, "        Assert.NotEmpty({result_var});");
                return;
            }
            "is_empty" => {
                let _ = writeln!(out, "        Assert.Empty({result_var});");
                return;
            }
            "count_equals" | "length_equals" => {
                if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
                    let _ = writeln!(out, "        Assert.Equal({n}, {result_var}.Length);");
                }
                return;
            }
            "count_min" | "length_min" => {
                if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
                    let _ = writeln!(out, "        Assert.True({result_var}.Length >= {n});");
                }
                return;
            }
            "not_error" => {
                let _ = writeln!(out, "        Assert.NotNull({result_var});");
                return;
            }
            _ => {
                // Other assertion types are not meaningful on raw byte buffers;
                // emit a comment so the test still compiles but flags unsupported
                // assertion types for fixture authors.
                let _ = writeln!(
                    out,
                    "        // skipped: assertion type '{}' not supported on byte[] result",
                    assertion.assertion_type
                );
                return;
            }
        }
    }
    // Handle synthetic / derived fields before the is_valid_for_result check
    // so they are never treated as struct property accesses on the result.
    if let Some(f) = &assertion.field {
        match f.as_str() {
            "chunks_have_content" => {
                let synthetic_pred =
                    format!("({result_var}.Chunks ?? new()).All(c => !string.IsNullOrEmpty(c.Content))");
                let synthetic_pred_type = match assertion.assertion_type.as_str() {
                    "is_true" => "is_true",
                    "is_false" => "is_false",
                    _ => {
                        out.push_str(&format!(
                            "        // skipped: unsupported assertion type on synthetic field '{f}'\n"
                        ));
                        return;
                    }
                };
                let rendered = crate::e2e::template_env::render(
                    "csharp/assertion.jinja",
                    minijinja::context! {
                        assertion_type => "synthetic_assertion",
                        synthetic_pred => synthetic_pred,
                        synthetic_pred_type => synthetic_pred_type,
                    },
                );
                out.push_str(&rendered);
                return;
            }
            "chunks_have_embeddings" => {
                let synthetic_pred =
                    format!("({result_var}.Chunks ?? new()).All(c => c.Embedding != null && c.Embedding.Count > 0)");
                let synthetic_pred_type = match assertion.assertion_type.as_str() {
                    "is_true" => "is_true",
                    "is_false" => "is_false",
                    _ => {
                        out.push_str(&format!(
                            "        // skipped: unsupported assertion type on synthetic field '{f}'\n"
                        ));
                        return;
                    }
                };
                let rendered = crate::e2e::template_env::render(
                    "csharp/assertion.jinja",
                    minijinja::context! {
                        assertion_type => "synthetic_assertion",
                        synthetic_pred => synthetic_pred,
                        synthetic_pred_type => synthetic_pred_type,
                    },
                );
                out.push_str(&rendered);
                return;
            }
            "chunks_have_heading_context" => {
                let synthetic_pred =
                    format!("({result_var}.Chunks ?? new()).All(c => c.Metadata?.HeadingContext != null)");
                let synthetic_pred_type = match assertion.assertion_type.as_str() {
                    "is_true" => "is_true",
                    "is_false" => "is_false",
                    _ => {
                        out.push_str(&format!(
                            "        // skipped: unsupported assertion type on synthetic field '{f}'\n"
                        ));
                        return;
                    }
                };
                let rendered = crate::e2e::template_env::render(
                    "csharp/assertion.jinja",
                    minijinja::context! {
                        assertion_type => "synthetic_assertion",
                        synthetic_pred => synthetic_pred,
                        synthetic_pred_type => synthetic_pred_type,
                    },
                );
                out.push_str(&rendered);
                return;
            }
            "first_chunk_starts_with_heading" => {
                let synthetic_pred =
                    format!("({result_var}.Chunks ?? new()).FirstOrDefault()?.Metadata?.HeadingContext != null");
                let synthetic_pred_type = match assertion.assertion_type.as_str() {
                    "is_true" => "is_true",
                    "is_false" => "is_false",
                    _ => {
                        out.push_str(&format!(
                            "        // skipped: unsupported assertion type on synthetic field '{f}'\n"
                        ));
                        return;
                    }
                };
                let rendered = crate::e2e::template_env::render(
                    "csharp/assertion.jinja",
                    minijinja::context! {
                        assertion_type => "synthetic_assertion",
                        synthetic_pred => synthetic_pred,
                        synthetic_pred_type => synthetic_pred_type,
                    },
                );
                out.push_str(&rendered);
                return;
            }
            // ---- EmbedResponse virtual fields ----
            // embed_texts returns List<List<float>> in C# — no wrapper object.
            // result_var is the embedding matrix; use it directly.
            "embeddings" => {
                match assertion.assertion_type.as_str() {
                    "count_equals" => {
                        if let Some(val) = &assertion.value {
                            if let Some(n) = val.as_u64() {
                                let rendered = crate::e2e::template_env::render(
                                    "csharp/assertion.jinja",
                                    minijinja::context! {
                                        assertion_type => "synthetic_embeddings_count_equals",
                                        synthetic_pred => format!("{result_var}.Count"),
                                        n => n,
                                    },
                                );
                                out.push_str(&rendered);
                            }
                        }
                    }
                    "count_min" => {
                        if let Some(val) = &assertion.value {
                            if let Some(n) = val.as_u64() {
                                let rendered = crate::e2e::template_env::render(
                                    "csharp/assertion.jinja",
                                    minijinja::context! {
                                        assertion_type => "synthetic_embeddings_count_min",
                                        synthetic_pred => format!("{result_var}.Count"),
                                        n => n,
                                    },
                                );
                                out.push_str(&rendered);
                            }
                        }
                    }
                    "not_empty" => {
                        let rendered = crate::e2e::template_env::render(
                            "csharp/assertion.jinja",
                            minijinja::context! {
                                assertion_type => "synthetic_embeddings_not_empty",
                                synthetic_pred => result_var.to_string(),
                            },
                        );
                        out.push_str(&rendered);
                    }
                    "is_empty" => {
                        let rendered = crate::e2e::template_env::render(
                            "csharp/assertion.jinja",
                            minijinja::context! {
                                assertion_type => "synthetic_embeddings_is_empty",
                                synthetic_pred => result_var.to_string(),
                            },
                        );
                        out.push_str(&rendered);
                    }
                    _ => {
                        out.push_str(
                            "        // skipped: unsupported assertion type on synthetic field 'embeddings'\n",
                        );
                    }
                }
                return;
            }
            "embedding_dimensions" => {
                let expr = format!("({result_var}.Count > 0 ? {result_var}[0].Count : 0)");
                match assertion.assertion_type.as_str() {
                    "equals" => {
                        if let Some(val) = &assertion.value {
                            if let Some(n) = val.as_u64() {
                                let rendered = crate::e2e::template_env::render(
                                    "csharp/assertion.jinja",
                                    minijinja::context! {
                                        assertion_type => "synthetic_embedding_dimensions_equals",
                                        synthetic_pred => expr,
                                        n => n,
                                    },
                                );
                                out.push_str(&rendered);
                            }
                        }
                    }
                    "greater_than" => {
                        if let Some(val) = &assertion.value {
                            if let Some(n) = val.as_u64() {
                                let rendered = crate::e2e::template_env::render(
                                    "csharp/assertion.jinja",
                                    minijinja::context! {
                                        assertion_type => "synthetic_embedding_dimensions_greater_than",
                                        synthetic_pred => expr,
                                        n => n,
                                    },
                                );
                                out.push_str(&rendered);
                            }
                        }
                    }
                    _ => {
                        out.push_str("        // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'\n");
                    }
                }
                return;
            }
            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
                let synthetic_pred = match f.as_str() {
                    "embeddings_valid" => {
                        format!("{result_var}.All(e => e.Count > 0)")
                    }
                    "embeddings_finite" => {
                        format!("{result_var}.All(e => e.All(v => !float.IsInfinity(v) && !float.IsNaN(v)))")
                    }
                    "embeddings_non_zero" => {
                        format!("{result_var}.All(e => e.Any(v => v != 0.0f))")
                    }
                    "embeddings_normalized" => {
                        format!(
                            "{result_var}.All(e => {{ var n = e.Sum(v => (double)v * v); return Math.Abs(n - 1.0) < 1e-3; }})"
                        )
                    }
                    _ => unreachable!(),
                };
                let synthetic_pred_type = match assertion.assertion_type.as_str() {
                    "is_true" => "is_true",
                    "is_false" => "is_false",
                    _ => {
                        out.push_str(&format!(
                            "        // skipped: unsupported assertion type on synthetic field '{f}'\n"
                        ));
                        return;
                    }
                };
                let rendered = crate::e2e::template_env::render(
                    "csharp/assertion.jinja",
                    minijinja::context! {
                        assertion_type => "synthetic_assertion",
                        synthetic_pred => synthetic_pred,
                        synthetic_pred_type => synthetic_pred_type,
                    },
                );
                out.push_str(&rendered);
                return;
            }
            // ---- keywords / keywords_count ----
            // C# ExtractionResult does not expose extracted_keywords; skip.
            "keywords" | "keywords_count" => {
                let skipped_reason = format!("field '{f}' not available on C# ExtractionResult");
                let rendered = crate::e2e::template_env::render(
                    "csharp/assertion.jinja",
                    minijinja::context! {
                        skipped_reason => skipped_reason,
                    },
                );
                out.push_str(&rendered);
                return;
            }
            _ => {}
        }
    }

    // Skip assertions on fields that don't exist on the result type.
    if let Some(f) = &assertion.field {
        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
            let skipped_reason = format!("field '{f}' not available on result type");
            let rendered = crate::e2e::template_env::render(
                "csharp/assertion.jinja",
                minijinja::context! {
                    skipped_reason => skipped_reason,
                },
            );
            out.push_str(&rendered);
            return;
        }
    }

    // For count assertions on list results with no field specified, use the list directly.
    // Otherwise, when the result is a List<T>, index into the first element for field access.
    let is_count_assertion = matches!(
        assertion.assertion_type.as_str(),
        "count_equals" | "count_min" | "count_max"
    );
    let is_no_field = assertion.field.is_none() || assertion.field.as_ref().is_some_and(|f| f.is_empty());
    let use_list_directly = result_is_vec && is_count_assertion && is_no_field;

    let effective_result_var: String = if result_is_vec && !use_list_directly {
        format!("{result_var}[0]")
    } else {
        result_var.to_string()
    };

    // Check if this is a discriminated union access (e.g., metadata.format.excel.sheet_count)
    let is_discriminated_union = assertion
        .field
        .as_ref()
        .is_some_and(|f| parse_discriminated_union_access(f).is_some());

    // For discriminated union assertions, generate pattern-matching wrapper
    if is_discriminated_union {
        if let Some((_, variant_name, inner_field)) = assertion
            .field
            .as_ref()
            .and_then(|f| parse_discriminated_union_access(f))
        {
            // Use a unique variable name based on the field hash to avoid shadowing
            let mut hasher = std::collections::hash_map::DefaultHasher::new();
            inner_field.hash(&mut hasher);
            let var_hash = format!("{:x}", hasher.finish());
            let variant_var = format!("variant_{}", &var_hash[..8]);
            let _ = writeln!(
                out,
                "        if ({effective_result_var}.Metadata.Format is FormatMetadata.{} {})",
                variant_name, &variant_var
            );
            let _ = writeln!(out, "        {{");
            render_discriminated_union_assertion(
                out,
                assertion,
                &variant_var,
                &inner_field,
                result_is_vec,
                assert_enum_fields,
            );
            let _ = writeln!(out, "        }}");
            let _ = writeln!(out, "        else");
            let _ = writeln!(out, "        {{");
            let _ = writeln!(
                out,
                "            Assert.Fail(\"Expected {} format metadata\");",
                variant_name.to_lowercase()
            );
            let _ = writeln!(out, "        }}");
            return;
        }
    }

    let field_expr = if result_is_simple {
        effective_result_var.clone()
    } else {
        match &assertion.field {
            Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", &effective_result_var),
            _ => effective_result_var.clone(),
        }
    };

    // Fields declared in `assert_enum_fields` map to sealed/internally-tagged enum
    // types. Wrap the accessor with a display helper (e.g., `FormatMetadataDisplay.ToDisplayString`)
    // so the assertion sees a display string rather than the raw sealed-union object.
    let field_expr = match &assertion.field {
        Some(f) if assert_enum_fields.contains_key(f.as_str()) => {
            let type_name = assert_enum_fields.get(f.as_str()).unwrap();
            format!("{type_name}Display.ToDisplayString({field_expr})")
        }
        _ => field_expr,
    };

    // Determine if field_expr is a list or complex object that requires JSON serialization
    // for string-based assertions (contains, not_contains, etc.). List<T>.ToString() in C#
    // returns the type name, not the contents.
    let field_needs_json_serialize = if result_is_simple {
        // Simple results are scalars, but when they're also arrays (e.g., List<string>),
        // JSON-serialize so substring checks see actual content, not the type name.
        result_is_array
    } else {
        match &assertion.field {
            Some(f) if !f.is_empty() => field_resolver.is_array(f),
            // No field specified — the whole result object; needs serialization when complex.
            _ => !result_is_simple,
        }
    };
    // Build the string representation of field_expr for substring-based assertions.
    let field_as_str = if field_needs_json_serialize {
        format!("JsonSerializer.Serialize({field_expr})")
    } else {
        format!("{field_expr}.ToString()")
    };

    // Detect enum-typed fields. C# emits typed enums (e.g. `FinishReason?`) for
    // these so the codegen must avoid `.Trim()` (string-only) and instead
    // compare via `?.ToString()?.ToLower()` to match snake_case JSON.
    let field_is_enum = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
        let resolved = field_resolver.resolve(f);
        fields_enum.contains(f) || fields_enum.contains(resolved)
    });

    match assertion.assertion_type.as_str() {
        "equals" => {
            if let Some(expected) = &assertion.value {
                // Enum field equality bypasses the template (which would emit `.Trim()`,
                // a string-only API). Compare the snake-cased ToString() against the
                // expected value to match the wire JSON form (`InProgress` → `in_progress`,
                // `ContentFilter` → `content_filter`, etc.). `JsonNamingPolicy.SnakeCaseLower`
                // is the same policy used by the global JsonStringEnumConverter, so the
                // assertion compares against exactly what serde would emit.
                if field_is_enum && expected.is_string() {
                    let s_lower = expected.as_str().map(|s| s.to_lowercase()).unwrap_or_default();
                    let _ = writeln!(
                        out,
                        "        Assert.Equal(\"{}\", {field_expr} == null ? null : JsonNamingPolicy.SnakeCaseLower.ConvertName({field_expr}.ToString()!));",
                        escape_csharp(&s_lower)
                    );
                    return;
                }
                let cs_val = json_to_csharp(expected);
                let is_string_val = expected.is_string();
                let is_bool_true = expected.as_bool() == Some(true);
                let is_bool_false = expected.as_bool() == Some(false);
                let is_integer_val = expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0);

                let rendered = crate::e2e::template_env::render(
                    "csharp/assertion.jinja",
                    minijinja::context! {
                        assertion_type => "equals",
                        field_expr => field_expr.clone(),
                        cs_val => cs_val,
                        is_string_val => is_string_val,
                        is_bool_true => is_bool_true,
                        is_bool_false => is_bool_false,
                        is_integer_val => is_integer_val,
                    },
                );
                out.push_str(&rendered);
            }
        }
        "contains" => {
            if let Some(expected) = &assertion.value {
                // Lowercase both expected and actual so that enum fields (where .ToString()
                // returns the PascalCase C# member name like "Anchor") correctly match
                // fixture snake_case values like "anchor".  String fields are unaffected
                // because lowercasing both sides preserves substring matches.
                // List/complex fields use JsonSerializer.Serialize() since List<T>.ToString()
                // returns the type name, not the contents.
                let lower_expected = expected.as_str().map(|s| s.to_lowercase());
                let cs_val = lower_expected
                    .as_deref()
                    .map(|s| format!("\"{}\"", escape_csharp(s)))
                    .unwrap_or_else(|| json_to_csharp(expected));

                let rendered = crate::e2e::template_env::render(
                    "csharp/assertion.jinja",
                    minijinja::context! {
                        assertion_type => "contains",
                        field_as_str => field_as_str.clone(),
                        cs_val => cs_val,
                    },
                );
                out.push_str(&rendered);
            }
        }
        "contains_all" => {
            if let Some(values) = &assertion.values {
                let values_cs_lower: Vec<String> = values
                    .iter()
                    .map(|val| {
                        let lower_val = val.as_str().map(|s| s.to_lowercase());
                        lower_val
                            .as_deref()
                            .map(|s| format!("\"{}\"", escape_csharp(s)))
                            .unwrap_or_else(|| json_to_csharp(val))
                    })
                    .collect();

                let rendered = crate::e2e::template_env::render(
                    "csharp/assertion.jinja",
                    minijinja::context! {
                        assertion_type => "contains_all",
                        field_as_str => field_as_str.clone(),
                        values_cs_lower => values_cs_lower,
                    },
                );
                out.push_str(&rendered);
            }
        }
        "not_contains" => {
            if let Some(expected) = &assertion.value {
                let cs_val = json_to_csharp(expected);

                let rendered = crate::e2e::template_env::render(
                    "csharp/assertion.jinja",
                    minijinja::context! {
                        assertion_type => "not_contains",
                        field_as_str => field_as_str.clone(),
                        cs_val => cs_val,
                    },
                );
                out.push_str(&rendered);
            }
        }
        "not_empty" => {
            // Detect non-nullable: if expression has ! operator or is a method call
            let field_is_nullable = !field_expr.contains('!') && !field_expr.contains(")");
            let rendered = crate::e2e::template_env::render(
                "csharp/assertion.jinja",
                minijinja::context! {
                    assertion_type => "not_empty",
                    field_expr => field_expr.clone(),
                    field_needs_json_serialize => field_needs_json_serialize,
                    field_is_nullable => field_is_nullable,
                },
            );
            out.push_str(&rendered);
        }
        "is_empty" => {
            // Detect non-nullable: if expression has ! operator or is a method call
            let field_is_nullable = !field_expr.contains('!') && !field_expr.contains(")");
            let rendered = crate::e2e::template_env::render(
                "csharp/assertion.jinja",
                minijinja::context! {
                    assertion_type => "is_empty",
                    field_expr => field_expr.clone(),
                    field_needs_json_serialize => field_needs_json_serialize,
                    field_is_nullable => field_is_nullable,
                },
            );
            out.push_str(&rendered);
        }
        "contains_any" => {
            if let Some(values) = &assertion.values {
                let checks: Vec<String> = values
                    .iter()
                    .map(|v| {
                        let cs_val = json_to_csharp(v);
                        format!("{field_as_str}.Contains({cs_val})")
                    })
                    .collect();
                let contains_any_expr = checks.join(" || ");

                let rendered = crate::e2e::template_env::render(
                    "csharp/assertion.jinja",
                    minijinja::context! {
                        assertion_type => "contains_any",
                        contains_any_expr => contains_any_expr,
                    },
                );
                out.push_str(&rendered);
            }
        }
        "greater_than" => {
            if let Some(val) = &assertion.value {
                let cs_val = json_to_csharp(val);

                let rendered = crate::e2e::template_env::render(
                    "csharp/assertion.jinja",
                    minijinja::context! {
                        assertion_type => "greater_than",
                        field_expr => field_expr.clone(),
                        cs_val => cs_val,
                    },
                );
                out.push_str(&rendered);
            }
        }
        "less_than" => {
            if let Some(val) = &assertion.value {
                let cs_val = json_to_csharp(val);

                let rendered = crate::e2e::template_env::render(
                    "csharp/assertion.jinja",
                    minijinja::context! {
                        assertion_type => "less_than",
                        field_expr => field_expr.clone(),
                        cs_val => cs_val,
                    },
                );
                out.push_str(&rendered);
            }
        }
        "greater_than_or_equal" => {
            if let Some(val) = &assertion.value {
                let cs_val = json_to_csharp(val);

                let rendered = crate::e2e::template_env::render(
                    "csharp/assertion.jinja",
                    minijinja::context! {
                        assertion_type => "greater_than_or_equal",
                        field_expr => field_expr.clone(),
                        cs_val => cs_val,
                    },
                );
                out.push_str(&rendered);
            }
        }
        "less_than_or_equal" => {
            if let Some(val) = &assertion.value {
                let cs_val = json_to_csharp(val);

                let rendered = crate::e2e::template_env::render(
                    "csharp/assertion.jinja",
                    minijinja::context! {
                        assertion_type => "less_than_or_equal",
                        field_expr => field_expr.clone(),
                        cs_val => cs_val,
                    },
                );
                out.push_str(&rendered);
            }
        }
        "starts_with" => {
            if let Some(expected) = &assertion.value {
                let cs_val = json_to_csharp(expected);

                let rendered = crate::e2e::template_env::render(
                    "csharp/assertion.jinja",
                    minijinja::context! {
                        assertion_type => "starts_with",
                        field_expr => field_expr.clone(),
                        cs_val => cs_val,
                    },
                );
                out.push_str(&rendered);
            }
        }
        "ends_with" => {
            if let Some(expected) = &assertion.value {
                let cs_val = json_to_csharp(expected);

                let rendered = crate::e2e::template_env::render(
                    "csharp/assertion.jinja",
                    minijinja::context! {
                        assertion_type => "ends_with",
                        field_expr => field_expr.clone(),
                        cs_val => cs_val,
                    },
                );
                out.push_str(&rendered);
            }
        }
        "min_length" => {
            if let Some(val) = &assertion.value {
                if let Some(n) = val.as_u64() {
                    let rendered = crate::e2e::template_env::render(
                        "csharp/assertion.jinja",
                        minijinja::context! {
                            assertion_type => "min_length",
                            field_expr => field_expr.clone(),
                            n => n,
                        },
                    );
                    out.push_str(&rendered);
                }
            }
        }
        "max_length" => {
            if let Some(val) = &assertion.value {
                if let Some(n) = val.as_u64() {
                    let rendered = crate::e2e::template_env::render(
                        "csharp/assertion.jinja",
                        minijinja::context! {
                            assertion_type => "max_length",
                            field_expr => field_expr.clone(),
                            n => n,
                        },
                    );
                    out.push_str(&rendered);
                }
            }
        }
        "count_min" => {
            if let Some(val) = &assertion.value {
                if let Some(n) = val.as_u64() {
                    let rendered = crate::e2e::template_env::render(
                        "csharp/assertion.jinja",
                        minijinja::context! {
                            assertion_type => "count_min",
                            field_expr => field_expr.clone(),
                            n => n,
                        },
                    );
                    out.push_str(&rendered);
                }
            }
        }
        "count_equals" => {
            if let Some(val) = &assertion.value {
                if let Some(n) = val.as_u64() {
                    let rendered = crate::e2e::template_env::render(
                        "csharp/assertion.jinja",
                        minijinja::context! {
                            assertion_type => "count_equals",
                            field_expr => field_expr.clone(),
                            n => n,
                        },
                    );
                    out.push_str(&rendered);
                }
            }
        }
        "is_true" => {
            // When the field expression is not a simple bool (e.g., a complex object or
            // result type), use Assert.NotNull instead of Assert.True to avoid cast issues.
            // If it's clearly not a boolean type (contains null-checking operators or is a
            // complex object), treat it as a not-null check.
            let is_complex_or_object = field_expr.contains("(object)")
                || (field_expr.contains(".")
                    && !result_is_simple
                    && !field_expr.contains("?")
                    && !field_expr.contains("=="));

            let rendered = if is_complex_or_object {
                crate::e2e::template_env::render(
                    "csharp/assertion.jinja",
                    minijinja::context! {
                        assertion_type => "not_empty",
                        field_expr => field_expr.clone(),
                        field_needs_json_serialize => false,
                    },
                )
            } else {
                crate::e2e::template_env::render(
                    "csharp/assertion.jinja",
                    minijinja::context! {
                        assertion_type => "is_true",
                        field_expr => field_expr.clone(),
                    },
                )
            };
            out.push_str(&rendered);
        }
        "is_false" => {
            let is_complex_or_object = field_expr.contains("(object)")
                || (field_expr.contains(".")
                    && !result_is_simple
                    && !field_expr.contains("?")
                    && !field_expr.contains("=="));

            let rendered = if is_complex_or_object {
                // For complex types, is_false means "is empty/null"
                crate::e2e::template_env::render(
                    "csharp/assertion.jinja",
                    minijinja::context! {
                        assertion_type => "is_empty",
                        field_expr => field_expr.clone(),
                        field_needs_json_serialize => false,
                    },
                )
            } else {
                crate::e2e::template_env::render(
                    "csharp/assertion.jinja",
                    minijinja::context! {
                        assertion_type => "is_false",
                        field_expr => field_expr.clone(),
                    },
                )
            };
            out.push_str(&rendered);
        }
        "not_error" => {
            // Already handled by the call succeeding without exception.
            let rendered = crate::e2e::template_env::render(
                "csharp/assertion.jinja",
                minijinja::context! {
                    assertion_type => "not_error",
                },
            );
            out.push_str(&rendered);
        }
        "error" => {
            // Handled at the test method level.
            let rendered = crate::e2e::template_env::render(
                "csharp/assertion.jinja",
                minijinja::context! {
                    assertion_type => "error",
                },
            );
            out.push_str(&rendered);
        }
        "method_result" => {
            if let Some(method_name) = &assertion.method {
                let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
                let check = assertion.check.as_deref().unwrap_or("is_true");

                match check {
                    "equals" => {
                        if let Some(val) = &assertion.value {
                            let is_check_bool_true = val.as_bool() == Some(true);
                            let is_check_bool_false = val.as_bool() == Some(false);
                            let cs_check_val = json_to_csharp(val);

                            let rendered = crate::e2e::template_env::render(
                                "csharp/assertion.jinja",
                                minijinja::context! {
                                    assertion_type => "method_result",
                                    check => "equals",
                                    call_expr => call_expr.clone(),
                                    is_check_bool_true => is_check_bool_true,
                                    is_check_bool_false => is_check_bool_false,
                                    cs_check_val => cs_check_val,
                                },
                            );
                            out.push_str(&rendered);
                        }
                    }
                    "is_true" => {
                        let rendered = crate::e2e::template_env::render(
                            "csharp/assertion.jinja",
                            minijinja::context! {
                                assertion_type => "method_result",
                                check => "is_true",
                                call_expr => call_expr.clone(),
                            },
                        );
                        out.push_str(&rendered);
                    }
                    "is_false" => {
                        let rendered = crate::e2e::template_env::render(
                            "csharp/assertion.jinja",
                            minijinja::context! {
                                assertion_type => "method_result",
                                check => "is_false",
                                call_expr => call_expr.clone(),
                            },
                        );
                        out.push_str(&rendered);
                    }
                    "greater_than_or_equal" => {
                        if let Some(val) = &assertion.value {
                            let check_n = val.as_u64().unwrap_or(0);

                            let rendered = crate::e2e::template_env::render(
                                "csharp/assertion.jinja",
                                minijinja::context! {
                                    assertion_type => "method_result",
                                    check => "greater_than_or_equal",
                                    call_expr => call_expr.clone(),
                                    check_n => check_n,
                                },
                            );
                            out.push_str(&rendered);
                        }
                    }
                    "count_min" => {
                        if let Some(val) = &assertion.value {
                            let check_n = val.as_u64().unwrap_or(0);

                            let rendered = crate::e2e::template_env::render(
                                "csharp/assertion.jinja",
                                minijinja::context! {
                                    assertion_type => "method_result",
                                    check => "count_min",
                                    call_expr => call_expr.clone(),
                                    check_n => check_n,
                                },
                            );
                            out.push_str(&rendered);
                        }
                    }
                    "is_error" => {
                        let rendered = crate::e2e::template_env::render(
                            "csharp/assertion.jinja",
                            minijinja::context! {
                                assertion_type => "method_result",
                                check => "is_error",
                                call_expr => call_expr.clone(),
                                exception_class => exception_class,
                            },
                        );
                        out.push_str(&rendered);
                    }
                    "contains" => {
                        if let Some(val) = &assertion.value {
                            let cs_check_val = json_to_csharp(val);

                            let rendered = crate::e2e::template_env::render(
                                "csharp/assertion.jinja",
                                minijinja::context! {
                                    assertion_type => "method_result",
                                    check => "contains",
                                    call_expr => call_expr.clone(),
                                    cs_check_val => cs_check_val,
                                },
                            );
                            out.push_str(&rendered);
                        }
                    }
                    other_check => {
                        panic!("C# e2e generator: unsupported method_result check type: {other_check}");
                    }
                }
            } else {
                panic!("C# e2e generator: method_result assertion missing 'method' field");
            }
        }
        "matches_regex" => {
            if let Some(expected) = &assertion.value {
                let cs_val = json_to_csharp(expected);

                let rendered = crate::e2e::template_env::render(
                    "csharp/assertion.jinja",
                    minijinja::context! {
                        assertion_type => "matches_regex",
                        field_expr => field_expr.clone(),
                        cs_val => cs_val,
                    },
                );
                out.push_str(&rendered);
            }
        }
        other => {
            panic!("C# e2e generator: unsupported assertion type: {other}");
        }
    }
}

/// Recursively sort JSON objects so that any key named `"type"` appears first.
///
/// System.Text.Json's `[JsonPolymorphic]` requires the type discriminator to be
/// the first property when deserializing polymorphic types. Fixture config values
/// serialised via serde_json preserve insertion/alphabetical order, which may put
/// `"type"` after other keys (e.g. `"password"` before `"type"` in auth configs).
fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
    match value {
        serde_json::Value::Object(map) => {
            let mut sorted = serde_json::Map::with_capacity(map.len());
            // Insert "type" first if present.
            if let Some(type_val) = map.get("type") {
                sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
            }
            for (k, v) in map {
                if k != "type" {
                    sorted.insert(k, sort_discriminator_first(v));
                }
            }
            serde_json::Value::Object(sorted)
        }
        serde_json::Value::Array(arr) => {
            serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
        }
        other => other,
    }
}

/// Render a C# sealed-union display helper for assert_enum_fields.
/// Pattern-matches on variants from the IR and returns a displayable string.
fn render_sealed_display(
    type_name: &str,
    enum_def: &crate::core::ir::EnumDef,
    type_defs: &[crate::core::ir::TypeDef],
    namespace: &str,
) -> String {
    let header = hash::header(CommentStyle::DoubleSlash);
    let mut out = header;
    out.push_str(&format!("namespace {namespace}.E2e;\n\n"));
    out.push_str(&format!(
        "/// <summary>\n/// Helper class for extracting display strings from {type_name} sealed interface.\n /// </summary>\n"
    ));
    out.push_str(&format!("internal static class {type_name}Display\n"));
    out.push_str("{\n");
    out.push_str(&format!(
        "    internal static string ToDisplayString({type_name}? value)\n"
    ));
    out.push_str("    {\n");
    out.push_str("        if (value == null) return \"\";\n");
    out.push_str("        return value switch\n");
    out.push_str("        {\n");

    for variant in &enum_def.variants {
        let variant_name = &variant.name;
        // Determine the display string for this variant's arm.
        // Tuple variants with one field whose resolved struct type has a `format`
        // field return the inner `.Value.Format` — this gives the actual format
        // string (e.g. "PNG") rather than the generic variant label (e.g. "image").
        let has_format_field = variant.is_tuple && variant.fields.len() == 1 && {
            let field_type_name = match &variant.fields[0].ty {
                crate::core::ir::TypeRef::Named(n) => Some(n.as_str()),
                _ => None,
            };
            field_type_name.is_some_and(|tn| {
                type_defs
                    .iter()
                    .find(|td| td.name == tn)
                    .is_some_and(|td| td.fields.iter().any(|f| f.name == "format"))
            })
        };

        let display = if has_format_field {
            "i.Value.Format".to_string()
        } else {
            // Use the serde rename when present; otherwise lowercase the variant name.
            let serde_name = variant
                .serde_rename
                .as_deref()
                .unwrap_or(variant_name.as_str())
                .to_lowercase();
            format!("\"{serde_name}\"")
        };

        let binding = if has_format_field {
            format!("{type_name}.{variant_name} i")
        } else {
            format!("{type_name}.{variant_name}")
        };

        out.push_str(&format!("            {binding} => {display},\n"));
    }

    out.push_str("            _ => \"unknown\",\n");
    out.push_str("        };\n");
    out.push_str("    }\n");
    out.push_str("}\n");
    out
}

/// Convert a `serde_json::Value` to a C# literal string.
fn json_to_csharp(value: &serde_json::Value) -> String {
    match value {
        serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
        serde_json::Value::Bool(true) => "true".to_string(),
        serde_json::Value::Bool(false) => "false".to_string(),
        serde_json::Value::Number(n) => {
            if n.is_f64() {
                format!("{}d", n)
            } else {
                n.to_string()
            }
        }
        serde_json::Value::Null => "null".to_string(),
        serde_json::Value::Array(arr) => {
            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
            format!("new[] {{ {} }}", items.join(", "))
        }
        serde_json::Value::Object(_) => {
            let json_str = serde_json::to_string(value).unwrap_or_default();
            format!("\"{}\"", escape_csharp(&json_str))
        }
    }
}

/// Emit a C# object initializer for a JSON options object.
///
/// - camelCase fixture keys → PascalCase C# property names
/// - Enum fields (from `enum_fields`) → `EnumType.Member`
/// - Nested objects with known type (from `nested_types`) → `JsonSerializer.Deserialize<T>(...)`
/// - Arrays → `new List<string> { ... }`
/// - Primitives → C# literals via `json_to_csharp`
fn csharp_object_initializer(
    obj: &serde_json::Map<String, serde_json::Value>,
    type_name: &str,
    enum_fields: &HashMap<String, String>,
    nested_types: &HashMap<String, String>,
) -> String {
    if obj.is_empty() {
        return format!("new {type_name}()");
    }

    // Snake_case fixture keys for fields that are real C# enums in the binding.
    // The fixture string value (e.g. "markdown") maps to `EnumType.Member` (e.g. `OutputFormat.Markdown`).
    static IMPLICIT_ENUM_FIELDS: &[(&str, &str)] = &[("output_format", "OutputFormat")];

    let props: Vec<String> = obj
        .iter()
        .map(|(key, val)| {
            let pascal_key = key.to_upper_camel_case();
            let implicit_enum_type = IMPLICIT_ENUM_FIELDS
                .iter()
                .find(|(k, _)| *k == key.as_str())
                .map(|(_, t)| *t);
            // Check enum_fields both with the original snake_case key AND with camelCase key.
            // The alef.toml config uses camelCase keys (e.g., "codeBlockStyle"), but fixture
            // JSON uses snake_case keys (e.g., "code_block_style"). So we check both.
            let camel_key = key.to_lower_camel_case();
            let cs_val = if let Some(enum_type) = enum_fields
                .get(key.as_str())
                .or_else(|| enum_fields.get(camel_key.as_str()))
                .map(String::as_str)
                .or(implicit_enum_type)
            {
                // Enum: EnumType.Member
                if val.is_null() {
                    "null".to_string()
                } else {
                    let member = val
                        .as_str()
                        .map(|s| s.to_upper_camel_case())
                        .unwrap_or_else(|| "null".to_string());
                    format!("{enum_type}.{member}")
                }
            } else if let Some(nested_type) = nested_types
                .get(key.as_str())
                .or_else(|| nested_types.get(camel_key.as_str()))
            {
                // Nested type: deserialize via JsonSerializer using the binding's custom converters.
                // This handles sealed records, custom JsonConverters, and sealed unions correctly.
                let normalized = normalize_csharp_enum_values(val, enum_fields);
                let json_str = serde_json::to_string(&normalized).unwrap_or_default();
                let escaped = escape_csharp(&json_str);
                format!("JsonSerializer.Deserialize<{nested_type}>(\"{escaped}\", ConfigOptions)!")
            } else if let Some(arr) = val.as_array() {
                // Array: List<string>
                let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
                format!("new List<string> {{ {} }}", items.join(", "))
            } else {
                json_to_csharp(val)
            };
            format!("{pascal_key} = {cs_val}")
        })
        .collect();
    format!("new {} {{ {} }}", type_name, props.join(", "))
}

/// Convert enum values in a JSON object to lowercase to match C# [JsonPropertyName] attributes.
/// The JSON deserialization uses JsonPropertyName("lowercase_value"), so fixture enum values
/// (typically PascalCase like "Tildes") must be converted to lowercase ("tildes") for correct
/// deserialization with JsonStringEnumConverter.
fn normalize_csharp_enum_values(value: &serde_json::Value, enum_fields: &HashMap<String, String>) -> serde_json::Value {
    match value {
        serde_json::Value::Object(map) => {
            let mut result = map.clone();
            for (key, val) in result.iter_mut() {
                // Check both snake_case and camelCase keys, since alef.toml uses camelCase
                // but fixture JSON uses snake_case.
                let camel_key = key.to_lower_camel_case();
                if enum_fields.contains_key(key) || enum_fields.contains_key(camel_key.as_str()) {
                    // This is an enum field; convert the string value to lowercase.
                    if let Some(s) = val.as_str() {
                        *val = serde_json::Value::String(s.to_lowercase());
                    }
                }
            }
            serde_json::Value::Object(result)
        }
        other => other.clone(),
    }
}

// ---------------------------------------------------------------------------
// Visitor generation
// ---------------------------------------------------------------------------

/// Build a C# visitor: add an instantiation line to `setup_lines` and push
/// a private nested class declaration to `class_decls` (emitted at class scope,
/// outside any method body — C# does not allow local class declarations inside
/// methods).  Each fixture gets a unique class name derived from its ID to avoid
/// duplicate-name compile errors when multiple visitor fixtures exist per file.
/// Returns the visitor variable name for use as a call argument.
fn build_csharp_visitor(
    setup_lines: &mut Vec<String>,
    class_decls: &mut Vec<String>,
    fixture_id: &str,
    visitor_spec: &crate::e2e::fixture::VisitorSpec,
) -> String {
    use heck::ToUpperCamelCase;
    let class_name = format!("{}Visitor", fixture_id.to_upper_camel_case());
    let var_name = format!("_visitor_{}", fixture_id.replace('-', "_"));

    setup_lines.push(format!("var {var_name} = new {class_name}();"));

    // Build the class declaration string (indented for nesting inside the test class).
    let mut decl = String::new();
    decl.push_str(&format!("    private sealed class {class_name} : IHtmlVisitor\n"));
    decl.push_str("    {\n");

    // List of all visitor methods that must be implemented by IHtmlVisitor.
    let all_methods = [
        "visit_element_start",
        "visit_element_end",
        "visit_text",
        "visit_link",
        "visit_image",
        "visit_heading",
        "visit_code_block",
        "visit_code_inline",
        "visit_list_item",
        "visit_list_start",
        "visit_list_end",
        "visit_table_start",
        "visit_table_row",
        "visit_table_end",
        "visit_blockquote",
        "visit_strong",
        "visit_emphasis",
        "visit_strikethrough",
        "visit_underline",
        "visit_subscript",
        "visit_superscript",
        "visit_mark",
        "visit_line_break",
        "visit_horizontal_rule",
        "visit_custom_element",
        "visit_definition_list_start",
        "visit_definition_term",
        "visit_definition_description",
        "visit_definition_list_end",
        "visit_form",
        "visit_input",
        "visit_button",
        "visit_audio",
        "visit_video",
        "visit_iframe",
        "visit_details",
        "visit_summary",
        "visit_figure_start",
        "visit_figcaption",
        "visit_figure_end",
    ];

    // Emit all methods: use fixture action if specified, otherwise default to Continue.
    for method_name in &all_methods {
        if let Some(action) = visitor_spec.callbacks.get(*method_name) {
            emit_csharp_visitor_method(&mut decl, method_name, action);
        } else {
            // Default: Continue for methods not in the fixture
            emit_csharp_visitor_method(&mut decl, method_name, &CallbackAction::Continue);
        }
    }

    decl.push_str("    }\n");
    class_decls.push(decl);

    var_name
}

/// Emit a C# visitor method into a class declaration string.
fn emit_csharp_visitor_method(decl: &mut String, method_name: &str, action: &CallbackAction) {
    let camel_method = method_to_camel(method_name);
    let params = match method_name {
        "visit_link" => "NodeContext ctx, string href, string text, string title",
        "visit_image" => "NodeContext ctx, string src, string alt, string title",
        "visit_heading" => "NodeContext ctx, uint level, string text, string id",
        "visit_code_block" => "NodeContext ctx, string lang, string code",
        "visit_code_inline"
        | "visit_strong"
        | "visit_emphasis"
        | "visit_strikethrough"
        | "visit_underline"
        | "visit_subscript"
        | "visit_superscript"
        | "visit_mark"
        | "visit_button"
        | "visit_summary"
        | "visit_figcaption"
        | "visit_definition_term"
        | "visit_definition_description" => "NodeContext ctx, string text",
        "visit_text" => "NodeContext ctx, string text",
        "visit_list_item" => "NodeContext ctx, bool ordered, string marker, string text",
        "visit_blockquote" => "NodeContext ctx, string content, ulong depth",
        "visit_table_row" => "NodeContext ctx, List<string> cells, bool isHeader",
        "visit_custom_element" => "NodeContext ctx, string tagName, string html",
        "visit_form" => "NodeContext ctx, string actionUrl, string method",
        "visit_input" => "NodeContext ctx, string inputType, string name, string value",
        "visit_audio" | "visit_video" | "visit_iframe" => "NodeContext ctx, string src",
        "visit_details" => "NodeContext ctx, bool isOpen",
        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
            "NodeContext ctx, string output"
        }
        "visit_list_start" => "NodeContext ctx, bool ordered",
        "visit_list_end" => "NodeContext ctx, bool ordered, string output",
        "visit_element_start"
        | "visit_table_start"
        | "visit_definition_list_start"
        | "visit_figure_start"
        | "visit_line_break"
        | "visit_horizontal_rule" => "NodeContext ctx",
        _ => "NodeContext ctx",
    };

    let (action_type, action_value) = match action {
        CallbackAction::Skip => ("skip", String::new()),
        CallbackAction::Continue => ("continue", String::new()),
        CallbackAction::PreserveHtml => ("preserve_html", String::new()),
        CallbackAction::Custom { output } => ("custom", escape_csharp(output)),
        CallbackAction::CustomTemplate { template, .. } => {
            let camel = snake_case_template_to_camel(template);
            ("custom_template", escape_csharp(&camel))
        }
    };

    let rendered = crate::e2e::template_env::render(
        "csharp/visitor_method.jinja",
        minijinja::context! {
            camel_method => camel_method,
            params => params,
            action_type => action_type,
            action_value => action_value,
        },
    );
    let _ = write!(decl, "{}", rendered);
}

/// Convert snake_case method names to C# PascalCase.
fn method_to_camel(snake: &str) -> String {
    use heck::ToUpperCamelCase;
    snake.to_upper_camel_case()
}

/// Rewrite `{snake_case}` placeholders in a custom template to `{camelCase}` so
/// they match C# parameter names (which alef emits in camelCase).
fn snake_case_template_to_camel(template: &str) -> String {
    use heck::ToLowerCamelCase;
    let mut out = String::with_capacity(template.len());
    let mut chars = template.chars().peekable();
    while let Some(c) = chars.next() {
        if c == '{' {
            let mut name = String::new();
            while let Some(&nc) = chars.peek() {
                if nc == '}' {
                    chars.next();
                    break;
                }
                name.push(nc);
                chars.next();
            }
            out.push('{');
            out.push_str(&name.to_lower_camel_case());
            out.push('}');
        } else {
            out.push(c);
        }
    }
    out
}

/// Build a C# call expression for a `method_result` assertion on a sample_language Tree.
///
/// Maps well-known method names to the appropriate C# static helper calls on the
/// generated lib class, falling back to `result_var.PascalCase()` for unknowns.
fn build_csharp_method_call(
    result_var: &str,
    method_name: &str,
    args: Option<&serde_json::Value>,
    class_name: &str,
) -> String {
    match method_name {
        "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
        "root_node_type" => format!("{result_var}.RootNode.Kind"),
        "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
        "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
        "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
        "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
        "contains_node_type" => {
            let node_type = args
                .and_then(|a| a.get("node_type"))
                .and_then(|v| v.as_str())
                .unwrap_or("");
            format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
        }
        "find_nodes_by_type" => {
            let node_type = args
                .and_then(|a| a.get("node_type"))
                .and_then(|v| v.as_str())
                .unwrap_or("");
            format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
        }
        "run_query" => {
            let query_source = args
                .and_then(|a| a.get("query_source"))
                .and_then(|v| v.as_str())
                .unwrap_or("");
            let language = args
                .and_then(|a| a.get("language"))
                .and_then(|v| v.as_str())
                .unwrap_or("");
            format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
        }
        _ => {
            use heck::ToUpperCamelCase;
            let pascal = method_name.to_upper_camel_case();
            format!("{result_var}.{pascal}()")
        }
    }
}

fn fixture_has_csharp_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
    // HTTP fixtures are handled separately — not our concern here.
    if fixture.is_http_test() {
        return false;
    }
    // Use resolve_call_for_fixture to support auto-routing via select_when.
    let call_config = e2e_config.resolve_call_for_fixture(
        fixture.call.as_deref(),
        &fixture.id,
        &fixture.resolved_category(),
        &fixture.tags,
        &fixture.input,
    );
    let cs_override = call_config
        .overrides
        .get("csharp")
        .or_else(|| e2e_config.call.overrides.get("csharp"));
    // When a client_factory is configured the fixture is callable via the client pattern.
    if cs_override.and_then(|o| o.client_factory.as_deref()).is_some() {
        return true;
    }
    // C# binding provides a default class name (e.g., SampleCrawlerLib) if not overridden,
    // so any function name makes a callable available.
    cs_override.and_then(|o| o.function.as_deref()).is_some() || !call_config.function.is_empty()
}

/// Classify a fixture string value that maps to a `bytes` argument.
/// Determines whether to treat it as a file path, inline text, or base64-encoded data.
fn classify_bytes_value_csharp(s: &str) -> String {
    // File paths: start with alphanumeric/underscore, contain "/" with extension
    // e.g., "pdf/fake.pdf", "images/test.png"
    if let Some(first) = s.chars().next() {
        if first.is_ascii_alphanumeric() || first == '_' {
            if let Some(slash_pos) = s.find('/') {
                if slash_pos > 0 {
                    let after_slash = &s[slash_pos + 1..];
                    if after_slash.contains('.') && !after_slash.is_empty() {
                        // File path: use File.ReadAllBytes(path)
                        return format!("System.IO.File.ReadAllBytes(\"{}\")", s);
                    }
                }
            }
        }
    }

    // Inline text: starts with markup or contains spaces
    // e.g., "<html>...", "{...}", "[...]", "text with spaces"
    if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
        // Inline text: use System.Text.Encoding.UTF8.GetBytes()
        return format!("System.Text.Encoding.UTF8.GetBytes(\"{}\")", escape_csharp(s));
    }

    // Base64: base64-like pattern (uppercase/lowercase letters, digits, +, /, =)
    // e.g., "/9j/4AAQ", "SGVsbG8gV29ybGQ="
    // Use Convert.FromBase64String()
    format!("System.Convert.FromBase64String(\"{}\")", s)
}

/// Map an IR `TypeRef` to a C# type string for stub method signatures.
///
/// Used only by `emit_test_backend` — not the full production type-map used by
/// the C# backend generator.  Keeps stub generation self-contained and avoids
/// a dependency on the private `backends::csharp::type_map` module.
fn csharp_type_for_stub(ty: &crate::core::ir::TypeRef) -> String {
    use crate::core::ir::{PrimitiveType, TypeRef};
    match ty {
        TypeRef::Primitive(p) => match p {
            PrimitiveType::Bool => "bool".to_string(),
            PrimitiveType::U8 => "byte".to_string(),
            PrimitiveType::U16 => "ushort".to_string(),
            PrimitiveType::U32 => "uint".to_string(),
            PrimitiveType::U64 => "ulong".to_string(),
            PrimitiveType::I8 => "sbyte".to_string(),
            PrimitiveType::I16 => "short".to_string(),
            PrimitiveType::I32 => "int".to_string(),
            PrimitiveType::I64 => "long".to_string(),
            PrimitiveType::F32 => "float".to_string(),
            PrimitiveType::F64 => "double".to_string(),
            PrimitiveType::Usize => "ulong".to_string(), // usize maps to ulong in C# (not long!)
            PrimitiveType::Isize => "long".to_string(),
        },
        TypeRef::String | TypeRef::Char | TypeRef::Path => "string".to_string(),
        TypeRef::Bytes => "byte[]".to_string(),
        TypeRef::Unit => "void".to_string(),
        TypeRef::Optional(inner) => format!("{}?", csharp_type_for_stub(inner)),
        TypeRef::Vec(inner) => format!("List<{}>", csharp_type_for_stub(inner)),
        TypeRef::Map(k, v) => format!("Dictionary<{}, {}>", csharp_type_for_stub(k), csharp_type_for_stub(v)),
        TypeRef::Named(name) => name.clone(),
        TypeRef::Json => "object".to_string(),
        TypeRef::Duration => "ulong?".to_string(),
    }
}

/// Like `csharp_type_for_stub`, but replaces non-visible types (like InternalDocument,
/// SyncExtractor) with `string` so test stubs don't reference internal types.
fn csharp_type_for_stub_visible(ty: &crate::core::ir::TypeRef) -> String {
    use crate::core::ir::TypeRef;
    match ty {
        TypeRef::Named(name) => {
            if is_visible_csharp_type(name) {
                name.clone()
            } else {
                // Non-visible types become string
                "string".to_string()
            }
        }
        TypeRef::Optional(inner) => {
            let inner_str = csharp_type_for_stub_visible(inner);
            format!("{}?", inner_str)
        }
        TypeRef::Vec(inner) => {
            let inner_str = csharp_type_for_stub_visible(inner);
            format!("List<{}>", inner_str)
        }
        TypeRef::Map(k, v) => {
            let key_str = csharp_type_for_stub_visible(k);
            let val_str = csharp_type_for_stub_visible(v);
            format!("Dictionary<{}, {}>", key_str, val_str)
        }
        _ => csharp_type_for_stub(ty),
    }
}

/// Check if a type name is known to be exported from the C# binding.
/// Types like InternalDocument and SyncExtractor are internal and should not
/// appear in test stubs or public interfaces.
fn is_visible_csharp_type(type_name: &str) -> bool {
    // Whitelist of types that are actually exported by the C# binding
    // Includes public enums returned by trait methods (OcrBackendType, ProcessingStage, etc.)
    matches!(
        type_name,
        "ExtractionResult"
            | "ExtractionConfig"
            | "ExtractionFormat"
            | "OcrBackend"
            | "DocumentExtractor"
            | "PostProcessor"
            | "Renderer"
            | "Validator"
            | "EmbeddingBackend"
            | "EmbeddingRequest"
            | "EmbeddingResponse"
            | "Table"
            | "Block"
            | "Element"
            | "Metadata"
            | "FormatMetadata"
            | "LayoutDetection"
            | "TableDetectionResult"
            | "TableCell"
            | "ChunkingConfig"
            | "Chunk"
            | "ChunkMetadata"
            | "PluginException"
            | "SampleCrateError"
            | "OcrBackendType"
            | "ProcessingStage"
            | "OcrConfig"
    )
}

/// Emit the correct default value for a C# test stub return type.
/// When the original type is non-visible (e.g., InternalDocument), it's mapped to `string`,
/// so we need to return the appropriate default for the visible type, not the original.
fn emit_csharp_stub_default(
    original_type: &crate::core::ir::TypeRef,
    visible_type: &str,
    defaults: &dyn crate::codegen::defaults::LanguageDefaults,
) -> String {
    use crate::core::ir::TypeRef;

    // Check if this type or its inner types are non-visible
    fn contains_non_visible(ty: &TypeRef) -> bool {
        match ty {
            TypeRef::Named(name) => !is_visible_csharp_type(name),
            TypeRef::Optional(inner) => contains_non_visible(inner),
            TypeRef::Vec(inner) => contains_non_visible(inner),
            TypeRef::Map(k, v) => contains_non_visible(k) || contains_non_visible(v),
            _ => false,
        }
    }

    if contains_non_visible(original_type) {
        // Type contains non-visible parts, map to string default
        if visible_type.contains("?") {
            "null".to_string()
        } else {
            "\"\"".to_string()
        }
    } else {
        // Visible type, use the default logic
        defaults.emit_default(original_type)
    }
}

/// Emit a single C# stub method body into `out`.
///
/// Used by both the main method loop and the super-trait method section of
/// `emit_test_backend` so both paths share the same formatting logic.
/// `method_cs` is the already-PascalCased method name (caller's responsibility).
fn emit_csharp_stub_method(
    out: &mut String,
    method_cs: &str,
    method: &crate::core::ir::MethodDef,
    defaults: &dyn crate::codegen::defaults::LanguageDefaults,
) {
    use crate::core::ir::TypeRef;

    // C# trait bridge interfaces expose synchronous methods even though Rust traits are async.
    // The bridge implementation blocks on the async Rust call. So stubs must always be sync
    // (never emit `async Task<T>`). Always use the actual return type.
    let ret_ty = csharp_type_for_stub_visible(&method.return_type);
    // Use the visible type to determine the default value, not the original type
    // (e.g., InternalDocument → string → "")
    let default_val = emit_csharp_stub_default(&method.return_type, &ret_ty, defaults);

    // Build parameter list using visible types (internal types like InternalDocument
    // are mapped to string to avoid stub referencing non-public types).
    let params: Vec<String> = method
        .params
        .iter()
        .map(|p| {
            format!(
                "{} {}",
                csharp_type_for_stub_visible(&p.ty),
                p.name.to_lower_camel_case()
            )
        })
        .collect();
    let param_list = params.join(", ");

    // 8-space indent for method declarations (class body level); the caller's
    // class declaration is at 4-space, and the emitter adds 4 more — giving 8+4=12
    // for methods and 4+4=8 for the class line in the final file.
    // ALWAYS emit sync stubs, regardless of is_async in the Rust trait.
    if matches!(method.return_type, TypeRef::Unit) {
        let _ = writeln!(out, "        public void {method_cs}({param_list}) {{ }}");
    } else {
        let _ = writeln!(out, "        public {ret_ty} {method_cs}({param_list})");
        let _ = writeln!(out, "            => {default_val};");
    }
}

/// Emit a C# test backend stub.
///
/// Generates a nested private class implementing the bridge interface
/// (`I{TraitName}`) with minimal stub methods, then returns a
/// `{TraitName}Bridge.Register(new TestStub_{fixture_id}())` expression
/// as the registration call site.
///
/// Rules:
/// - The stub class name is `TestStub_{sanitized_fixture_id}` where the id
///   has been converted to PascalCase (safe C# identifier).
/// - Super-trait properties (Name, Version) are emitted first with literal values;
///   then lifecycle methods (Initialize, Shutdown) are emitted with default bodies.
/// - Required methods are emitted with return-type defaults produced by `CSharpDefaults`.
/// - Async methods return `Task<T>` and are `async`; sync methods are plain.
/// - Type names come from `csharp_type_for_stub()` — no crate-domain names
///   are hardcoded here. Non-visible types (like InternalDocument, SyncExtractor)
///   are NOT referenced in test stubs.
pub fn emit_test_backend(
    trait_bridge: &crate::core::config::TraitBridgeConfig,
    methods: &[&crate::core::ir::MethodDef],
    fixture: &crate::e2e::fixture::Fixture,
) -> super::TestBackendEmission {
    use crate::codegen::defaults::language_defaults;

    let defaults = language_defaults("csharp");

    // Derive a safe C# class identifier from the fixture id.
    let stub_class = format!("TestStub_{}", sanitize_ident(&fixture.id).to_upper_camel_case());

    // Interface name: I{TraitName} following C# convention.
    let trait_pascal = trait_bridge.trait_name.to_upper_camel_case();
    let iface_name = format!("I{trait_pascal}");

    let plugin_name = fixture
        .input
        .get("name")
        .and_then(|v| v.as_str())
        .unwrap_or(&fixture.id)
        .to_string();

    let mut setup = String::new();

    // Emit a private nested class declaration. This block will be placed at class scope
    // (not inside any method body) by the caller — the emitter adds 4 more spaces of
    // indentation, so each line here carries a 4-space prefix matching the visitor pattern.
    let _ = writeln!(setup, "    private class {stub_class} : {iface_name}");
    let _ = writeln!(setup, "    {{");

    // Track which super-trait methods we've already emitted to avoid duplication.
    let mut emitted_methods = std::collections::HashSet::new();

    // Super-trait properties and methods: when super_trait is configured, emit
    // the required Name and Version properties, then emit lifecycle methods
    // (initialize, shutdown) and domain-specific methods.
    if let Some(super_trait) = trait_bridge.super_trait.as_deref() {
        // Emit hardcoded Name and Version properties (required by Plugin super-trait)
        let _ = writeln!(setup, "        public string Name => \"{plugin_name}\";");
        let _ = writeln!(setup, "        public string Version => \"1.0.0\";");
        let _ = writeln!(setup);
        // Mark name and version as emitted so they won't be re-emitted as methods
        emitted_methods.insert("name".to_string());
        emitted_methods.insert("version".to_string());

        // Emit super-trait methods (initialize, shutdown) and domain methods
        for method in methods
            .iter()
            .filter(|m| m.trait_source.as_deref() == Some(super_trait))
        {
            let method_cs = method.name.to_upper_camel_case();
            emit_csharp_stub_method(&mut setup, &method_cs, method, &*defaults);
            emitted_methods.insert(method.name.clone());
        }
    }

    // All remaining methods (including those with default implementations).
    // Skip super-trait methods already emitted above.
    for method in methods.iter() {
        // Skip methods already emitted.
        if emitted_methods.contains(&method.name) {
            continue;
        }
        let method_cs = method.name.to_upper_camel_case();
        emit_csharp_stub_method(&mut setup, &method_cs, method, &*defaults);
    }

    let _ = writeln!(setup, "    }}");

    // Registration expression.
    // Always use the high-level `Bridge.Register(impl)` factory — it handles
    // FFI registration internally. The low-level `Bridge.RegisterXxx(impl)`
    // overloads (derived from reg_fn name) return IntPtr and are not the public API.
    let arg_expr = format!("{}Bridge.Register(new {}())", trait_pascal, stub_class);

    super::TestBackendEmission {
        setup_block: setup,
        arg_expr,
        type_imports: Vec::new(),
    }
}

#[cfg(test)]
mod tests {
    use crate::e2e::config::{CallConfig, E2eConfig, SelectWhen};
    use crate::e2e::fixture::Fixture;
    use std::collections::HashMap;

    fn make_fixture_with_input(id: &str, input: serde_json::Value) -> Fixture {
        Fixture {
            id: id.to_string(),
            category: None,
            description: "test fixture".to_string(),
            tags: vec![],
            skip: None,
            env: None,
            call: None,
            input,
            mock_response: None,
            source: String::new(),
            http: None,
            assertions: vec![],
            visitor: None,
            args: vec![],
        }
    }

    /// Test that resolve_call_for_fixture correctly routes to batch_scrape
    /// when input has batch_urls and select_when condition matches.
    #[test]
    fn test_csharp_select_when_routes_to_batch_scrape() {
        let mut calls = HashMap::new();
        calls.insert(
            "batch_scrape".to_string(),
            CallConfig {
                function: "BatchScrape".to_string(),
                module: "KreuzBrowser".to_string(),
                select_when: Some(SelectWhen {
                    input_has: Some("batch_urls".to_string()),
                    ..Default::default()
                }),
                ..CallConfig::default()
            },
        );

        let e2e_config = E2eConfig {
            call: CallConfig {
                function: "Scrape".to_string(),
                module: "KreuzBrowser".to_string(),
                ..CallConfig::default()
            },
            calls,
            ..E2eConfig::default()
        };

        // Fixture with batch_urls but no explicit call field should route to batch_scrape
        let fixture = make_fixture_with_input("batch_empty_urls", serde_json::json!({ "batch_urls": [] }));

        let resolved_call = e2e_config.resolve_call_for_fixture(
            fixture.call.as_deref(),
            &fixture.id,
            &fixture.resolved_category(),
            &fixture.tags,
            &fixture.input,
        );
        assert_eq!(resolved_call.function, "BatchScrape");

        // Fixture without batch_urls should fall back to default Scrape
        let fixture_no_batch =
            make_fixture_with_input("simple_scrape", serde_json::json!({ "url": "https://example.com" }));
        let resolved_default = e2e_config.resolve_call_for_fixture(
            fixture_no_batch.call.as_deref(),
            &fixture_no_batch.id,
            &fixture_no_batch.resolved_category(),
            &fixture_no_batch.tags,
            &fixture_no_batch.input,
        );
        assert_eq!(resolved_default.function, "Scrape");
    }

    /// Verify `emit_test_backend` is generic: output must not contain any
    /// hardcoded domain trait or method names — only names derived from the
    /// synthetic `TestTrait` / `do_work` inputs.
    #[test]
    fn test_emit_test_backend_is_generic_no_domain_names() {
        use crate::core::config::TraitBridgeConfig;
        use crate::core::ir::{MethodDef, ParamDef, ReceiverKind, TypeRef};

        let method = MethodDef {
            name: "do_work".to_string(),
            params: vec![ParamDef {
                name: "payload".to_string(),
                ty: TypeRef::String,
                optional: false,
                default: None,
                sanitized: false,
                typed_default: None,
                is_ref: false,
                is_mut: false,
                newtype_wrapper: None,
                original_type: None,
                map_is_ahash: false,
                map_key_is_cow: false,
            }],
            return_type: TypeRef::String,
            is_async: false,
            is_static: false,
            error_type: None,
            doc: String::new(),
            receiver: Some(ReceiverKind::Ref),
            sanitized: false,
            trait_source: None,
            returns_ref: false,
            returns_cow: false,
            return_newtype_wrapper: None,
            has_default_impl: false,
            binding_excluded: false,
            binding_exclusion_reason: None,
        };

        let bridge = TraitBridgeConfig {
            trait_name: "TestTrait".to_string(),
            super_trait: Some("Plugin".to_string()),
            register_fn: Some("register_test_trait".to_string()),
            ..Default::default()
        };

        let fixture = Fixture {
            id: "my_fixture".to_string(),
            category: None,
            description: "test".to_string(),
            tags: vec![],
            skip: None,
            env: None,
            call: None,
            input: serde_json::Value::Null,
            mock_response: None,
            source: String::new(),
            http: None,
            assertions: vec![],
            visitor: None,
            args: vec![],
        };

        let methods = vec![&method];
        let emission = super::emit_test_backend(&bridge, &methods, &fixture);

        // The generated code must reference the synthetic interface name.
        assert!(
            emission.setup_block.contains("ITestTrait"),
            "setup_block should reference ITestTrait, got:\n{}",
            emission.setup_block
        );
        assert!(
            emission.setup_block.contains("DoWork"),
            "setup_block should contain method DoWork, got:\n{}",
            emission.setup_block
        );

        // Must not contain any hardcoded domain-specific names.
        for name in &[
            "OcrBackend",
            "DocumentExtractor",
            "ProcessImage",
            "ExtractBytes",
            "sample_crate",
        ] {
            assert!(
                !emission.setup_block.contains(name),
                "setup_block must not contain domain name '{name}', got:\n{}",
                emission.setup_block
            );
        }
    }

    #[test]
    fn test_emit_test_backend_includes_name_version_properties_with_super_trait() {
        use crate::core::config::TraitBridgeConfig;
        use crate::core::ir::{MethodDef, ReceiverKind, TypeRef};

        let method = MethodDef {
            name: "initialize".to_string(),
            params: vec![],
            return_type: TypeRef::Unit,
            is_async: false,
            is_static: false,
            error_type: None,
            doc: String::new(),
            receiver: Some(ReceiverKind::Ref),
            sanitized: false,
            trait_source: Some("Plugin".to_string()),
            returns_ref: false,
            returns_cow: false,
            return_newtype_wrapper: None,
            has_default_impl: false,
            binding_excluded: false,
            binding_exclusion_reason: None,
        };

        let bridge = TraitBridgeConfig {
            trait_name: "OcrBackend".to_string(),
            super_trait: Some("Plugin".to_string()),
            ..Default::default()
        };

        let fixture = Fixture {
            id: "test_ocr".to_string(),
            category: None,
            description: "test".to_string(),
            tags: vec![],
            skip: None,
            env: None,
            call: None,
            input: serde_json::json!({"name": "my_ocr"}),
            mock_response: None,
            source: String::new(),
            http: None,
            assertions: vec![],
            visitor: None,
            args: vec![],
        };

        let methods = vec![&method];
        let emission = super::emit_test_backend(&bridge, &methods, &fixture);

        // Must include Name and Version properties
        assert!(
            emission.setup_block.contains("public string Name => \"my_ocr\";"),
            "setup_block should contain Name property, got:\n{}",
            emission.setup_block
        );
        assert!(
            emission.setup_block.contains("public string Version => \"1.0.0\";"),
            "setup_block should contain Version property, got:\n{}",
            emission.setup_block
        );

        // Must implement the interface
        assert!(
            emission.setup_block.contains("IOcrBackend"),
            "setup_block should reference IOcrBackend, got:\n{}",
            emission.setup_block
        );
    }

    /// Test that void-returning methods like register_ocr_backend are emitted as statements,
    /// not as variable assignments. The returns_void flag should prevent:
    ///   var result = SampleCrateLib.RegisterOcrBackend(...);  // ❌ WRONG - CS0815 Cannot assign void
    /// And instead emit:
    ///   SampleCrateLib.RegisterOcrBackend(...);  // ✓ CORRECT
    #[test]
    fn test_void_returning_register_calls_emit_as_statements() {
        // Create a call config with returns_void = true (as set in sample_crate alef.toml)
        let call_config = CallConfig {
            function: "register_ocr_backend".to_string(),
            returns_void: true,
            result_var: "result".to_string(),
            ..CallConfig::default()
        };

        // Verify the flag is correctly set. The C# codegen checks this at line 937:
        // let returns_void = if call_config.returns_void { true } else { ... };
        assert!(
            call_config.returns_void,
            "CallConfig.returns_void must be true for register_ocr_backend"
        );

        // The codegen then uses this to control template rendering:
        // Line 1227: has_usable_assertion => !expects_error && !returns_void,
        // Which causes the template to emit the call without assignment:
        // Line 76 (else branch): {{ async_kw }}{{ call_target }}.{{ call_expr }};
        // NOT Line 73: var {{ result_var }} = {{ async_kw }}{{ call_target }}.{{ call_expr }};
    }
}
