//! Dynamic types emitter for `dynamic/types/*.rs`.
//!
//! Consumes a [`CanonicalSpec`] (the union of every shipped NiFi version's
//! spec) and emits per-tag union DTOs where fields absent in any version are
//! wrapped in `Option<T>`, plus sibling helper enums for inline-enum fields.
//!
//! Mechanical pieces shared with the single-version static emitter
//! (`emit::types`) — the tag-reference walk and the `mod.rs` skeleton —
//! live in [`crate::emit::types_shared`]. The per-type emission logic
//! remains distinct because the output shape is intentionally different:
//! this emitter unions variants and fields across versions, the static one
//! passes through the single version under compilation.

use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};

use crate::canonical::CanonicalSpec;
use crate::emit::common::{InlineEnumMode, field_type_to_rust};
use crate::emit::types_shared;
use crate::parser::{ApiSpec, Field, FieldType, TypeKind};
use crate::util::{escape_keyword, wire_to_pascal};

/// Intermediate representation of a merged type across all API versions.
enum MergedType {
    Dto {
        /// Union of all fields across versions, preserving insertion order via BTreeMap on rust_name.
        fields: BTreeMap<String, Field>,
        /// Fields that exist in every version with the same Rust type string.
        /// These can be emitted without extra Option wrapping.
        universal_fields: BTreeSet<String>,
        /// Doc comment from the first version's TypeDef, if present.
        doc: Option<String>,
    },
    Entity {
        field: String,
        inner: String,
    },
    StringEnum {
        variants: BTreeSet<String>,
    },
}

/// Sibling helper emitted alongside a DTO when one of its fields is an inline
/// string enum. The parent field stays `Option<String>` (wire-tolerant); this
/// type gives callers a typed companion via `as_str` / `from_wire`.
pub(super) struct InlineEnumHelper {
    pub(super) parent_dto: String,
    pub(super) parent_field: String,
    pub(super) field_doc: Option<String>,
    /// `None` ⇒ helper lands in `common.rs`; `Some(tag)` ⇒ goes to that tag's file.
    pub(super) owner_tag: Option<String>,
    /// Union of variants across all versions. `BTreeSet` ⇒ deterministic, sorted.
    pub(super) variants: BTreeSet<String>,
}

/// Insert or merge an inline-enum helper. Panics with an actionable message
/// when two distinct parent DTOs would produce the same helper name.
pub(super) fn merge_inline_enum(
    inline_enums: &mut BTreeMap<String, InlineEnumHelper>,
    helper_name: String,
    parent_dto: &str,
    parent_field: &str,
    field_doc: Option<String>,
    owner_tag: Option<String>,
    variants: Vec<String>,
) {
    match inline_enums.get_mut(&helper_name) {
        Some(existing) if existing.parent_dto != parent_dto => {
            panic!(
                "nifi-openapi-gen: inline-enum helper name collision\n  \
                 helper: {helper_name}\n  \
                 parents: [{}.{} (tag={:?}), {}.{} (tag={:?})]\n\
                 A DTO-field override table does not exist yet — rename one of the \
                 conflicting field declarations in the OpenAPI spec, or open an \
                 issue to add field-level naming_overrides support.",
                existing.parent_dto,
                existing.parent_field,
                existing.owner_tag,
                parent_dto,
                parent_field,
                owner_tag,
            );
        }
        Some(existing) => {
            for v in variants {
                existing.variants.insert(v);
            }
        }
        None => {
            let mut variant_set = BTreeSet::new();
            for v in variants {
                variant_set.insert(v);
            }
            inline_enums.insert(
                helper_name,
                InlineEnumHelper {
                    parent_dto: parent_dto.to_string(),
                    parent_field: parent_field.to_string(),
                    field_doc,
                    owner_tag,
                    variants: variant_set,
                },
            );
        }
    }
}

/// Entry point: merge type definitions from all API versions in the canonical spec
/// and emit per-tag Rust source files containing union types.
///
/// Returns a list of `(filename, content)` pairs to write into `src/dynamic/types/`:
/// - `"mod.rs"` — module declarations + glob re-exports
/// - `"common.rs"` — types referenced by 0 or 2+ tags (across all versions)
/// - `"<tag>.rs"` — one file per tag for types used exclusively by that tag
pub fn emit_types(canonical: &CanonicalSpec) -> Vec<(String, String)> {
    let specs: Vec<(&str, &ApiSpec)> = canonical
        .per_version_specs
        .iter()
        .map(|(v, s)| (v.as_str(), s))
        .collect();
    emit_types_from_specs(&specs)
}

/// Merge type definitions from all API versions and emit per-tag Rust source files
/// containing union types where all fields are `Option<T>`.
///
/// Returns a list of `(filename, content)` pairs to write into `src/dynamic/types/`:
/// - `"mod.rs"` — module declarations + glob re-exports
/// - `"common.rs"` — types referenced by 0 or 2+ tags (across all versions)
/// - `"<tag>.rs"` — one file per tag for types used exclusively by that tag
fn emit_types_from_specs(specs: &[(&str, &ApiSpec)]) -> Vec<(String, String)> {
    let type_to_tags = build_type_to_tags(specs);
    let (merged, inline_enums) = merge_all_types(specs, &type_to_tags);

    // Assign each merged type to exactly one tag or common.
    // BTreeMap for deterministic file order.
    let mut tag_types: BTreeMap<String, Vec<&str>> = BTreeMap::new();
    let mut common_names: Vec<&str> = vec![];

    for name in merged.keys() {
        match type_to_tags.get(name.as_str()) {
            Some(tags) if tags.len() == 1 => {
                let tag = tags.iter().next().unwrap().clone();
                tag_types.entry(tag).or_default().push(name.as_str());
            }
            _ => common_names.push(name.as_str()),
        }
    }

    let mut files: Vec<(String, String)> = vec![];

    // common.rs
    files.push((
        "common.rs".into(),
        emit_dynamic_type_file(&common_names, &merged, &inline_enums, None),
    ));

    // per-tag files
    let tag_names: Vec<String> = tag_types.keys().cloned().collect();
    for tag_name in &tag_names {
        let names = tag_types.get(tag_name).unwrap();
        files.push((
            format!("{tag_name}.rs"),
            emit_dynamic_type_file(names, &merged, &inline_enums, Some(tag_name)),
        ));
    }

    // mod.rs
    files.push(("mod.rs".into(), types_shared::emit_mod_rs(&tag_names)));

    files
}

