//! 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::e2e::config::E2eConfig;
use crate::e2e::escape::{escape_csharp, sanitize_filename};
use crate::e2e::field_access::FieldResolver;
use crate::e2e::fixture::{Fixture, FixtureGroup};
use anyhow::Result;
use heck::ToUpperCamelCase;
use std::collections::HashMap;
use std::fmt::Write as FmtWrite;
use std::path::PathBuf;

use super::E2eCodegen;

pub(super) fn resolve_handle_config_type(
    arg: &crate::e2e::config::ArgMapping,
    options_type: Option<&str>,
    type_defs: &[crate::core::ir::TypeDef],
) -> Option<String> {
    if arg.arg_type != "handle" {
        return None;
    }
    arg.element_type.as_deref().map(str::to_string).or_else(|| {
        options_type.map(str::to_string).or_else(|| {
            let candidate = format!("{}Config", arg.name.to_upper_camel_case());
            type_defs.iter().any(|ty| ty.name == candidate).then_some(candidate)
        })
    })
}

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
        // can fail with a client 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,
                enums,
            );
            files.push(GeneratedFile {
                path: tests_base.join(filename),
                content,
                generated_header: true,
            });
        }

        Ok(files)
    }

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

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

#[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],
    enums: &[crate::core::ir::EnumDef],
) -> 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,
            enums,
        );
        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)
}

#[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],
    enums: &[crate::core::ir::EnumDef],
) {
    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: streaming adapters return IAsyncEnumerable<T>, 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 crate::e2e::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming_enabled()) {
        render_streaming_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,
            enums,
            resolve_csharp_streaming_item_type(call_config, adapters, &raw_function_name).as_deref(),
        );
        return;
    }

    let _is_streaming = call_config.streaming_enabled().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 = cs_overrides.and_then(|o| o.r#async).unwrap_or(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 recipe = crate::e2e::codegen::recipe::ResolvedE2eCallRecipe::resolve("csharp", fixture, call_config, type_defs);
    let args = recipe.args;

    // 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.
    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] = recipe.extra_args;
    // 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 = recipe.options_type.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 teardown_lines: Vec<String> = Vec::new();
    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,
        enums,
        visitor_class_decls,
        &mut teardown_lines,
    );

    // 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 BatchedStreamItemsRequest 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 {
        let visitor_config = resolve_csharp_visitor_config(config, cs_overrides, type_defs, visitor_spec);
        visitor_arg = build_csharp_visitor(
            &mut setup_lines,
            visitor_class_decls,
            &fixture.id,
            visitor_spec,
            &visitor_config,
        );
    }

    // 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 an options instance that has Visitor set.
    let final_args = if has_visitor && !visitor_arg.is_empty() {
        let Some(opts_type) =
            effective_options_type.or_else(|| crate::e2e::codegen::recipe::trait_bridge_options_type(config))
        else {
            let return_type = if is_async { "async Task" } else { "void" };
            let _ = writeln!(out, "    [Fact]");
            let _ = writeln!(out, "    public {return_type} Test{method_name}()");
            let _ = writeln!(out, "    {{");
            let _ = writeln!(out, "        // {description}");
            let _ = writeln!(out, "        return;");
            let _ = writeln!(out, "    }}");
            return;
        };
        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"
            ));
        }
    }

    // Detect if this is a trait bridge registration test. The presence of a `test_backend`
    // arg is a reliable signal — the args loop above resolves it into a
    // `{Trait}Bridge.Register(new TestStub_*())` expression, which is self-contained:
    // it already performs native registration internally and returns the bridge id.
    // Wrapping it in the outer generated facade `Register*Backend()` reinterprets the
    // GCHandle as userData and NREs. Drop the wrap and invoke the bridge directly.
    let is_trait_bridge_registration = args.iter().any(|arg| arg.arg_type == "test_backend");

    // 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_trait_bridge_registration {
        // For trait bridge registration, emit ONLY the Bridge.Register() call, not the wrapper.
        // The Bridge.Register() method already calls native registration internally and returns
        // the bridge ID; wrapping it in the generated facade's Register*() reinterprets the GCHandle as
        // userData and NREs. The template branches on `is_trait_bridge` to drop the wrap.
        final_args.clone()
    } else 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,
        is_trait_bridge => is_trait_bridge_registration,
        teardown_lines => teardown_lines.clone(),
    };

    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() {
        let trimmed = line.trim_end();
        if !trimmed.is_empty() {
            out.push_str("    ");
            out.push_str(trimmed);
        }
        out.push('\n');
    }
}

mod assertions;
mod discriminated;
mod http;
mod project;
mod setup;
mod streaming;
mod values;
mod visitor;

use assertions::render_assertion;
use discriminated::{parse_discriminated_union_access, render_discriminated_union_assertion};
use http::render_http_test_method;
use project::{render_csproj, render_test_setup};
use setup::build_args_and_setup;
use streaming::{render_streaming_test_method, resolve_csharp_streaming_item_type};
use values::{json_to_csharp, render_sealed_display};
use visitor::{build_csharp_visitor, resolve_csharp_visitor_config};

/// 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.
pub(super) 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., DemoCrawlerLib) 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.
pub(super) 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)
}

mod stubs;

pub use stubs::emit_test_backend;

#[cfg(test)]
mod tests;
