use super::*;

#[test]
fn generates_extendr_module_registration() {
    let backend = ExtendrBackend;
    let config = make_config();
    let api = make_api_surface();
    let files = backend.generate_bindings(&api, &config).unwrap();
    assert_eq!(files.len(), 1);
    let content = &files[0].content;
    assert!(content.contains("extendr_module!"), "must emit extendr_module! macro");
    assert!(content.contains("mod testlib"), "module name must match r_package_name");
}

#[test]
fn generates_extendr_function_attribute() {
    let backend = ExtendrBackend;
    let config = make_config();
    let api = make_api_surface();
    let files = backend.generate_bindings(&api, &config).unwrap();
    let content = &files[0].content;
    assert!(
        content.contains("#[extendr]"),
        "functions must carry #[extendr] attribute"
    );
    assert!(content.contains("fn process"), "process function must be generated");
}

#[test]
fn r_package_name_drives_output_path() {
    let backend = ExtendrBackend;
    let config = make_config();
    let api = make_api_surface();
    let files = backend.generate_bindings(&api, &config).unwrap();
    // Output should go to packages/r/src/rust/src/lib.rs (default path)
    assert!(
        files[0].path.to_string_lossy().ends_with("lib.rs"),
        "output file must be lib.rs"
    );
}

#[test]
fn generate_public_api_uses_r_package_name() {
    let backend = ExtendrBackend;
    let config = make_config();
    let api = make_api_surface();
    let files = backend.generate_public_api(&api, &config).unwrap();
    // Expect: <package>.R (useDynLib stub), extendr-wrappers.R, NAMESPACE.
    let paths: Vec<String> = files.iter().map(|f| f.path.to_string_lossy().into_owned()).collect();
    assert!(
        paths.iter().any(|p| p.ends_with("testlib.R")),
        "public API file must include {{package_name}}.R, got {paths:?}"
    );
    assert!(
        paths.iter().any(|p| p.ends_with("extendr-wrappers.R")),
        "public API file must include extendr-wrappers.R, got {paths:?}"
    );
    assert!(
        paths.iter().any(|p| p.ends_with("NAMESPACE")),
        "public API file must include NAMESPACE, got {paths:?}"
    );
}

#[test]
fn extendr_wrappers_emits_function_call_binding() {
    let backend = ExtendrBackend;
    let config = make_config();
    let api = make_api_surface();
    let files = backend.generate_public_api(&api, &config).unwrap();
    let wrappers = files
        .iter()
        .find(|f| f.path.to_string_lossy().ends_with("extendr-wrappers.R"))
        .expect("extendr-wrappers.R must be generated");
    assert!(
        wrappers.content.contains("process <- function()"),
        "free function must produce a wrapper: {}",
        wrappers.content
    );
    assert!(
        wrappers.content.contains(".Call(\"wrap__process\""),
        "wrapper must invoke the wrap__ symbol: {}",
        wrappers.content
    );
    assert!(
        wrappers.content.contains("Config <- new.env(parent = emptyenv())"),
        "non-trait class must be registered as an env: {}",
        wrappers.content
    );
}