fn build_type_to_tags(specs: &[(&str, &ApiSpec)]) -> HashMap<String, HashSet<String>> {
    let mut type_to_tags: HashMap<String, HashSet<String>> = HashMap::new();
    for (_version, spec) in specs {
        for tag in &spec.tags {
            for type_name in types_shared::types_referenced_by_tag(tag) {
                type_to_tags
                    .entry(type_name)
                    .or_default()
                    .insert(tag.module_name.clone());
            }
        }
    }
    type_to_tags
}

fn emit_dynamic_type_file(
    names: &[&str],
    merged: &BTreeMap<String, MergedType>,
    inline_enums: &BTreeMap<String, InlineEnumHelper>,
    owner_tag: Option<&str>,
) -> String {
    let mut out = String::new();
    let tag_label = owner_tag.unwrap_or("shared / cross-tag");
    out.push_str(&format!(
        "//! Generated dynamic types for the `{tag_label}` API tag.\n"
    ));
    out.push_str("//!\n");
    out.push_str("//! Includes union DTOs, entity wrappers, standalone string enums, and\n");
    out.push_str("//! field-level enum helpers (`Option<String>` + typed companion).\n");
    out.push_str("#![allow(dead_code, private_interfaces, unused_imports)]\n\n");
    out.push_str("use serde::{Deserialize, Serialize};\n");
    out.push_str("use super::*;\n\n");

    for name in names {
        let mt = match merged.get(*name) {
            Some(m) => m,
            None => continue,
        };
        emit_merged_type(&mut out, name, mt);
    }

    // Helper enums whose owner_tag matches this file.
    let mut helpers_for_file: Vec<(&String, &InlineEnumHelper)> = inline_enums
        .iter()
        .filter(|(_, h)| h.owner_tag.as_deref() == owner_tag)
        .collect();
    helpers_for_file.sort_by(|a, b| a.0.cmp(b.0));
    let first_helper_doc_marker = helpers_for_file
        .first()
        .map(|(_, h)| format!("/// Wire values for `{}::{}`", h.parent_dto, h.parent_field));

    for (name, helper) in helpers_for_file {
        emit_inline_enum_helper(&mut out, name, helper);
    }

    let mut formatted = crate::util::format_source(&out);

    // Add section banner before helpers if any exist. This must happen AFTER
    // format_source because prettyplease strips line comments, so we inject it
    // into the formatted output by searching for the first helper's doc comment.
    if let Some(pos) = first_helper_doc_marker.and_then(|marker| formatted.find(&marker)) {
        formatted.insert_str(
            pos,
            "// ── Inline-enum helpers ──────────────────────────────────────────────\n\n",
        );
    }

    formatted
}

/// Emit a complete inline-enum helper block: doc-comment preamble (with optional
/// field-doc passthrough), `#[non_exhaustive]` enum, `as_str`, `from_wire`,
/// `Display`, and `From<T> for String`. Variants iterate in BTreeSet order
/// (alphabetic by wire value).
pub(super) fn emit_inline_enum_helper(out: &mut String, name: &str, helper: &InlineEnumHelper) {
    // Doc preamble — always emitted
    out.push_str(&format!(
        "/// Wire values for `{}::{}`.\n",
        helper.parent_dto, helper.parent_field
    ));
    out.push_str("///\n");
    out.push_str("/// Helper enum — the parent field is `Option<String>` so unknown\n");
    out.push_str(
        "/// server values still deserialize. Convert via [`Self::as_str`] / [`Self::from_wire`].\n",
    );
    if let Some(doc) = &helper.field_doc {
        out.push_str("///\n");
        for line in doc.lines() {
            out.push_str(&format!("/// {line}\n"));
        }
    }

    // Enum
    out.push_str("#[non_exhaustive]\n");
    out.push_str("#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]\n");
    out.push_str(&format!("pub enum {name} {{\n"));
    for wire in &helper.variants {
        out.push_str(&format!("    {},\n", wire_to_pascal(wire)));
    }
    out.push_str("}\n\n");

    // impl block: as_str + from_wire
    out.push_str(&format!("impl {name} {{\n"));
    out.push_str("    /// Server wire value (e.g. `\"RUNNING\"`).\n");
    out.push_str("    pub fn as_str(&self) -> &'static str {\n");
    out.push_str("        match self {\n");
    for wire in &helper.variants {
        out.push_str(&format!(
            "            Self::{} => \"{}\",\n",
            wire_to_pascal(wire),
            wire
        ));
    }
    out.push_str("        }\n");
    out.push_str("    }\n\n");
    out.push_str("    /// Parse a wire string into the typed variant. Returns `None` for\n");
    out.push_str("    /// values not present in any supported NiFi version's spec.\n");
    out.push_str("    pub fn from_wire(s: &str) -> Option<Self> {\n");
    out.push_str("        match s {\n");
    for wire in &helper.variants {
        out.push_str(&format!(
            "            \"{}\" => Some(Self::{}),\n",
            wire,
            wire_to_pascal(wire)
        ));
    }
    out.push_str("            _ => None,\n");
    out.push_str("        }\n");
    out.push_str("    }\n");
    out.push_str("}\n\n");

    // Display impl
    out.push_str(&format!("impl std::fmt::Display for {name} {{\n"));
    out.push_str("    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n");
    out.push_str("        f.write_str(self.as_str())\n");
    out.push_str("    }\n");
    out.push_str("}\n\n");

    // From<T> for String
    out.push_str(&format!("impl From<{name}> for String {{\n"));
    out.push_str(&format!("    fn from(v: {name}) -> String {{\n"));
    out.push_str("        v.as_str().to_string()\n");
    out.push_str("    }\n");
    out.push_str("}\n\n");
}