#[test]
fn extendr_wrappers_emits_roxygen_doc_block_for_free_functions() {
    // Regression: prior to roxygen2 doc emission, every free function in
    // extendr-wrappers.R carried only `#' @export` — `?<fn>` in an R REPL
    // returned an empty .Rd. The wrapper emitter must now derive a title
    // line + description from the Rust doc comment and emit `@param` /
    // `@return` lines from the IR's type information.
    let backend = ExtendrBackend;
    let config = make_config();
    let api = ApiSurface {
            crate_name: "test_lib".to_string(),
            version: "0.1.0".to_string(),
            types: vec![],
            functions: vec![FunctionDef {
                name: "extract_bytes".to_string(),
                rust_path: "test_lib::extract_bytes".to_string(),
                original_rust_path: String::new(),
                params: vec![
                    ParamDef {
                        name: "bytes".to_string(),
                        ty: TypeRef::Bytes,
                        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,
            vec_inner_is_ref: false,
                    map_is_btree: false,
                    core_wrapper: crate::core::ir::CoreWrapper::None,
                    },
                    ParamDef {
                        name: "mime_type".to_string(),
                        ty: TypeRef::Optional(Box::new(TypeRef::String)),
                        optional: true,
                        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,
            vec_inner_is_ref: false,
                    map_is_btree: false,
                    core_wrapper: crate::core::ir::CoreWrapper::None,
                    },
                    ParamDef {
                        name: "config".to_string(),
                        ty: TypeRef::Optional(Box::new(TypeRef::Named("ExtractionConfig".to_string()))),
                        optional: true,
                        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,
            vec_inner_is_ref: false,
                    map_is_btree: false,
                    core_wrapper: crate::core::ir::CoreWrapper::None,
                    },
                ],
                return_type: TypeRef::Named("ExtractionResult".to_string()),
                is_async: false,
                error_type: None,
                doc: "Extract text from raw bytes.\n\nDetect the MIME type of the input bytes\nand run the appropriate extractor.".to_string(),
                cfg: None,
                sanitized: false,
                return_sanitized: false,
                returns_ref: false,
                returns_cow: false,
                return_newtype_wrapper: None,
                binding_excluded: false,
                binding_exclusion_reason: None,
            version: Default::default(),
            }],
            enums: vec![],
            errors: vec![],
            excluded_type_paths: ::std::collections::HashMap::new(),
            excluded_trait_names: ::std::collections::HashSet::new(),
            services: vec![],
            handler_contracts: vec![],
                unsupported_public_items: Vec::new(),
};
    let files = backend.generate_public_api(&api, &config).unwrap();
    let wrappers = files
        .iter()
        .find(|f| f.path.to_string_lossy().ends_with("extendr-wrappers.R"))
        .expect("extendr-wrappers.R must be generated");
    let content = &wrappers.content;

    assert!(
        content.contains("#' Extract text from raw bytes"),
        "title line derived from Rust doc comment must be emitted:\n{content}"
    );
    assert!(
        content.contains("#' Detect the MIME type of the input bytes"),
        "description from Rust doc comment must be emitted:\n{content}"
    );
    assert!(
        content.contains("#' @param bytes Raw vector of bytes."),
        "@param for bytes must describe the type:\n{content}"
    );
    assert!(
        content.contains("#' @param mime_type Optional character string."),
        "@param for optional string must include `Optional` qualifier:\n{content}"
    );
    assert!(
        content.contains("#' @param config Optional ExtractionConfig object"),
        "@param for named optional type must reference the named type:\n{content}"
    );
    assert!(
        content.contains("extract_bytes <- function(bytes, mime_type = NULL, config = NULL)"),
        "R wrapper must allow README-style omitted optional config/mime args:\n{content}"
    );
    assert!(
        content.contains("#' @return ExtractionResult object"),
        "@return must describe the return type:\n{content}"
    );
    assert!(
        content.contains("#' @export"),
        "@export tag must be preserved:\n{content}"
    );
    for line in content.lines() {
        if let Some(rest) = line.strip_prefix("#' @param ") {
            let mut parts = rest.splitn(2, ' ');
            let _name = parts.next();
            let description = parts.next().unwrap_or("").trim();
            assert!(
                !description.is_empty(),
                "@param line must include a description, got: {line:?}\nfull content:\n{content}"
            );
        }
    }
}

#[test]
fn extendr_wrappers_default_required_config_objects_in_r() {
    let backend = ExtendrBackend;
    let config = make_config();
    let api = ApiSurface {
        crate_name: "test_lib".to_string(),
        version: "0.1.0".to_string(),
        types: vec![TypeDef {
            name: "ExtractionConfig".to_string(),
            rust_path: "test_lib::ExtractionConfig".to_string(),
            original_rust_path: String::new(),
            fields: vec![],
            methods: vec![],
            is_opaque: false,
            is_clone: true,
            is_copy: false,
            is_trait: false,
            has_default: true,
            has_stripped_cfg_fields: false,
            is_return_type: false,
            serde_rename_all: None,
            has_serde: true,
            super_traits: vec![],
            doc: String::new(),
            cfg: None,
            binding_excluded: false,
            binding_exclusion_reason: None,
            is_variant_wrapper: false,

            has_lifetime_params: false,
            version: Default::default(),
        }],
        functions: vec![FunctionDef {
            name: "extract_bytes".to_string(),
            rust_path: "test_lib::extract_bytes".to_string(),
            original_rust_path: String::new(),
            params: vec![
                ParamDef {
                    name: "bytes".to_string(),
                    ty: TypeRef::Bytes,
                    ..Default::default()
                },
                ParamDef {
                    name: "config".to_string(),
                    ty: TypeRef::Named("ExtractionConfig".to_string()),
                    ..Default::default()
                },
            ],
            return_type: TypeRef::String,
            is_async: false,
            error_type: None,
            doc: String::new(),
            cfg: None,
            sanitized: false,
            return_sanitized: false,
            returns_ref: false,
            returns_cow: false,
            return_newtype_wrapper: None,
            binding_excluded: false,
            binding_exclusion_reason: None,
            version: Default::default(),
        }],
        enums: vec![],
        errors: vec![],
        excluded_type_paths: ::std::collections::HashMap::new(),
        excluded_trait_names: ::std::collections::HashSet::new(),
        services: vec![],
        handler_contracts: vec![],
        unsupported_public_items: Vec::new(),
    };
    let files = backend.generate_public_api(&api, &config).unwrap();
    let wrappers = files
        .iter()
        .find(|f| f.path.to_string_lossy().ends_with("extendr-wrappers.R"))
        .expect("extendr-wrappers.R must be generated");
    let content = &wrappers.content;

    assert!(
        content.contains("extract_bytes <- function(bytes, config = ExtractionConfig$default())"),
        "R wrapper must synthesize default objects instead of advertising NULL for required config:\n{content}"
    );
}

#[test]
fn extendr_wrappers_emits_placeholder_title_when_doc_is_empty() {
    // Functions with no Rust doc comment must still produce a complete
    // roxygen block — title falls back to the function name, description
    // is omitted, @param/@return lines are still emitted.
    let backend = ExtendrBackend;
    let config = make_config();
    let api = make_api_surface();
    let files = backend.generate_public_api(&api, &config).unwrap();
    let wrappers = files
        .iter()
        .find(|f| f.path.to_string_lossy().ends_with("extendr-wrappers.R"))
        .expect("extendr-wrappers.R must be generated");
    let content = &wrappers.content;
    assert!(
        content.contains("#' process"),
        "fallback title (function name) must be emitted when doc is empty:\n{content}"
    );
    assert!(
        content.contains("#' @return Character string."),
        "@return must be emitted even without a doc comment:\n{content}"
    );
}

#[test]
fn namespace_exports_functions_and_classes() {
    let backend = ExtendrBackend;
    let config = make_config();
    let api = make_api_surface();
    let files = backend.generate_public_api(&api, &config).unwrap();
    let namespace = files
        .iter()
        .find(|f| f.path.to_string_lossy().ends_with("NAMESPACE"))
        .expect("NAMESPACE must be generated");
    assert!(
        namespace.content.contains("export(process)"),
        "free function must be exported: {}",
        namespace.content
    );
    assert!(
        namespace.content.contains("export(Config)"),
        "class env must be exported: {}",
        namespace.content
    );
    assert!(
        namespace.content.contains("S3method(\"$\", Config)"),
        "S3 dispatch operator must be registered: {}",
        namespace.content
    );
    // NAMESPACE must use the bare `useDynLib(...)` directive — the roxygen2
    // form (`#' @useDynLib ...`) is silently ignored by R when placed in
    // NAMESPACE, leaving the .so unloaded and every `.Call` unresolved.
    assert!(
        namespace.content.contains("useDynLib(testlib, .registration = TRUE)"),
        "NAMESPACE must contain bare useDynLib directive: {}",
        namespace.content
    );
    assert!(
        !namespace.content.contains("#' @useDynLib"),
        "NAMESPACE must not contain roxygen2 useDynLib form: {}",
        namespace.content
    );
}