fn emit_merged_type(out: &mut String, name: &str, mt: &MergedType) {
    match mt {
        MergedType::Dto {
            fields,
            universal_fields,
            doc,
        } => {
            if let Some(d) = doc {
                for line in d.lines() {
                    out.push_str(&format!("/// {line}\n"));
                }
            }
            out.push_str("#[non_exhaustive]\n");
            out.push_str("#[derive(Debug, Clone, Default, Deserialize, Serialize)]\n");
            out.push_str("#[serde(rename_all = \"camelCase\")]\n");
            out.push_str(&format!("pub struct {name} {{\n"));
            for field in fields.values() {
                let rust_ty = field_type_to_rust(&field.ty, name, InlineEnumMode::AsString);
                let is_universal = universal_fields.contains(&field.rust_name);
                let final_ty = if is_universal {
                    rust_ty
                } else {
                    wrap_in_option(&field.ty, &rust_ty)
                };
                if let Some(field_doc) = &field.doc {
                    for line in field_doc.lines() {
                        out.push_str(&format!("    /// {line}\n"));
                    }
                }
                if field.serde_name != field.rust_name {
                    out.push_str(&format!(
                        "    #[serde(rename = \"{}\")]\n",
                        field.serde_name
                    ));
                }
                if !is_universal {
                    out.push_str("    #[serde(default)]\n");
                }
                if final_ty.starts_with("Option<") {
                    out.push_str("    #[serde(skip_serializing_if = \"Option::is_none\")]\n");
                }
                out.push_str(&format!(
                    "    pub {}: {final_ty},\n",
                    escape_keyword(&field.rust_name)
                ));
            }
            out.push_str("}\n\n");
        }
        MergedType::Entity { field, inner } => {
            out.push_str("#[derive(Debug, Clone, Default, Deserialize, Serialize)]\n");
            out.push_str("#[serde(rename_all = \"camelCase\")]\n");
            out.push_str(&format!("pub struct {name} {{\n"));
            out.push_str(&format!(
                "    pub {}: Option<{inner}>,\n",
                escape_keyword(field)
            ));
            out.push_str("}\n\n");
        }
        MergedType::StringEnum { variants } => {
            out.push_str("#[non_exhaustive]\n");
            out.push_str("#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]\n");
            out.push_str(&format!("pub enum {name} {{\n"));
            for wire in variants {
                let pascal = wire_to_pascal(wire);
                out.push_str(&format!("    #[serde(rename = \"{wire}\")]\n"));
                out.push_str(&format!("    {pascal},\n"));
            }
            out.push_str("}\n\n");

            out.push_str(&format!("impl std::fmt::Display for {name} {{\n"));
            out.push_str(
                "    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n",
            );
            out.push_str("        match self {\n");
            for wire in variants {
                let pascal = wire_to_pascal(wire);
                out.push_str(&format!(
                    "            {name}::{pascal} => write!(f, \"{wire}\"),\n"
                ));
            }
            out.push_str("        }\n");
            out.push_str("    }\n");
            out.push_str("}\n\n");
        }
    }
}