fn make_instance_method(name: &str) -> MethodDef {
    MethodDef {
        name: name.to_string(),
        params: vec![],
        return_type: TypeRef::Primitive(PrimitiveType::Bool),
        is_async: false,
        is_static: false,
        error_type: None,
        doc: String::new(),
        sanitized: false,
        receiver: Some(ReceiverKind::Ref),
        trait_source: None,
        returns_ref: false,
        returns_cow: false,
        return_newtype_wrapper: None,
        has_default_impl: false,
        binding_excluded: false,
        binding_exclusion_reason: None,
        version: Default::default(),
    }
}

fn make_api_with_instance_method() -> ApiSurface {
    ApiSurface {
        crate_name: "test_lib".to_string(),
        version: "0.1.0".to_string(),
        types: vec![TypeDef {
            name: "HeaderMetadata".to_string(),
            rust_path: "test_lib::HeaderMetadata".to_string(),
            original_rust_path: String::new(),
            fields: vec![make_field("level", TypeRef::Primitive(PrimitiveType::U32), false)],
            methods: vec![make_instance_method("is_valid")],
            is_opaque: false,
            is_clone: true,
            is_copy: false,
            is_trait: false,
            has_default: false,
            has_stripped_cfg_fields: false,
            is_return_type: false,
            serde_rename_all: None,
            has_serde: false,
            super_traits: vec![],
            doc: String::new(),
            cfg: None,
            binding_excluded: false,
            binding_exclusion_reason: None,
            is_variant_wrapper: false,

            has_lifetime_params: false,
            version: Default::default(),
        }],
        functions: vec![],
        enums: vec![],
        errors: vec![],
        excluded_type_paths: ::std::collections::HashMap::new(),
        excluded_trait_names: ::std::collections::HashSet::new(),
        services: vec![],
        handler_contracts: vec![],
        unsupported_public_items: Vec::new(),
    }
}

#[test]
fn extendr_wrappers_emits_s3_generic_and_method_for_instance_methods() {
    // Regression: bare env-class form `meta$is_valid()` leaks the extendr implementation
    // detail. Generate an S3 generic + class method so callers can write `is_valid(meta)`.
    let backend = ExtendrBackend;
    let config = make_config();
    let api = make_api_with_instance_method();
    let files = backend.generate_public_api(&api, &config).unwrap();
    let wrappers = files
        .iter()
        .find(|f| f.path.to_string_lossy().ends_with("extendr-wrappers.R"))
        .expect("extendr-wrappers.R must be generated");
    let content = &wrappers.content;
    assert!(
        content.contains("is_valid <- function(x, ...) UseMethod(\"is_valid\")"),
        "S3 generic must be emitted for instance methods:\n{content}"
    );
    assert!(
        content.contains("is_valid.HeaderMetadata <- function(x, ...) x$is_valid(...)"),
        "S3 class method must forward to the env-class binding:\n{content}"
    );
}

#[test]
fn extendr_wrappers_skips_s3_wrappers_for_static_methods() {
    // Static factories like `default` / `from_json` are accessed off the class env
    // (`Type$from_json(json)`) — no `self`, no S3 forwarding needed.
    let backend = ExtendrBackend;
    let config = make_config();
    let mut api = make_api_with_instance_method();
    let static_method = MethodDef {
        is_static: true,
        ..make_instance_method("default")
    };
    api.types[0].methods.push(static_method);
    let files = backend.generate_public_api(&api, &config).unwrap();
    let wrappers = files
        .iter()
        .find(|f| f.path.to_string_lossy().ends_with("extendr-wrappers.R"))
        .expect("extendr-wrappers.R must be generated");
    let content = &wrappers.content;
    assert!(
        !content.contains("default <- function(x, ...) UseMethod"),
        "must not emit S3 generic for static methods:\n{content}"
    );
    assert!(
        !content.contains("default.HeaderMetadata <-"),
        "must not emit S3 class method for static methods:\n{content}"
    );
}

#[test]
fn extendr_wrappers_emits_one_generic_per_unique_method_name() {
    // Two classes both expose `is_valid` — only one generic should be emitted to
    // avoid `UseMethod` being clobbered by a second definition.
    let backend = ExtendrBackend;
    let config = make_config();
    let mut api = make_api_with_instance_method();
    let second_type = TypeDef {
        name: "LinkMetadata".to_string(),
        rust_path: "test_lib::LinkMetadata".to_string(),
        methods: vec![make_instance_method("is_valid")],
        ..api.types[0].clone()
    };
    api.types.push(second_type);
    let files = backend.generate_public_api(&api, &config).unwrap();
    let wrappers = files
        .iter()
        .find(|f| f.path.to_string_lossy().ends_with("extendr-wrappers.R"))
        .expect("extendr-wrappers.R must be generated");
    let content = &wrappers.content;
    let generic_count = content.matches("is_valid <- function(x, ...) UseMethod").count();
    assert_eq!(
        generic_count, 1,
        "exactly one S3 generic per unique method name, got {generic_count}:\n{content}"
    );
    assert!(
        content.contains("is_valid.HeaderMetadata <- function(x, ...) x$is_valid(...)"),
        "S3 method for HeaderMetadata must be emitted:\n{content}"
    );
    assert!(
        content.contains("is_valid.LinkMetadata <- function(x, ...) x$is_valid(...)"),
        "S3 method for LinkMetadata must be emitted:\n{content}"
    );
}

#[test]
fn namespace_exports_s3_generics_and_methods_for_instance_methods() {
    // S3 generics + class methods emitted into extendr-wrappers.R need matching
    // `export(name)` + `S3method(name, Type)` NAMESPACE entries. Without them R
    // refuses to dispatch `is_valid(meta)` even though the function is loaded.
    let backend = ExtendrBackend;
    let config = make_config();
    let api = make_api_with_instance_method();
    let files = backend.generate_public_api(&api, &config).unwrap();
    let namespace = files
        .iter()
        .find(|f| f.path.to_string_lossy().ends_with("NAMESPACE"))
        .expect("NAMESPACE must be generated");
    let content = &namespace.content;
    assert!(
        content.contains("export(is_valid)"),
        "S3 generic must be exported by name: {content}"
    );
    assert!(
        content.contains("S3method(is_valid, HeaderMetadata)"),
        "S3 class method must be registered: {content}"
    );
}