fn merge_all_types(
    specs: &[(&str, &ApiSpec)],
    type_to_tags: &HashMap<String, HashSet<String>>,
) -> (
    BTreeMap<String, MergedType>,
    BTreeMap<String, InlineEnumHelper>,
) {
    let mut merged: BTreeMap<String, MergedType> = BTreeMap::new();
    let mut inline_enums: BTreeMap<String, InlineEnumHelper> = BTreeMap::new();

    // Pass 1: collect all fields from all versions (union)
    // Also track per-type: which versions contain this type, and per-field: presence count + type string
    let num_versions = specs.len();

    // Track: type_name -> (field_name -> (presence_count, rust_type_string, consistent))
    let mut field_presence: BTreeMap<String, BTreeMap<String, (usize, String, bool)>> =
        BTreeMap::new();
    // Track: type_name -> number of versions this type appears in
    let mut type_version_count: BTreeMap<String, usize> = BTreeMap::new();

    for (_version, spec) in specs {
        for td in &spec.all_types {
            match &td.kind {
                TypeKind::Dto => {
                    *type_version_count.entry(td.name.clone()).or_default() += 1;

                    let entry = merged
                        .entry(td.name.clone())
                        .or_insert_with(|| MergedType::Dto {
                            fields: BTreeMap::new(),
                            universal_fields: BTreeSet::new(),
                            doc: td.doc.clone(),
                        });
                    if let MergedType::Dto { fields, .. } = entry {
                        for field in &td.fields {
                            fields
                                .entry(field.rust_name.clone())
                                .or_insert_with(|| field.clone());
                        }
                    }

                    // Track field presence and type consistency
                    let type_fields = field_presence.entry(td.name.clone()).or_default();
                    for field in &td.fields {
                        let rust_ty =
                            field_type_to_rust(&field.ty, &td.name, InlineEnumMode::AsString);
                        let entry = type_fields
                            .entry(field.rust_name.clone())
                            .or_insert_with(|| (0, rust_ty.clone(), true));
                        entry.0 += 1;
                        if entry.1 != rust_ty {
                            entry.2 = false; // type mismatch
                        }
                    }

                    // Collect inline enum helpers (FieldType::Enum reached through Opt/List/Map).
                    for field in &td.fields {
                        let Some(variants) =
                            crate::emit::common::extract_inline_enum_variants(&field.ty)
                        else {
                            continue;
                        };
                        let helper_name =
                            format!("{}{}", td.name, crate::util::pascal_case(&field.rust_name));
                        let owner_tag = match type_to_tags.get(&td.name) {
                            Some(tags) if tags.len() == 1 => {
                                Some(tags.iter().next().unwrap().clone())
                            }
                            _ => None,
                        };
                        merge_inline_enum(
                            &mut inline_enums,
                            helper_name,
                            &td.name,
                            &field.rust_name,
                            field.doc.clone(),
                            owner_tag,
                            variants,
                        );
                    }
                }
                TypeKind::Entity { field, inner } => {
                    merged
                        .entry(td.name.clone())
                        .or_insert_with(|| MergedType::Entity {
                            field: field.clone(),
                            inner: inner.clone(),
                        });
                }
                TypeKind::StringEnum(variants) => {
                    let entry =
                        merged
                            .entry(td.name.clone())
                            .or_insert_with(|| MergedType::StringEnum {
                                variants: BTreeSet::new(),
                            });
                    if let MergedType::StringEnum { variants: existing } = entry {
                        for v in variants {
                            existing.insert(v.clone());
                        }
                    }
                }
            }
        }
    }

    // Pass 2: compute universal_fields for each Dto type.
    // A field is universal if:
    // 1. The type itself exists in all versions (type_version_count == num_versions)
    // 2. The field appears in every version where the type exists
    // 3. The Rust type string is consistent across all versions
    for (type_name, mt) in merged.iter_mut() {
        if let MergedType::Dto {
            universal_fields, ..
        } = mt
        {
            let versions_with_type = type_version_count.get(type_name).copied().unwrap_or(0);
            if versions_with_type < num_versions {
                // Type doesn't exist in all versions — no fields can be universal
                continue;
            }
            if let Some(fields_info) = field_presence.get(type_name) {
                for (field_name, (count, _ty, consistent)) in fields_info {
                    if *count == versions_with_type && *consistent {
                        universal_fields.insert(field_name.clone());
                    }
                }
            }
        }
    }

    (merged, inline_enums)
}