#[test]
fn extendr_wrappers_emits_roxygen_class_block_with_field_lines_for_struct() {
    // Class envs (`Type <- new.env(parent = emptyenv())`) must carry a roxygen2
    // block derived from the struct's Rust doc comment: a title line, an
    // optional description, one `@field` per public field (with the field's
    // own doc comment as the description), and an `@export` tag.
    let backend = ExtendrBackend;
    let config = make_config();
    let api = ApiSurface {
        crate_name: "test_lib".to_string(),
        version: "0.1.0".to_string(),
        types: vec![TypeDef {
            name: "ServerConfig".to_string(),
            rust_path: "test_lib::ServerConfig".to_string(),
            original_rust_path: String::new(),
            fields: vec![
                FieldDef {
                    doc: "TCP port the server binds to.".to_string(),
                    ..make_field("port", TypeRef::Primitive(PrimitiveType::U32), false)
                },
                FieldDef {
                    doc: "Maximum number of in-flight requests.\n\nApplies to all listener sockets.".to_string(),
                    ..make_field("max_connections", TypeRef::Primitive(PrimitiveType::U32), false)
                },
            ],
            methods: vec![],
            is_opaque: false,
            is_clone: true,
            is_copy: false,
            is_trait: false,
            has_default: false,
            has_stripped_cfg_fields: false,
            is_return_type: false,
            serde_rename_all: None,
            has_serde: false,
            super_traits: vec![],
            doc: "Server configuration.\n\nHolds tunable parameters for the network listener.".to_string(),
            cfg: None,
            binding_excluded: false,
            binding_exclusion_reason: None,
            is_variant_wrapper: false,

            has_lifetime_params: false,
            version: Default::default(),
        }],
        functions: vec![],
        enums: vec![],
        errors: vec![],
        excluded_type_paths: ::std::collections::HashMap::new(),
        excluded_trait_names: ::std::collections::HashSet::new(),
        services: vec![],
        handler_contracts: vec![],
        unsupported_public_items: Vec::new(),
    };
    let files = backend.generate_public_api(&api, &config).unwrap();
    let wrappers = files
        .iter()
        .find(|f| f.path.to_string_lossy().ends_with("extendr-wrappers.R"))
        .expect("extendr-wrappers.R must be generated");
    let content = &wrappers.content;
    assert!(
        content.contains("#' Server configuration"),
        "class title from struct doc must be emitted:\n{content}"
    );
    assert!(
        content.contains("#' Holds tunable parameters for the network listener."),
        "class description must be emitted:\n{content}"
    );
    assert!(
        content.contains("#' @field port TCP port the server binds to."),
        "@field with single-line doc must be emitted:\n{content}"
    );
    assert!(
        content.contains("#' @field max_connections Maximum number of in-flight requests."),
        "@field must collapse multi-paragraph doc to the first paragraph:\n{content}"
    );
    // The class env line must follow the roxygen block.
    assert!(
        content.contains("ServerConfig <- new.env(parent = emptyenv())"),
        "class env definition must still be emitted:\n{content}"
    );
}

#[test]
fn extendr_wrappers_emits_param_doc_from_arguments_section_for_function() {
    // When a free function's Rust doc carries a `# Arguments` section, the
    // per-param description from the bullet list must override the default
    // type-based description on the `#' @param` line, and the `# Returns`
    // section must drive the `#' @return` line.
    let backend = ExtendrBackend;
    let config = make_config();
    let api = ApiSurface {
            crate_name: "test_lib".to_string(),
            version: "0.1.0".to_string(),
            types: vec![],
            functions: vec![FunctionDef {
                name: "render".to_string(),
                rust_path: "test_lib::render".to_string(),
                original_rust_path: String::new(),
                params: vec![ParamDef {
                    name: "template".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,
            vec_inner_is_ref: false,
                    map_is_btree: false,
                    core_wrapper: crate::core::ir::CoreWrapper::None,
                }],
                return_type: TypeRef::String,
                is_async: false,
                error_type: None,
                doc: "Render a template to a string.\n\n# Arguments\n\n* `template` - Mustache template source.\n\n# Returns\n\nThe fully interpolated output.".to_string(),
                cfg: None,
                sanitized: false,
                return_sanitized: false,
                returns_ref: false,
                returns_cow: false,
                return_newtype_wrapper: None,
                binding_excluded: false,
                binding_exclusion_reason: None,
            version: Default::default(),
            }],
            enums: vec![],
            errors: vec![],
            excluded_type_paths: ::std::collections::HashMap::new(),
            excluded_trait_names: ::std::collections::HashSet::new(),
            services: vec![],
            handler_contracts: vec![],
                unsupported_public_items: Vec::new(),
};
    let files = backend.generate_public_api(&api, &config).unwrap();
    let wrappers = files
        .iter()
        .find(|f| f.path.to_string_lossy().ends_with("extendr-wrappers.R"))
        .expect("extendr-wrappers.R must be generated");
    let content = &wrappers.content;
    assert!(
        content.contains("#' @param template Mustache template source."),
        "@param must use description from `# Arguments` bullet:\n{content}"
    );
    assert!(
        content.contains("#' @return The fully interpolated output."),
        "@return must use prose from `# Returns` section:\n{content}"
    );
    // The raw `# Arguments` / `# Returns` headings must not leak into the
    // description body now that they're rendered as roxygen tags.
    assert!(
        !content.contains("#' # Arguments"),
        "raw `# Arguments` heading must not appear in roxygen output:\n{content}"
    );
    assert!(
        !content.contains("#' # Returns"),
        "raw `# Returns` heading must not appear in roxygen output:\n{content}"
    );
}

#[test]
fn extendr_wrappers_emits_roxygen_block_for_flat_data_enum_with_variant_fields() {
    // Flat data enums (single-field tuple variants) are surfaced in R as
    // class envs with one scalar field per variant. The class env must
    // carry roxygen with one `@field` per variant carrying the variant's
    // Rust doc as description.
    let backend = ExtendrBackend;
    let config = make_config();
    let api = ApiSurface {
        crate_name: "test_lib".to_string(),
        version: "0.1.0".to_string(),
        types: vec![],
        functions: vec![],
        enums: vec![EnumDef {
            name: "Payload".to_string(),
            rust_path: "test_lib::Payload".to_string(),
            original_rust_path: String::new(),
            variants: vec![
                EnumVariant {
                    name: "Text".to_string(),
                    fields: vec![make_field("inner", TypeRef::String, false)],
                    doc: "UTF-8 encoded text payload.".to_string(),
                    is_default: false,
                    serde_rename: None,
                    is_tuple: true,
                    binding_excluded: false,
                    binding_exclusion_reason: None,
                    originally_had_data_fields: false,
                    cfg: None,
                    version: Default::default(),
                },
                EnumVariant {
                    name: "Binary".to_string(),
                    fields: vec![make_field("inner", TypeRef::String, false)],
                    doc: "Base64-encoded binary payload.".to_string(),
                    is_default: false,
                    serde_rename: None,
                    is_tuple: true,
                    binding_excluded: false,
                    binding_exclusion_reason: None,
                    originally_had_data_fields: false,
                    cfg: None,
                    version: Default::default(),
                },
            ],
            doc: "Wire payload variants.".to_string(),
            cfg: None,
            is_copy: false,
            has_serde: false,
            serde_tag: None,
            serde_untagged: false,
            serde_rename_all: None,
            binding_excluded: false,
            binding_exclusion_reason: None,
            excluded_variants: vec![],
            version: Default::default(),
        }],
        errors: vec![],
        excluded_type_paths: ::std::collections::HashMap::new(),
        excluded_trait_names: ::std::collections::HashSet::new(),
        services: vec![],
        handler_contracts: vec![],
        unsupported_public_items: Vec::new(),
    };
    let files = backend.generate_public_api(&api, &config).unwrap();
    let wrappers = files
        .iter()
        .find(|f| f.path.to_string_lossy().ends_with("extendr-wrappers.R"))
        .expect("extendr-wrappers.R must be generated");
    let content = &wrappers.content;
    assert!(
        content.contains("#' Wire payload variants"),
        "enum title from Rust doc must be emitted:\n{content}"
    );
    assert!(
        content.contains("#' @field Text UTF-8 encoded text payload."),
        "@field per variant must carry the variant's doc:\n{content}"
    );
    assert!(
        content.contains("#' @field Binary Base64-encoded binary payload."),
        "every variant must produce a `@field` line:\n{content}"
    );
    assert!(
        content.contains("Payload <- new.env(parent = emptyenv())"),
        "enum class env must still be emitted:\n{content}"
    );
}