/// Wrap the type in `Option<T>` unless it is already `Option<T>` (i.e. `FieldType::Opt`).
fn wrap_in_option(ty: &FieldType, rust_str: &str) -> String {
    match ty {
        FieldType::Opt(_) => rust_str.to_string(),
        _ => format!("Option<{rust_str}>"),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::canonical::canonicalize;
    use crate::parser::{
        ApiSpec, Endpoint, Field, FieldType, HttpMethod, TagGroup, TypeDef, TypeKind,
    };
    use std::collections::HashMap;

    fn make_spec(types: Vec<TypeDef>) -> ApiSpec {
        ApiSpec {
            tags: vec![],
            all_types: types,
        }
    }

    fn make_spec_with_tags(types: Vec<TypeDef>, tags: Vec<TagGroup>) -> ApiSpec {
        ApiSpec {
            tags,
            all_types: types,
        }
    }

    fn make_tag(module_name: &str, response_types: Vec<&str>) -> TagGroup {
        let endpoints = response_types
            .into_iter()
            .map(|t| Endpoint {
                fn_name: format!("get_{t}"),
                raw_operation_id: String::new(),
                path: format!("/{t}"),
                method: HttpMethod::Get,
                doc: None,
                description: None,
                path_params: vec![],
                query_params: vec![],
                header_params: vec![],
                request_type: None,
                body_kind: None,
                body_doc: None,
                multipart_fields: Vec::new(),
                response_type: Some(t.to_string()),
                response_inner: None,
                response_field: None,
                response_kind: crate::content_type::ResponseBodyKind::Json {
                    schema_ref: t.to_string(),
                },
                success_responses: vec![],
                error_responses: vec![],
                security: None,
            })
            .collect();
        TagGroup {
            tag: module_name.to_string(),
            struct_name: format!("{}Api", module_name),
            module_name: module_name.to_string(),
            accessor_fn: format!("{module_name}_api"),
            types: vec![],
            endpoints,
        }
    }

    fn make_field(name: &str, ty: FieldType) -> Field {
        Field {
            rust_name: name.to_string(),
            serde_name: name.to_string(),
            ty,
            doc: None,
            read_only: false,
            deprecated: false,
        }
    }

    /// Concatenate all file contents from emit_types_from_specs output.
    fn all_content(files: &[(String, String)]) -> String {
        files
            .iter()
            .map(|(_, c)| c.as_str())
            .collect::<Vec<_>>()
            .join("\n")
    }

    #[test]
    fn test_merge_identical_types() {
        let td = TypeDef {
            name: "AboutDto".to_string(),
            kind: TypeKind::Dto,
            fields: vec![
                make_field("title", FieldType::Opt(Box::new(FieldType::Str))),
                make_field("version", FieldType::Opt(Box::new(FieldType::Str))),
            ],
            doc: None,
        };
        let spec_a = make_spec(vec![td.clone()]);
        let spec_b = make_spec(vec![td]);
        let files = emit_types_from_specs(&[("2.7.2", &spec_a), ("2.8.0", &spec_b)]);
        let output = all_content(&files);
        assert!(output.contains("pub struct AboutDto"));
        assert!(output.contains("pub title: Option<String>"));
        assert!(output.contains("pub version: Option<String>"));
    }

    #[test]
    fn test_merge_union_adds_new_field() {
        let td_old = TypeDef {
            name: "ProcessorDto".to_string(),
            kind: TypeKind::Dto,
            fields: vec![make_field("name", FieldType::Opt(Box::new(FieldType::Str)))],
            doc: None,
        };
        let td_new = TypeDef {
            name: "ProcessorDto".to_string(),
            kind: TypeKind::Dto,
            fields: vec![
                make_field("name", FieldType::Opt(Box::new(FieldType::Str))),
                make_field("new_field", FieldType::Opt(Box::new(FieldType::Bool))),
            ],
            doc: None,
        };
        let spec_a = make_spec(vec![td_old]);
        let spec_b = make_spec(vec![td_new]);
        let files = emit_types_from_specs(&[("2.7.2", &spec_a), ("2.8.0", &spec_b)]);
        let output = all_content(&files);
        assert!(output.contains("pub struct ProcessorDto"));
        assert!(output.contains("pub name: Option<String>"));
        assert!(output.contains("pub new_field: Option<bool>"));
    }

    #[test]
    fn test_merge_string_enum_union() {
        let td_old = TypeDef {
            name: "IncludedRegistries".to_string(),
            kind: TypeKind::StringEnum(vec!["NIFI".into(), "JVM".into()]),
            fields: vec![],
            doc: None,
        };
        let td_new = TypeDef {
            name: "IncludedRegistries".to_string(),
            kind: TypeKind::StringEnum(vec!["NIFI".into(), "JVM".into(), "VERSION_INFO".into()]),
            fields: vec![],
            doc: None,
        };
        let spec_a = make_spec(vec![td_old]);
        let spec_b = make_spec(vec![td_new]);
        let files = emit_types_from_specs(&[("2.7.2", &spec_a), ("2.8.0", &spec_b)]);
        let output = all_content(&files);
        assert!(output.contains("pub enum IncludedRegistries"));
        assert!(output.contains("Nifi"));
        assert!(output.contains("Jvm"));
        assert!(output.contains("VersionInfo"));
    }

    #[test]
    fn test_type_only_in_one_version() {
        let td = TypeDef {
            name: "NewInV2Dto".to_string(),
            kind: TypeKind::Dto,
            fields: vec![make_field("id", FieldType::Opt(Box::new(FieldType::Str)))],
            doc: None,
        };
        let spec_a = make_spec(vec![]);
        let spec_b = make_spec(vec![td]);
        let files = emit_types_from_specs(&[("2.7.2", &spec_a), ("2.8.0", &spec_b)]);
        let output = all_content(&files);
        assert!(output.contains("pub struct NewInV2Dto"));
        assert!(output.contains("pub id: Option<String>"));
    }

    fn make_field_with_doc(name: &str, ty: FieldType, doc: &str) -> Field {
        Field {
            rust_name: name.to_string(),
            serde_name: name.to_string(),
            ty,
            doc: Some(doc.to_string()),
            read_only: false,
            deprecated: false,
        }
    }

    #[test]
    fn dynamic_types_emit_non_exhaustive_on_struct() {
        let td = TypeDef {
            name: "AboutDto".to_string(),
            kind: TypeKind::Dto,
            fields: vec![
                make_field_with_doc(
                    "version",
                    FieldType::Opt(Box::new(FieldType::Str)),
                    "The NiFi version",
                ),
                make_field("title", FieldType::Str),
            ],
            doc: Some("Information about the NiFi instance".to_string()),
        };
        let spec = make_spec(vec![td]);
        let files = emit_types_from_specs(&[("2.8.0", &spec)]);
        let output = all_content(&files);
        // non_exhaustive attribute on struct
        assert!(
            output.contains("#[non_exhaustive]"),
            "missing #[non_exhaustive] on struct"
        );
        // struct-level doc comment
        assert!(
            output.contains("/// Information about the NiFi instance"),
            "missing struct doc comment"
        );
        // field-level doc comment
        assert!(
            output.contains("/// The NiFi version"),
            "missing field doc comment"
        );
        // skip_serializing_if on Option fields
        assert!(
            output.contains("#[serde(skip_serializing_if = \"Option::is_none\")]"),
            "missing skip_serializing_if"
        );
    }

    #[test]
    fn dynamic_types_emit_non_exhaustive_on_enum() {
        let td = TypeDef {
            name: "DiagnosticLevel".to_string(),
            kind: TypeKind::StringEnum(vec!["BASIC".into(), "VERBOSE".into()]),
            fields: vec![],
            doc: None,
        };
        let spec = make_spec(vec![td]);
        let files = emit_types_from_specs(&[("2.8.0", &spec)]);
        let output = all_content(&files);
        assert!(
            output.contains("pub enum DiagnosticLevel"),
            "missing enum declaration"
        );
        assert!(
            output.contains("#[non_exhaustive]"),
            "missing #[non_exhaustive] on enum"
        );
    }

    #[test]
    fn dynamic_types_split_per_tag() {
        // AboutDto and AboutEntity are referenced by the "flow" tag
        // PositionDto is not referenced by any tag — goes to common.rs
        let about_dto = TypeDef {
            name: "AboutDto".to_string(),
            kind: TypeKind::Dto,
            fields: vec![make_field(
                "version",
                FieldType::Opt(Box::new(FieldType::Str)),
            )],
            doc: None,
        };
        let about_entity = TypeDef {
            name: "AboutEntity".to_string(),
            kind: TypeKind::Entity {
                field: "about".into(),
                inner: "AboutDto".into(),
            },
            fields: vec![],
            doc: None,
        };
        let position_dto = TypeDef {
            name: "PositionDto".to_string(),
            kind: TypeKind::Dto,
            fields: vec![make_field("x", FieldType::F64)],
            doc: None,
        };

        let flow_tag = make_tag("flow", vec!["AboutEntity"]);
        let spec = make_spec_with_tags(vec![about_dto, about_entity, position_dto], vec![flow_tag]);

        let files = emit_types_from_specs(&[("2.8.0", &spec)]);
        let filenames: Vec<&str> = files.iter().map(|(n, _)| n.as_str()).collect();

        // Must contain mod.rs, flow.rs, and common.rs
        assert!(
            filenames.contains(&"mod.rs"),
            "missing mod.rs; got: {filenames:?}"
        );
        assert!(
            filenames.contains(&"flow.rs"),
            "missing flow.rs; got: {filenames:?}"
        );
        assert!(
            filenames.contains(&"common.rs"),
            "missing common.rs; got: {filenames:?}"
        );

        let flow_content = files
            .iter()
            .find(|(n, _)| n == "flow.rs")
            .unwrap()
            .1
            .as_str();
        let common_content = files
            .iter()
            .find(|(n, _)| n == "common.rs")
            .unwrap()
            .1
            .as_str();

        // AboutEntity is exclusively referenced by flow -> flow.rs
        assert!(
            flow_content.contains("AboutEntity"),
            "flow.rs should contain AboutEntity"
        );
        // PositionDto is not referenced by any tag -> common.rs
        assert!(
            common_content.contains("PositionDto"),
            "common.rs should contain PositionDto"
        );
        // AboutDto is not directly referenced (AboutEntity is, not AboutDto itself) -> common.rs
        assert!(
            common_content.contains("AboutDto"),
            "common.rs should contain AboutDto"
        );
    }

    // --- Phase 4a canonical entry point test ---

    fn spec_with_about() -> ApiSpec {
        ApiSpec {
            tags: vec![TagGroup {
                tag: "Flow".to_string(),
                struct_name: "Flow".to_string(),
                module_name: "flow".to_string(),
                accessor_fn: "flow".to_string(),
                types: vec![],
                endpoints: vec![Endpoint {
                    method: HttpMethod::Get,
                    path: "/flow/about".to_string(),
                    fn_name: "get_about_info".to_string(),
                    raw_operation_id: "getAboutInfo".to_string(),
                    doc: None,
                    description: None,
                    path_params: vec![],
                    request_type: None,
                    body_kind: None,
                    body_doc: None,
                    multipart_fields: Vec::new(),
                    response_type: Some("AboutEntity".to_string()),
                    response_inner: Some("AboutDto".to_string()),
                    response_field: Some("about".to_string()),
                    response_kind: crate::content_type::ResponseBodyKind::Json {
                        schema_ref: "AboutEntity".to_string(),
                    },
                    query_params: vec![],
                    header_params: vec![],
                    success_responses: vec![],
                    error_responses: vec![],
                    security: None,
                }],
            }],
            all_types: vec![
                TypeDef {
                    name: "AboutEntity".to_string(),
                    kind: TypeKind::Entity {
                        field: "about".to_string(),
                        inner: "AboutDto".to_string(),
                    },
                    fields: vec![],
                    doc: None,
                },
                TypeDef {
                    name: "AboutDto".to_string(),
                    kind: TypeKind::Dto,
                    fields: vec![Field {
                        rust_name: "version".to_string(),
                        serde_name: "version".to_string(),
                        ty: FieldType::Opt(Box::new(FieldType::Str)),
                        doc: None,
                        read_only: false,
                        deprecated: false,
                    }],
                    doc: None,
                },
            ],
        }
    }

    #[test]
    fn merge_collects_inline_enum_helper_from_field() {
        let td = TypeDef {
            name: "ScheduleComponentsEntity".to_string(),
            kind: TypeKind::Dto,
            fields: vec![Field {
                rust_name: "state".to_string(),
                serde_name: "state".to_string(),
                ty: FieldType::Opt(Box::new(FieldType::Enum(vec![
                    "RUNNING".into(),
                    "STOPPED".into(),
                ]))),
                doc: Some("The desired state".to_string()),
                read_only: false,
                deprecated: false,
            }],
            doc: None,
        };
        let spec = make_spec(vec![td]);
        let type_to_tags = HashMap::new();
        let (_merged, inline_enums) = merge_all_types(&[("2.8.0", &spec)], &type_to_tags);
        let helper = inline_enums
            .get("ScheduleComponentsEntityState")
            .expect("helper not collected");
        assert_eq!(helper.parent_dto, "ScheduleComponentsEntity");
        assert_eq!(helper.parent_field, "state");
        assert_eq!(
            helper.variants.iter().cloned().collect::<Vec<_>>(),
            vec!["RUNNING".to_string(), "STOPPED".to_string()],
        );
        assert_eq!(helper.field_doc.as_deref(), Some("The desired state"));
        assert_eq!(helper.owner_tag, None); // empty type_to_tags ⇒ goes to common.rs
    }

    #[test]
    fn merge_unions_inline_enum_variants_across_versions() {
        let make = |variants: Vec<String>| TypeDef {
            name: "Dto".to_string(),
            kind: TypeKind::Dto,
            fields: vec![Field {
                rust_name: "state".to_string(),
                serde_name: "state".to_string(),
                ty: FieldType::Opt(Box::new(FieldType::Enum(variants))),
                doc: None,
                read_only: false,
                deprecated: false,
            }],
            doc: None,
        };
        let spec_a = make_spec(vec![make(vec!["A".into(), "B".into()])]);
        let spec_b = make_spec(vec![make(vec!["B".into(), "C".into()])]);
        let type_to_tags = HashMap::new();
        let (_merged, inline_enums) =
            merge_all_types(&[("2.7.2", &spec_a), ("2.8.0", &spec_b)], &type_to_tags);
        let helper = inline_enums.get("DtoState").expect("helper not collected");
        assert_eq!(
            helper.variants.iter().cloned().collect::<Vec<_>>(),
            vec!["A".to_string(), "B".to_string(), "C".to_string()],
        );
    }

    #[test]
    fn merge_resolves_owner_tag_from_single_referencing_tag() {
        let td_single = TypeDef {
            name: "Dto".to_string(),
            kind: TypeKind::Dto,
            fields: vec![Field {
                rust_name: "state".to_string(),
                serde_name: "state".to_string(),
                ty: FieldType::Opt(Box::new(FieldType::Enum(vec!["A".into()]))),
                doc: None,
                read_only: false,
                deprecated: false,
            }],
            doc: None,
        };
        let td_shared = TypeDef {
            name: "Shared".to_string(),
            kind: TypeKind::Dto,
            fields: vec![Field {
                rust_name: "kind".to_string(),
                serde_name: "kind".to_string(),
                ty: FieldType::Opt(Box::new(FieldType::Enum(vec!["X".into()]))),
                doc: None,
                read_only: false,
                deprecated: false,
            }],
            doc: None,
        };
        let spec = make_spec(vec![td_single, td_shared]);
        let mut type_to_tags: HashMap<String, HashSet<String>> = HashMap::new();
        type_to_tags
            .entry("Dto".to_string())
            .or_default()
            .insert("flow".to_string());
        // "Shared" referenced by two tags ⇒ should land in common.rs.
        type_to_tags
            .entry("Shared".to_string())
            .or_default()
            .insert("flow".to_string());
        type_to_tags
            .entry("Shared".to_string())
            .or_default()
            .insert("processors".to_string());

        let (_merged, inline_enums) = merge_all_types(&[("2.8.0", &spec)], &type_to_tags);

        let dto_state = inline_enums
            .get("DtoState")
            .expect("DtoState helper missing");
        assert_eq!(
            dto_state.owner_tag.as_deref(),
            Some("flow"),
            "single-tag-referenced parent should set owner_tag = Some(tag)"
        );

        let shared_kind = inline_enums
            .get("SharedKind")
            .expect("SharedKind helper missing");
        assert_eq!(
            shared_kind.owner_tag, None,
            "multi-tag-referenced parent should set owner_tag = None"
        );
    }

    #[test]
    #[should_panic(expected = "inline-enum helper name collision")]
    fn merge_panics_when_two_dtos_resolve_to_same_helper_name() {
        // Drive a collision by calling merge_inline_enum directly with two
        // distinct parent_dto values for the same helper_name. Production
        // code computes helper_name = format!("{ParentDto}{PascalCase(field)}"),
        // so two DTOs would only collide if a naming_overrides rename forced it —
        // but the panic logic is independent of how the collision arose.
        let mut inline_enums: BTreeMap<String, InlineEnumHelper> = BTreeMap::new();
        inline_enums.insert(
            "FooState".to_string(),
            InlineEnumHelper {
                parent_dto: "Foo".into(),
                parent_field: "state".into(),
                field_doc: None,
                owner_tag: None,
                variants: BTreeSet::from(["A".to_string()]),
            },
        );
        super::merge_inline_enum(
            &mut inline_enums,
            "FooState".to_string(),
            "Bar", // different parent — should panic
            "state",
            None,
            None,
            vec!["B".to_string()],
        );
    }

    #[test]
    fn emit_types_produces_per_tag_files() {
        let canonical = canonicalize(&[("2.8.0".to_string(), spec_with_about())]);
        let files = emit_types(&canonical);
        let names: Vec<&str> = files.iter().map(|(n, _)| n.as_str()).collect();
        assert!(names.contains(&"mod.rs"));
        assert!(names.contains(&"common.rs") || names.iter().any(|n| n.starts_with("flow")));
        let all_content: String = files.iter().map(|(_, c)| c.as_str()).collect();
        assert!(all_content.contains("AboutDto"));
        assert!(all_content.contains("version"));
    }

    #[test]
    fn emit_inline_enum_helper_renders_full_block() {
        let helper = InlineEnumHelper {
            parent_dto: "ScheduleComponentsEntity".into(),
            parent_field: "state".into(),
            field_doc: Some("The desired state of the descendant components".into()),
            owner_tag: Some("flow".into()),
            variants: BTreeSet::from([
                "DISABLED".to_string(),
                "ENABLED".to_string(),
                "RUNNING".to_string(),
                "STOPPED".to_string(),
            ]),
        };
        let mut out = String::new();
        super::emit_inline_enum_helper(&mut out, "ScheduleComponentsEntityState", &helper);
        // doc preamble
        assert!(out.contains("/// Wire values for `ScheduleComponentsEntity::state`."));
        assert!(out.contains("/// Helper enum"));
        // field-doc passthrough
        assert!(out.contains("/// The desired state of the descendant components"));
        // enum + non_exhaustive
        assert!(out.contains("#[non_exhaustive]"));
        assert!(out.contains("pub enum ScheduleComponentsEntityState {"));
        // variants in BTreeSet (alphabetic) order
        let disabled = out.find("Disabled,").unwrap();
        let enabled = out.find("Enabled,").unwrap();
        let running = out.find("Running,").unwrap();
        let stopped = out.find("Stopped,").unwrap();
        assert!(disabled < enabled && enabled < running && running < stopped);
        // as_str
        assert!(out.contains("pub fn as_str(&self) -> &'static str"));
        assert!(out.contains("Self::Running => \"RUNNING\""));
        // from_wire
        assert!(out.contains("pub fn from_wire(s: &str) -> Option<Self>"));
        assert!(out.contains("\"RUNNING\" => Some(Self::Running)"));
        assert!(out.contains("_ => None,"));
        // Display
        assert!(out.contains("impl std::fmt::Display for ScheduleComponentsEntityState"));
        // From<T> for String
        assert!(out.contains("impl From<ScheduleComponentsEntityState> for String"));
        // No serde derives — wire-detached
        assert!(
            !out.contains("Deserialize") && !out.contains("Serialize"),
            "helper must not have serde derives"
        );
    }

    #[test]
    fn helpers_land_in_owner_tag_file() {
        let dto = TypeDef {
            name: "ScheduleComponentsEntity".to_string(),
            kind: TypeKind::Dto,
            fields: vec![Field {
                rust_name: "state".to_string(),
                serde_name: "state".to_string(),
                ty: FieldType::Opt(Box::new(FieldType::Enum(vec![
                    "RUNNING".into(),
                    "STOPPED".into(),
                ]))),
                doc: None,
                read_only: false,
                deprecated: false,
            }],
            doc: None,
        };
        let flow_tag = make_tag("flow", vec!["ScheduleComponentsEntity"]);
        let spec = make_spec_with_tags(vec![dto], vec![flow_tag]);
        let files = emit_types_from_specs(&[("2.8.0", &spec)]);
        let flow = files
            .iter()
            .find(|(n, _)| n == "flow.rs")
            .expect("flow.rs missing")
            .1
            .as_str();
        assert!(
            flow.contains("pub enum ScheduleComponentsEntityState"),
            "helper enum should land in flow.rs"
        );
        let common = files
            .iter()
            .find(|(n, _)| n == "common.rs")
            .expect("common.rs missing")
            .1
            .as_str();
        assert!(
            !common.contains("pub enum ScheduleComponentsEntityState"),
            "helper enum should NOT land in common.rs"
        );
    }

    #[test]
    fn helper_for_unreferenced_dto_lands_in_common_rs() {
        let dto = TypeDef {
            name: "OrphanDto".to_string(),
            kind: TypeKind::Dto,
            fields: vec![Field {
                rust_name: "kind".to_string(),
                serde_name: "kind".to_string(),
                ty: FieldType::Opt(Box::new(FieldType::Enum(vec!["A".into(), "B".into()]))),
                doc: None,
                read_only: false,
                deprecated: false,
            }],
            doc: None,
        };
        let spec = make_spec(vec![dto]); // no tags reference OrphanDto
        let files = emit_types_from_specs(&[("2.8.0", &spec)]);
        let common = files
            .iter()
            .find(|(n, _)| n == "common.rs")
            .expect("common.rs missing")
            .1
            .as_str();
        assert!(common.contains("pub enum OrphanDtoKind"));
    }

    #[test]
    fn dto_field_stays_option_string_alongside_helper() {
        let dto = TypeDef {
            name: "ScheduleComponentsEntity".to_string(),
            kind: TypeKind::Dto,
            fields: vec![Field {
                rust_name: "state".to_string(),
                serde_name: "state".to_string(),
                ty: FieldType::Opt(Box::new(FieldType::Enum(vec!["RUNNING".into()]))),
                doc: None,
                read_only: false,
                deprecated: false,
            }],
            doc: None,
        };
        let spec = make_spec(vec![dto]);
        let files = emit_types_from_specs(&[("2.8.0", &spec)]);
        let output = all_content(&files);
        // The struct field MUST remain Option<String>, not Option<HelperEnum>.
        assert!(
            output.contains("pub state: Option<String>"),
            "DTO field should be Option<String>; got:\n{output}"
        );
        // Helper still emitted.
        assert!(output.contains("pub enum ScheduleComponentsEntityState"));
    }

    #[test]
    fn dynamic_type_files_have_module_level_doc() {
        let dto = TypeDef {
            name: "AboutDto".to_string(),
            kind: TypeKind::Dto,
            fields: vec![make_field(
                "version",
                FieldType::Opt(Box::new(FieldType::Str)),
            )],
            doc: None,
        };
        let flow_tag = make_tag("flow", vec!["AboutDto"]);
        let spec = make_spec_with_tags(vec![dto], vec![flow_tag]);
        let files = emit_types_from_specs(&[("2.8.0", &spec)]);
        for (name, content) in &files {
            if name == "mod.rs" {
                continue;
            }
            assert!(
                content.starts_with("//!"),
                "{name} should start with //! doc block; got:\n{}",
                &content[..content.len().min(120)]
            );
            assert!(
                content.contains("Generated dynamic types"),
                "{name} module doc missing"
            );
        }
    }

    #[test]
    fn helper_section_banner_emitted_when_helpers_present() {
        // DTO not referenced by any tag ⇒ lands in common.rs with its helper.
        let dto = TypeDef {
            name: "DtoWithHelper".to_string(),
            kind: TypeKind::Dto,
            fields: vec![Field {
                rust_name: "state".to_string(),
                serde_name: "state".to_string(),
                ty: FieldType::Opt(Box::new(FieldType::Enum(vec!["A".into()]))),
                doc: None,
                read_only: false,
                deprecated: false,
            }],
            doc: None,
        };
        let spec = make_spec(vec![dto]);
        let files = emit_types_from_specs(&[("2.8.0", &spec)]);
        let common = files
            .iter()
            .find(|(n, _)| n == "common.rs")
            .unwrap()
            .1
            .as_str();
        assert!(
            common.contains("// ── Inline-enum helpers"),
            "banner missing; got:\n{common}"
        );
    }

    #[test]
    fn helper_section_banner_skipped_when_no_helpers() {
        // DTO not referenced by any tag, no enum fields ⇒ lands in common.rs with NO helpers.
        let dto = TypeDef {
            name: "DtoNoHelper".to_string(),
            kind: TypeKind::Dto,
            fields: vec![make_field(
                "version",
                FieldType::Opt(Box::new(FieldType::Str)),
            )],
            doc: None,
        };
        let spec = make_spec(vec![dto]);
        let files = emit_types_from_specs(&[("2.8.0", &spec)]);
        let common = files
            .iter()
            .find(|(n, _)| n == "common.rs")
            .unwrap()
            .1
            .as_str();
        assert!(
            !common.contains("// ── Inline-enum helpers"),
            "banner should not appear when there are no helpers"
        );
    }
}
