Skip to main content

mig_bo4e/
engine.rs

1//! Mapping engine — loads TOML definitions and provides bidirectional conversion.
2//!
3//! Supports nested group paths (e.g., "SG4.SG5") for navigating the assembled tree
4//! and provides `map_forward` / `map_reverse` for full entity conversion.
5
6use std::collections::{BTreeMap, HashMap, HashSet};
7use std::path::Path;
8
9use mig_assembly::assembler::{
10    AssembledGroup, AssembledGroupInstance, AssembledSegment, AssembledTree,
11};
12use mig_types::schema::mig::MigSchema;
13use mig_types::segment::OwnedSegment;
14
15use crate::definition::{FieldMapping, MappingDefinition};
16use crate::error::MappingError;
17use crate::segment_structure::SegmentStructure;
18
19/// The mapping engine holds all loaded mapping definitions
20/// and provides methods for bidirectional conversion.
21pub struct MappingEngine {
22    definitions: Vec<MappingDefinition>,
23    segment_structure: Option<SegmentStructure>,
24    code_lookup: Option<crate::code_lookup::CodeLookup>,
25    /// Transaction-root SG id (e.g. "SG4" for UTILMD), when the engine is
26    /// operating at transaction scope. Child entities whose parent group
27    /// equals this id are left at the top level of the forward-mapped JSON
28    /// rather than being nested — SG4 is the transaction envelope, so
29    /// entities inside it (Marktlokation, Geschaeftspartner, …) are peers of
30    /// the transaction metadata, not sub-objects of it.
31    ///
32    /// Nesting still applies to other parent groups: e.g. Kontakt (SG2.SG3)
33    /// remains nested under Marktteilnehmer (SG2) because SG2 is a
34    /// message-level group, not the transaction root.
35    transaction_group: Option<String>,
36    /// PID currently being processed (e.g., "55002"). Used to suppress codelist
37    /// decoration of self-referential PID-identifier fields (e.g., RFF+Z13's
38    /// d1154 in PID 55002 has only "55002" as an allowed value).
39    current_pid: Option<String>,
40}
41
42impl MappingEngine {
43    /// Create an empty engine with no definitions (for unit testing).
44    pub fn new_empty() -> Self {
45        Self {
46            definitions: Vec::new(),
47            segment_structure: None,
48            code_lookup: None,
49            transaction_group: None,
50            current_pid: None,
51        }
52    }
53
54    /// Load all TOML mapping files from a directory.
55    pub fn load(dir: &Path) -> Result<Self, MappingError> {
56        let mut definitions = Vec::new();
57
58        let mut entries: Vec<_> = std::fs::read_dir(dir)?.filter_map(|e| e.ok()).collect();
59        entries.sort_by_key(|e| e.file_name());
60
61        for entry in entries {
62            let path = entry.path();
63            if path.extension().map(|e| e == "toml").unwrap_or(false) {
64                let content = std::fs::read_to_string(&path)?;
65                let def = MappingDefinition::from_toml_str(&content).map_err(|message| {
66                    MappingError::TomlParse {
67                        file: path.display().to_string(),
68                        message,
69                    }
70                })?;
71                definitions.push(def);
72            }
73        }
74
75        // Emission order comes from `meta.order` where a definition states it,
76        // and from the filename otherwise — which is what every file relies on
77        // today, via the `_30_12_` prefix convention. `sort_by_key` is stable,
78        // so definitions without the key keep their filename order exactly, and
79        // `u32::MAX` puts them after any that opt in.
80        definitions.sort_by_key(|d| d.meta.order.unwrap_or(u32::MAX));
81
82        Ok(Self {
83            definitions,
84            segment_structure: None,
85            code_lookup: None,
86            transaction_group: None,
87            current_pid: None,
88        })
89    }
90
91    /// Load message-level and transaction-level TOML mappings from separate directories.
92    ///
93    /// Returns `(message_engine, transaction_engine)` where:
94    /// - `message_engine` maps SG2/SG3/root-level definitions (shared across PIDs)
95    /// - `transaction_engine` maps SG4+ definitions (PID-specific)
96    pub fn load_split(
97        message_dir: &Path,
98        transaction_dir: &Path,
99    ) -> Result<(Self, Self), MappingError> {
100        let msg_engine = Self::load(message_dir)?;
101        let tx_engine = Self::load(transaction_dir)?;
102        Ok((msg_engine, tx_engine))
103    }
104
105    /// Load TOML mapping files from multiple directories into a single engine.
106    ///
107    /// Useful for combining message-level and transaction-level mappings
108    /// when a single engine with all definitions is needed.
109    pub fn load_merged(dirs: &[&Path]) -> Result<Self, MappingError> {
110        let mut definitions = Vec::new();
111        for dir in dirs {
112            let engine = Self::load(dir)?;
113            definitions.extend(engine.definitions);
114        }
115        Ok(Self {
116            definitions,
117            segment_structure: None,
118            code_lookup: None,
119            transaction_group: None,
120            current_pid: None,
121        })
122    }
123
124    /// Load transaction-level mappings with common template inheritance.
125    ///
126    /// 1. Loads all `.toml` from `common_dir`
127    /// 2. Filters: keeps only definitions whose `source_path` exists in the PID schema
128    /// 3. Loads all `.toml` from `pid_dir`
129    /// 4. For each PID definition, if a common definition has matching
130    ///    `(source_group, discriminator)`, replaces the common one (file-level replacement)
131    /// 5. Merges both sets: common first, then PID additions
132    pub fn load_with_common(
133        common_dir: &Path,
134        pid_dir: &Path,
135        schema_index: &crate::pid_schema_index::PidSchemaIndex,
136    ) -> Result<Self, MappingError> {
137        let mut common_defs = Self::load(common_dir)?.definitions;
138
139        // Filter common defs by schema — keep only groups that exist in this PID
140        common_defs.retain(|d| {
141            d.meta
142                .source_path
143                .as_deref()
144                .map(|sp| schema_index.has_group(sp))
145                .unwrap_or(true)
146        });
147
148        let pid_defs = Self::load(pid_dir)?.definitions;
149
150        // Build set of PID override keys: (source_group_normalized, discriminator)
151        // Normalizations applied:
152        // 1. Strip positional indices from source_group: "SG4.SG5:1" → "SG4.SG5"
153        // 2. Strip occurrence indices from discriminator: "RFF.c506.d1153=TN#0" → "RFF.c506.d1153=TN"
154        let normalize_sg = |sg: &str| -> String {
155            sg.split('.')
156                .map(|part| part.split(':').next().unwrap_or(part))
157                .collect::<Vec<_>>()
158                .join(".")
159        };
160        let pid_keys: HashSet<(String, Option<String>)> = pid_defs
161            .iter()
162            .flat_map(|d| {
163                let sg = normalize_sg(&d.meta.source_group);
164                let disc = d.meta.discriminator.clone();
165                let mut keys = vec![(sg.clone(), disc.clone())];
166                // If discriminator has occurrence index (#N), also add base form
167                if let Some(ref disc_str) = disc {
168                    if let Some(base) = disc_str.rsplit_once('#') {
169                        if base.1.chars().all(|c| c.is_ascii_digit()) {
170                            keys.push((sg, Some(base.0.to_string())));
171                        }
172                    }
173                }
174                keys
175            })
176            .collect();
177
178        // Remove common defs that are overridden by PID defs
179        common_defs.retain(|d| {
180            let key = (
181                normalize_sg(&d.meta.source_group),
182                d.meta.discriminator.clone(),
183            );
184            !pid_keys.contains(&key)
185        });
186
187        // Combine: common first, then PID
188        let mut definitions = common_defs;
189        definitions.extend(pid_defs);
190
191        Ok(Self {
192            definitions,
193            segment_structure: None,
194            code_lookup: None,
195            transaction_group: None,
196            current_pid: None,
197        })
198    }
199
200    /// Load common definitions only (no per-PID dir), filtered by schema index.
201    ///
202    /// Used for PIDs that have no per-PID directory but can use shared common/ definitions.
203    pub fn load_common_only(
204        common_dir: &Path,
205        schema_index: &crate::pid_schema_index::PidSchemaIndex,
206    ) -> Result<Self, MappingError> {
207        let mut common_defs = Self::load(common_dir)?.definitions;
208
209        // Filter common defs by schema — keep only groups that exist in this PID
210        common_defs.retain(|d| {
211            d.meta
212                .source_path
213                .as_deref()
214                .map(|sp| schema_index.has_group(sp))
215                .unwrap_or(true)
216        });
217
218        Ok(Self {
219            definitions: common_defs,
220            segment_structure: None,
221            code_lookup: None,
222            transaction_group: None,
223            current_pid: None,
224        })
225    }
226
227    /// Load message + transaction engines with common template inheritance.
228    ///
229    /// Returns `(message_engine, transaction_engine)` where the transaction engine
230    /// inherits shared templates from `common_dir`, filtered by the PID schema.
231    pub fn load_split_with_common(
232        message_dir: &Path,
233        common_dir: &Path,
234        transaction_dir: &Path,
235        schema_index: &crate::pid_schema_index::PidSchemaIndex,
236    ) -> Result<(Self, Self), MappingError> {
237        let msg_engine = Self::load(message_dir)?;
238        let tx_engine = Self::load_with_common(common_dir, transaction_dir, schema_index)?;
239        Ok((msg_engine, tx_engine))
240    }
241
242    /// Create an engine from an already-parsed list of definitions.
243    pub fn from_definitions(definitions: Vec<MappingDefinition>) -> Self {
244        Self {
245            definitions,
246            segment_structure: None,
247            code_lookup: None,
248            transaction_group: None,
249            current_pid: None,
250        }
251    }
252
253    /// Save definitions to a cache file.
254    ///
255    /// Only the `definitions` are serialized — `segment_structure` and `code_lookup`
256    /// must be re-attached after loading from cache. Paths in the definitions are
257    /// already resolved to numeric indices, so no `PathResolver` is needed at load time.
258    pub fn save_cached(&self, path: &Path) -> Result<(), MappingError> {
259        let encoded =
260            serde_json::to_vec(&self.definitions).map_err(|e| MappingError::CacheWrite {
261                path: path.display().to_string(),
262                message: e.to_string(),
263            })?;
264        if let Some(parent) = path.parent() {
265            std::fs::create_dir_all(parent)?;
266        }
267        std::fs::write(path, encoded)?;
268        Ok(())
269    }
270
271    /// Load from cache if available, otherwise fall back to TOML directory.
272    ///
273    /// When loading from cache, PathResolver is NOT needed (paths pre-resolved).
274    /// When falling back to TOML, the caller should chain `.with_path_resolver()`.
275    pub fn load_cached_or_toml(cache_path: &Path, toml_dir: &Path) -> Result<Self, MappingError> {
276        if cache_path.exists() {
277            Self::load_cached(cache_path)
278        } else {
279            Self::load(toml_dir)
280        }
281    }
282
283    /// Load definitions from a cache file.
284    ///
285    /// Returns an engine with only `definitions` populated. Attach `segment_structure`
286    /// and `code_lookup` via the builder methods if needed.
287    pub fn load_cached(path: &Path) -> Result<Self, MappingError> {
288        let bytes = std::fs::read(path)?;
289        let definitions: Vec<MappingDefinition> =
290            serde_json::from_slice(&bytes).map_err(|e| MappingError::CacheRead {
291                path: path.display().to_string(),
292                message: e.to_string(),
293            })?;
294        Ok(Self {
295            definitions,
296            segment_structure: None,
297            code_lookup: None,
298            transaction_group: None,
299            current_pid: None,
300        })
301    }
302
303    /// Attach a MIG-derived segment structure for trailing element padding.
304    ///
305    /// When set, `map_reverse` pads each segment's elements up to the
306    /// MIG-defined count, ensuring trailing empty elements are preserved.
307    pub fn with_segment_structure(mut self, ss: SegmentStructure) -> Self {
308        self.segment_structure = Some(ss);
309        self
310    }
311
312    /// Attach a code lookup for enriching code-type field values.
313    ///
314    /// When set, fields that map to code-type elements in the PID schema
315    /// are emitted as `{"code": "Z15", "meaning": "Ja"}` objects instead of plain strings.
316    pub fn with_code_lookup(mut self, cl: crate::code_lookup::CodeLookup) -> Self {
317        self.code_lookup = Some(cl);
318        self
319    }
320
321    /// Declare which PID this engine is currently processing.
322    ///
323    /// When combined with [`with_code_lookup`](Self::with_code_lookup), code
324    /// fields whose only allowed value equals the PID itself (Class C in the
325    /// 2026-04-28 audit — e.g., RFF+Z13's d1154 in PID 55002 enumerates only
326    /// `55002`) are emitted as plain strings instead of being decorated with
327    /// `{code, meaning, enum}` and a dedup-suffixed enum name.
328    pub fn with_pid(mut self, pid: impl Into<String>) -> Self {
329        self.current_pid = Some(pid.into());
330        self
331    }
332
333    /// Attach a path resolver to normalize EDIFACT ID paths to numeric indices.
334    ///
335    /// This allows TOML mapping files to use named paths like `loc.c517.d3225`
336    /// instead of numeric indices like `loc.1.0`. Resolution happens once at
337    /// load time — the engine hot path is completely unchanged.
338    pub fn with_path_resolver(mut self, resolver: crate::path_resolver::PathResolver) -> Self {
339        for def in &mut self.definitions {
340            def.normalize_paths(&resolver);
341        }
342        self
343    }
344
345    /// Declare the transaction-root SG id (e.g. `"SG4"` for UTILMD).
346    ///
347    /// When set, entities whose parent group equals this id are not nested
348    /// into their parent in the forward-mapped JSON. See the
349    /// [`transaction_group`](Self#structfield.transaction_group-1) field doc
350    /// on `MappingEngine` for the full rationale.
351    pub fn with_transaction_group(mut self, tx: impl Into<String>) -> Self {
352        self.transaction_group = Some(tx.into());
353        self
354    }
355
356    /// Add definitions to an already-built engine, keeping everything else it
357    /// carries (code lookup, segment structure, PID, transaction group).
358    ///
359    /// Used to widen a message-level engine into one flat engine over a whole
360    /// variant — what APERAK and CONTRL are converted with, since they have no
361    /// message/transaction split in the v2 `convert` route. A definition whose
362    /// `(entity, source_group, source_path, discriminator, parent_field)` the
363    /// engine already has is skipped, so the same rule reached through two
364    /// PIDs is added once.
365    ///
366    /// The definitions are taken as they are; run them through
367    /// [`with_path_resolver`](Self::with_path_resolver) first if their paths
368    /// are still named.
369    pub fn extend_definitions(mut self, defs: impl IntoIterator<Item = MappingDefinition>) -> Self {
370        fn key(d: &MappingDefinition) -> (String, String, String, String, String) {
371            (
372                d.meta.entity.clone(),
373                d.meta.source_group.clone(),
374                d.meta.source_path.clone().unwrap_or_default(),
375                d.meta.discriminator.clone().unwrap_or_default(),
376                d.meta.parent_field.clone().unwrap_or_default(),
377            )
378        }
379        let mut seen: std::collections::HashSet<_> = self.definitions.iter().map(key).collect();
380        for def in defs {
381            if seen.insert(key(&def)) {
382                self.definitions.push(def);
383            }
384        }
385        self
386    }
387
388    /// Get all loaded definitions.
389    pub fn definitions(&self) -> &[MappingDefinition] {
390        &self.definitions
391    }
392
393    /// Find a definition by entity name.
394    /// The entity's own definition. `parent_field` children (which carry their
395    /// parent's entity name and are mapped inside the parent's instance) are
396    /// skipped — they are not a definition *of* the entity.
397    pub fn definition_for_entity(&self, entity: &str) -> Option<&MappingDefinition> {
398        self.definitions
399            .iter()
400            .find(|d| d.meta.entity == entity && d.meta.parent_field.is_none())
401    }
402
403    // ── Forward mapping: tree → BO4E ──
404
405    /// Extract a field value from an assembled tree using a mapping path.
406    ///
407    /// `group_path` supports dotted notation for nested groups (e.g., "SG4.SG5").
408    /// Parent groups default to repetition 0; `repetition` applies to the leaf group.
409    ///
410    /// Path format: "segment.composite.data_element" e.g., "loc.c517.d3225"
411    pub fn extract_field(
412        &self,
413        tree: &AssembledTree,
414        group_path: &str,
415        path: &str,
416        repetition: usize,
417    ) -> Option<String> {
418        let instance = Self::resolve_group_instance(tree, group_path, repetition)?;
419        Self::extract_from_instance(instance, path)
420    }
421
422    /// Navigate a potentially nested group path to find a group instance.
423    ///
424    /// For "SG4.SG5", finds SG4\[0\] then SG5 at the given repetition within it.
425    /// For "SG8", finds SG8 at the given repetition in the top-level groups.
426    ///
427    /// Supports intermediate repetition with colon syntax: "SG4.SG8:1.SG10"
428    /// means SG4\[0\] → SG8\[1\] → SG10\[repetition\]. Without a colon suffix,
429    /// intermediate groups default to repetition 0.
430    pub fn resolve_group_instance<'a>(
431        tree: &'a AssembledTree,
432        group_path: &str,
433        repetition: usize,
434    ) -> Option<&'a AssembledGroupInstance> {
435        let parts: Vec<&str> = group_path.split('.').collect();
436
437        let (first_id, first_rep) = parse_group_spec(parts[0]);
438        let first_group = tree.groups.iter().find(|g| g.group_id == first_id)?;
439
440        if parts.len() == 1 {
441            // Single part — use the explicit rep from spec or the `repetition` param
442            let rep = first_rep.unwrap_or(repetition);
443            return first_group.repetitions.get(rep);
444        }
445
446        // Navigate through groups; intermediate parts default to rep 0
447        // unless explicitly specified via `:N` suffix
448        let mut current_instance = first_group.repetitions.get(first_rep.unwrap_or(0))?;
449
450        for (i, part) in parts[1..].iter().enumerate() {
451            let (group_id, explicit_rep) = parse_group_spec(part);
452            let child_group = current_instance
453                .child_groups
454                .iter()
455                .find(|g| g.group_id == group_id)?;
456
457            if i == parts.len() - 2 {
458                // Last part — use explicit rep, or fall back to `repetition`
459                let rep = explicit_rep.unwrap_or(repetition);
460                return child_group.repetitions.get(rep);
461            }
462            // Intermediate — use explicit rep or 0
463            current_instance = child_group.repetitions.get(explicit_rep.unwrap_or(0))?;
464        }
465
466        None
467    }
468
469    /// Navigate the assembled tree using a source_path with qualifier suffixes.
470    ///
471    /// Source paths like `"sg4.sg8_z98.sg10"` encode qualifiers inline:
472    /// `sg8_z98` means "find the SG8 repetition whose entry segment has qualifier Z98".
473    /// Parts without underscores (e.g., `sg4`, `sg10`) use the first repetition.
474    ///
475    /// Returns `None` if any part of the path can't be resolved.
476    pub fn resolve_by_source_path<'a>(
477        tree: &'a AssembledTree,
478        source_path: &str,
479    ) -> Option<&'a AssembledGroupInstance> {
480        let parts: Vec<&str> = source_path.split('.').collect();
481        if parts.is_empty() {
482            return None;
483        }
484
485        let (first_id, first_qualifier) = parse_source_path_part(parts[0]);
486        let first_group = tree
487            .groups
488            .iter()
489            .find(|g| g.group_id.eq_ignore_ascii_case(first_id))?;
490
491        let mut current_instance = if let Some(q) = first_qualifier {
492            find_rep_by_entry_qualifier(&first_group.repetitions, q)?
493        } else {
494            first_group.repetitions.first()?
495        };
496
497        if parts.len() == 1 {
498            return Some(current_instance);
499        }
500
501        for part in &parts[1..] {
502            let (group_id, qualifier) = parse_source_path_part(part);
503            let child_group = current_instance
504                .child_groups
505                .iter()
506                .find(|g| g.group_id.eq_ignore_ascii_case(group_id))?;
507
508            current_instance = if let Some(q) = qualifier {
509                find_rep_by_entry_qualifier(&child_group.repetitions, q)?
510            } else {
511                child_group.repetitions.first()?
512            };
513        }
514
515        Some(current_instance)
516    }
517
518    /// Resolve ALL matching instances for a source_path, returning a Vec.
519    ///
520    /// Like `resolve_by_source_path` but returns all repetitions matching
521    /// at any level, not just the first.  For example, if there are two SG5
522    /// reps with LOC+Z17, `resolve_all_by_source_path(tree, "sg4.sg5_z17")`
523    /// returns both.  For deeper paths like "sg4.sg8_zf3.sg10", if there are
524    /// two SG8 reps with ZF3, it returns SG10 children from both.
525    pub fn resolve_all_by_source_path<'a>(
526        tree: &'a AssembledTree,
527        source_path: &str,
528    ) -> Vec<&'a AssembledGroupInstance> {
529        let parts: Vec<&str> = source_path.split('.').collect();
530        if parts.is_empty() {
531            return vec![];
532        }
533
534        // First part: match against top-level groups
535        let (first_id, first_qualifier) = parse_source_path_part(parts[0]);
536        let first_group = match tree
537            .groups
538            .iter()
539            .find(|g| g.group_id.eq_ignore_ascii_case(first_id))
540        {
541            Some(g) => g,
542            None => return vec![],
543        };
544
545        let mut current_instances: Vec<&AssembledGroupInstance> = if let Some(q) = first_qualifier {
546            find_all_reps_by_entry_qualifier(&first_group.repetitions, q)
547        } else {
548            first_group.repetitions.iter().collect()
549        };
550
551        // Navigate remaining parts, branching at each level when multiple
552        // instances match a qualifier (e.g., two SG8 reps with ZF3).
553        for part in &parts[1..] {
554            let (group_id, qualifier) = parse_source_path_part(part);
555            let mut next_instances = Vec::new();
556
557            for instance in &current_instances {
558                if let Some(child_group) = instance
559                    .child_groups
560                    .iter()
561                    .find(|g| g.group_id.eq_ignore_ascii_case(group_id))
562                {
563                    if let Some(q) = qualifier {
564                        next_instances.extend(find_all_reps_by_entry_qualifier(
565                            &child_group.repetitions,
566                            q,
567                        ));
568                    } else {
569                        next_instances.extend(child_group.repetitions.iter());
570                    }
571                }
572            }
573
574            current_instances = next_instances;
575        }
576
577        current_instances
578    }
579
580    /// Like `resolve_all_by_source_path` but also returns the direct parent
581    /// rep index that each leaf instance came from. The "direct parent" is the
582    /// group one level above the leaf in the path.
583    ///
584    /// For `"sg2.sg3"`: parent is the SG2 rep index.
585    /// For `"sg17.sg36.sg40"`: parent is the SG36 rep index (not SG17).
586    ///
587    /// For single-level paths, all indices are 0.
588    ///
589    /// Compute child rep indices for the leaf group in a source_path.
590    /// E.g., for "sg29.sg30", returns the position of each matched SG30 rep
591    /// within its parent SG29's SG30 child group.
592    fn compute_child_indices(
593        tree: &AssembledTree,
594        source_path: &str,
595        indexed: &[(usize, &AssembledGroupInstance)],
596    ) -> Vec<usize> {
597        let parts: Vec<&str> = source_path.split('.').collect();
598        if parts.len() < 2 {
599            return vec![];
600        }
601        // Navigate to the parent level and find the child group
602        let (first_id, first_qualifier) = parse_source_path_part(parts[0]);
603        let first_group = match tree
604            .groups
605            .iter()
606            .find(|g| g.group_id.eq_ignore_ascii_case(first_id))
607        {
608            Some(g) => g,
609            None => return vec![],
610        };
611        let parent_reps: Vec<&AssembledGroupInstance> = if let Some(q) = first_qualifier {
612            find_all_reps_by_entry_qualifier(&first_group.repetitions, q)
613        } else {
614            first_group.repetitions.iter().collect()
615        };
616        // For 2-level paths (sg29.sg30), find the child group in the parent
617        let (child_id, _child_qualifier) = parse_source_path_part(parts[parts.len() - 1]);
618        let mut result = Vec::new();
619        for (_, inst) in indexed {
620            // Find which rep index this instance is at in the child group
621            let mut found = false;
622            for parent in &parent_reps {
623                if let Some(child_group) = parent
624                    .child_groups
625                    .iter()
626                    .find(|g| g.group_id.eq_ignore_ascii_case(child_id))
627                {
628                    if let Some(pos) = child_group
629                        .repetitions
630                        .iter()
631                        .position(|r| std::ptr::eq(r, *inst))
632                    {
633                        result.push(pos);
634                        found = true;
635                        break;
636                    }
637                }
638            }
639            if !found {
640                result.push(usize::MAX); // fallback
641            }
642        }
643        result
644    }
645
646    /// Returns `Vec<(parent_rep_index, &AssembledGroupInstance)>`.
647    pub fn resolve_all_with_parent_indices<'a>(
648        tree: &'a AssembledTree,
649        source_path: &str,
650    ) -> Vec<(usize, &'a AssembledGroupInstance)> {
651        let parts: Vec<&str> = source_path.split('.').collect();
652        if parts.is_empty() {
653            return vec![];
654        }
655
656        // First part: match against top-level groups
657        let (first_id, first_qualifier) = parse_source_path_part(parts[0]);
658        let first_group = match tree
659            .groups
660            .iter()
661            .find(|g| g.group_id.eq_ignore_ascii_case(first_id))
662        {
663            Some(g) => g,
664            None => return vec![],
665        };
666
667        // If single-level path, just return instances with index 0
668        if parts.len() == 1 {
669            let instances: Vec<&AssembledGroupInstance> = if let Some(q) = first_qualifier {
670                find_all_reps_by_entry_qualifier(&first_group.repetitions, q)
671            } else {
672                first_group.repetitions.iter().collect()
673            };
674            return instances.into_iter().map(|i| (0, i)).collect();
675        }
676
677        // Multi-level: navigate tracking (parent_rep_idx, instance) at each level.
678        // At intermediate levels, parent_rep_idx is updated to the current rep's
679        // position within its group. At the leaf level, the parent_rep_idx from
680        // the previous level is preserved — giving us the DIRECT parent index.
681        let first_reps: Vec<(usize, &AssembledGroupInstance)> = if let Some(q) = first_qualifier {
682            let matching = find_all_reps_by_entry_qualifier(&first_group.repetitions, q);
683            let mut result = Vec::new();
684            for m in matching {
685                let idx = first_group
686                    .repetitions
687                    .iter()
688                    .position(|r| std::ptr::eq(r, m))
689                    .unwrap_or(0);
690                result.push((idx, m));
691            }
692            result
693        } else {
694            first_group.repetitions.iter().enumerate().collect()
695        };
696
697        let mut current: Vec<(usize, &AssembledGroupInstance)> = first_reps;
698        let remaining = &parts[1..];
699
700        for (level, part) in remaining.iter().enumerate() {
701            let is_leaf = level == remaining.len() - 1;
702            let (group_id, qualifier) = parse_source_path_part(part);
703            let mut next: Vec<(usize, &AssembledGroupInstance)> = Vec::new();
704
705            for (prev_parent_idx, instance) in &current {
706                if let Some(child_group) = instance
707                    .child_groups
708                    .iter()
709                    .find(|g| g.group_id.eq_ignore_ascii_case(group_id))
710                {
711                    let matching: Vec<(usize, &AssembledGroupInstance)> = if let Some(q) = qualifier
712                    {
713                        let filtered =
714                            find_all_reps_by_entry_qualifier(&child_group.repetitions, q);
715                        filtered
716                            .into_iter()
717                            .map(|m| {
718                                let idx = child_group
719                                    .repetitions
720                                    .iter()
721                                    .position(|r| std::ptr::eq(r, m))
722                                    .unwrap_or(0);
723                                (idx, m)
724                            })
725                            .collect()
726                    } else {
727                        child_group.repetitions.iter().enumerate().collect()
728                    };
729
730                    for (rep_idx, child_rep) in matching {
731                        if is_leaf {
732                            // At the leaf: keep the parent index from the previous level
733                            next.push((*prev_parent_idx, child_rep));
734                        } else {
735                            // At intermediate: pass down the current rep index
736                            next.push((rep_idx, child_rep));
737                        }
738                    }
739                }
740            }
741
742            current = next;
743        }
744
745        current
746    }
747
748    /// Extract a field from a group instance by path.
749    ///
750    /// Supports qualifier-based segment selection with `tag[qualifier]` syntax:
751    /// - `"dtm.0.1"` → first DTM segment, elements\[0\]\[1\]
752    /// - `"dtm[92].0.1"` → DTM where elements\[0\]\[0\] == "92", then elements\[0\]\[1\]
753    pub fn extract_from_instance(instance: &AssembledGroupInstance, path: &str) -> Option<String> {
754        let parts: Vec<&str> = path.split('.').collect();
755        if parts.is_empty() {
756            return None;
757        }
758
759        // Parse segment tag, optional qualifier, and occurrence index:
760        // "dtm[92]" → ("DTM", Some("92"), 0), "rff[Z34,1]" → ("RFF", Some("Z34"), 1)
761        let (segment_tag, qualifier, occurrence) = parse_tag_qualifier(parts[0]);
762
763        let segment = if let Some(q) = qualifier {
764            instance
765                .segments
766                .iter()
767                .filter(|s| {
768                    s.tag.eq_ignore_ascii_case(&segment_tag)
769                        && s.elements
770                            .first()
771                            .and_then(|e| e.first())
772                            .map(|v| v.as_str())
773                            == Some(q)
774                })
775                .nth(occurrence)?
776        } else {
777            instance
778                .segments
779                .iter()
780                .filter(|s| s.tag.eq_ignore_ascii_case(&segment_tag))
781                .nth(occurrence)?
782        };
783
784        Self::resolve_field_path(segment, &parts[1..])
785    }
786
787    /// Extract ALL matching values from a group instance for a collect-all path.
788    ///
789    /// Used with wildcard occurrence syntax `tag[qualifier,*]` to collect values
790    /// from every segment matching the qualifier, not just the Nth one.
791    /// Returns a `Vec<String>` of all extracted values in segment order.
792    pub fn extract_all_from_instance(instance: &AssembledGroupInstance, path: &str) -> Vec<String> {
793        let parts: Vec<&str> = path.split('.').collect();
794        if parts.is_empty() {
795            return vec![];
796        }
797
798        let (segment_tag, qualifier, _) = parse_tag_qualifier(parts[0]);
799
800        let matching_segments: Vec<&AssembledSegment> = if let Some(q) = qualifier {
801            instance
802                .segments
803                .iter()
804                .filter(|s| {
805                    s.tag.eq_ignore_ascii_case(&segment_tag)
806                        && s.elements
807                            .first()
808                            .and_then(|e| e.first())
809                            .map(|v| v.as_str())
810                            == Some(q)
811                })
812                .collect()
813        } else {
814            instance
815                .segments
816                .iter()
817                .filter(|s| s.tag.eq_ignore_ascii_case(&segment_tag))
818                .collect()
819        };
820
821        matching_segments
822            .into_iter()
823            .filter_map(|seg| Self::resolve_field_path(seg, &parts[1..]))
824            .collect()
825    }
826
827    /// Map all fields in a definition from the assembled tree to a BO4E JSON object.
828    ///
829    /// `group_path` is the definition's `source_group` (may be dotted, e.g., "SG4.SG5").
830    /// An empty `source_group` maps root-level segments (BGM, DTM, etc.).
831    /// Returns a flat JSON object with target field names as keys.
832    pub fn map_forward(
833        &self,
834        tree: &AssembledTree,
835        def: &MappingDefinition,
836        repetition: usize,
837    ) -> serde_json::Value {
838        self.map_forward_inner(tree, def, repetition, true)
839    }
840
841    /// Inner implementation with enrichment control.
842    fn map_forward_inner(
843        &self,
844        tree: &AssembledTree,
845        def: &MappingDefinition,
846        repetition: usize,
847        enrich_codes: bool,
848    ) -> serde_json::Value {
849        let mut result = serde_json::Map::new();
850
851        // Root-level mapping: source_group is empty → use tree's own segments.
852        // Include all root segments (both pre-group and post-group, e.g., summary
853        // MOA after UNS+S in REMADV) plus any inter_group_segments (e.g., UNS+S
854        // consumed between groups by the assembler).
855        if def.meta.source_group.is_empty() {
856            let mut all_root_segs = tree.segments.clone();
857            for segs in tree.inter_group_segments.values() {
858                all_root_segs.extend(segs.iter().cloned());
859            }
860            let root_instance = AssembledGroupInstance {
861                segments: all_root_segs,
862                child_groups: vec![],
863                entry_mig_number: None,
864                variant_mig_numbers: vec![],
865                skipped_segments: Vec::new(),
866                skipped_positions: Vec::new(),
867            };
868            self.extract_fields_from_instance(&root_instance, def, &mut result, enrich_codes);
869            return serde_json::Value::Object(result);
870        }
871
872        // Try source_path-based resolution when:
873        //   1. source_path has qualifier suffixes (e.g., "sg4.sg8_z98.sg10")
874        //   2. source_group has no explicit :N indices (those take priority)
875        // This allows definitions without positional indices to navigate via
876        // entry-segment qualifiers (e.g., SEQ qualifier Z98).
877        let instance = if let Some(ref sp) = def.meta.source_path {
878            if has_source_path_qualifiers(sp) && !def.meta.source_group.contains(':') {
879                Self::resolve_by_source_path(tree, sp).or_else(|| {
880                    Self::resolve_group_instance(tree, &def.meta.source_group, repetition)
881                })
882            } else {
883                Self::resolve_group_instance(tree, &def.meta.source_group, repetition)
884            }
885        } else {
886            Self::resolve_group_instance(tree, &def.meta.source_group, repetition)
887        };
888
889        if let Some(instance) = instance {
890            // repeat_on_tag: iterate over all segments of that tag, producing an array
891            if let Some(ref tag) = def.meta.repeat_on_tag {
892                let matching: Vec<_> = instance
893                    .segments
894                    .iter()
895                    .filter(|s| s.tag.eq_ignore_ascii_case(tag))
896                    .collect();
897
898                if matching.len() > 1 {
899                    let mut arr = Vec::new();
900                    for seg in &matching {
901                        let sub_instance = AssembledGroupInstance {
902                            segments: vec![(*seg).clone()],
903                            child_groups: vec![],
904                            entry_mig_number: None,
905                            variant_mig_numbers: vec![],
906                            skipped_segments: Vec::new(),
907                            skipped_positions: Vec::new(),
908                        };
909                        let mut elem_result = serde_json::Map::new();
910                        self.extract_fields_from_instance(
911                            &sub_instance,
912                            def,
913                            &mut elem_result,
914                            enrich_codes,
915                        );
916                        if !elem_result.is_empty() {
917                            arr.push(serde_json::Value::Object(elem_result));
918                        }
919                    }
920                    if !arr.is_empty() {
921                        return serde_json::Value::Array(arr);
922                    }
923                }
924            }
925
926            self.extract_fields_from_instance(instance, def, &mut result, enrich_codes);
927        }
928
929        serde_json::Value::Object(result)
930    }
931
932    /// Extract all fields from an instance into a result map.
933    ///
934    /// When a `code_lookup` is configured, code-type fields are emitted as
935    /// `{"code": "E01", "meaning": "..."}` objects. Data-type fields remain plain strings.
936    fn extract_fields_from_instance(
937        &self,
938        instance: &AssembledGroupInstance,
939        def: &MappingDefinition,
940        result: &mut serde_json::Map<String, serde_json::Value>,
941        enrich_codes: bool,
942    ) {
943        for (path, field_mapping) in &def.fields {
944            let (target, enum_map) = match field_mapping {
945                FieldMapping::Simple(t) => (t.as_str(), None),
946                FieldMapping::Structured(s) => (s.target.as_str(), s.enum_map.as_ref()),
947                FieldMapping::Nested(_) => continue,
948            };
949            if target.is_empty() {
950                continue;
951            }
952            if let Some(val) = Self::extract_from_instance(instance, path) {
953                // Dual decomposition: one EDIFACT code also feeds a second BO4E
954                // field (e.g. the NAD qualifier carries both partnerrolle and
955                // datenqualitaet). Without this the code cannot be recovered in
956                // reverse, because several codes share the primary value.
957                if let FieldMapping::Structured(s) = field_mapping {
958                    if let (Some(also), Some(also_map)) =
959                        (s.also_target.as_deref(), s.also_enum_map.as_ref())
960                    {
961                        if let Some(also_val) = also_map.get(&val) {
962                            set_nested_value(result, also, also_val.clone());
963                        }
964                    }
965                }
966
967                let mapped_val = if let Some(map) = enum_map {
968                    map.get(&val).cloned().unwrap_or_else(|| val.clone())
969                } else {
970                    val.clone()
971                };
972
973                // Enrich code fields with meaning from PID schema
974                if enrich_codes {
975                    if let (Some(ref code_lookup), Some(ref source_path)) =
976                        (&self.code_lookup, &def.meta.source_path)
977                    {
978                        let parts: Vec<&str> = path.split('.').collect();
979                        let (seg_tag, path_qualifier, _occ) = parse_tag_qualifier(parts[0]);
980                        let (element_idx, component_idx) =
981                            Self::parse_element_component(&parts[1..]);
982                        let disc_qualifier = Self::discriminator_qualifier_for_tag(def, &seg_tag);
983                        let q = disc_qualifier.as_deref();
984
985                        if let Some(codes) = code_lookup.enrichment_codes(
986                            source_path,
987                            &seg_tag,
988                            path_qualifier,
989                            q,
990                            element_idx,
991                            component_idx,
992                        ) {
993                            // Class C: PID self-reference — emit a plain string,
994                            // skipping {code, meaning, enum} decoration when the
995                            // schema's only allowed value at this position is the
996                            // PID itself.
997                            if let Some(ref pid) = self.current_pid {
998                                if codes.len() == 1 && codes.contains_key(pid.as_str()) {
999                                    set_nested_value(result, target, mapped_val);
1000                                    continue;
1001                                }
1002                            }
1003
1004                            // Look up the original EDIFACT value for enrichment,
1005                            // since schema codes use raw values (e.g., "293")
1006                            // not enum_map targets (e.g., "BDEW").
1007                            let enrichment = codes.get(&val);
1008                            let meaning = enrichment
1009                                .map(|e| serde_json::Value::String(e.meaning.clone()))
1010                                .unwrap_or(serde_json::Value::Null);
1011
1012                            let mut obj = serde_json::Map::new();
1013                            obj.insert("code".into(), serde_json::json!(mapped_val));
1014                            obj.insert("meaning".into(), meaning);
1015                            if let Some(enum_key) = enrichment.and_then(|e| e.enum_key.as_ref()) {
1016                                obj.insert("enum".into(), serde_json::json!(enum_key));
1017                            }
1018                            let enriched = serde_json::Value::Object(obj);
1019                            set_nested_value_json(result, target, enriched);
1020                            continue;
1021                        }
1022                    }
1023                }
1024
1025                set_nested_value(result, target, mapped_val);
1026            }
1027        }
1028
1029        // Also for `parent_field` children: they can be parents of deeper
1030        // `parent_field` definitions (SG15 → SG17 → SG18).
1031        if !instance.child_groups.is_empty() {
1032            self.extract_nested_children(instance, def, result, enrich_codes);
1033        }
1034    }
1035
1036    /// Forward half of `[meta] parent_field`: map the child groups of `instance`
1037    /// (the parent group repetition `def` was just extracted from) into array
1038    /// fields of the same object. Placement is instance-local — a child can only
1039    /// land in the object produced from the group repetition that contains it.
1040    fn extract_nested_children(
1041        &self,
1042        instance: &AssembledGroupInstance,
1043        def: &MappingDefinition,
1044        result: &mut serde_json::Map<String, serde_json::Value>,
1045        enrich_codes: bool,
1046    ) {
1047        for child in self
1048            .definitions
1049            .iter()
1050            .filter(|c| is_nested_child_of(c, def))
1051        {
1052            if nested_parent_qualifier(child).is_some_and(|q| !entry_qualifier_matches(instance, q))
1053            {
1054                continue;
1055            }
1056            let (leaf_id, leaf_qualifier) = nested_child_leaf(child);
1057            let Some(group) = instance
1058                .child_groups
1059                .iter()
1060                .find(|g| g.group_id.eq_ignore_ascii_case(&leaf_id))
1061            else {
1062                continue;
1063            };
1064            let reps: Vec<&AssembledGroupInstance> = match leaf_qualifier {
1065                Some(q) => find_all_reps_by_entry_qualifier(&group.repetitions, q),
1066                None => group.repetitions.iter().collect(),
1067            };
1068
1069            let mut items: Vec<serde_json::Value> = Vec::new();
1070            let mut push_item = |sub: &AssembledGroupInstance| {
1071                let mut obj = serde_json::Map::new();
1072                self.extract_fields_from_instance(sub, child, &mut obj, enrich_codes);
1073                if !obj.is_empty() {
1074                    items.push(serde_json::Value::Object(obj));
1075                }
1076            };
1077            for rep in reps {
1078                let repeat_tag = child
1079                    .meta
1080                    .repeat_on_tag
1081                    .as_deref()
1082                    .filter(|tag| rep.segments.iter().any(|s| s.tag.eq_ignore_ascii_case(tag)));
1083                let Some(tag) = repeat_tag else {
1084                    push_item(rep);
1085                    continue;
1086                };
1087                // One element per repeating segment; the group's other segments
1088                // (e.g. the CTA entry segment) are visible to every element.
1089                let shared: Vec<AssembledSegment> = rep
1090                    .segments
1091                    .iter()
1092                    .filter(|s| !s.tag.eq_ignore_ascii_case(tag))
1093                    .cloned()
1094                    .collect();
1095                for seg in rep
1096                    .segments
1097                    .iter()
1098                    .filter(|s| s.tag.eq_ignore_ascii_case(tag))
1099                {
1100                    let mut segments = shared.clone();
1101                    segments.push(seg.clone());
1102                    push_item(&AssembledGroupInstance {
1103                        segments,
1104                        child_groups: vec![],
1105                        entry_mig_number: None,
1106                        variant_mig_numbers: vec![],
1107                        skipped_segments: Vec::new(),
1108                        skipped_positions: Vec::new(),
1109                    });
1110                }
1111            }
1112            if items.is_empty() {
1113                continue;
1114            }
1115            let field = child.meta.parent_field.as_deref().unwrap_or_default();
1116            match result.get_mut(field) {
1117                Some(serde_json::Value::Array(existing)) => existing.extend(items),
1118                _ => {
1119                    result.insert(field.to_string(), serde_json::Value::Array(items));
1120                }
1121            }
1122        }
1123    }
1124
1125    /// Reverse half of `[meta] parent_field`: emit the elements of
1126    /// `bo4e_value[parent_field]` as child group(s) of `instance`, the parent
1127    /// group repetition just rebuilt from that same object.
1128    fn reverse_nested_children(
1129        &self,
1130        bo4e_value: &serde_json::Value,
1131        def: &MappingDefinition,
1132        instance: &mut AssembledGroupInstance,
1133    ) {
1134        let mut handled_fields: Vec<&str> = Vec::new();
1135        for child in self
1136            .definitions
1137            .iter()
1138            .filter(|c| is_nested_child_of(c, def))
1139        {
1140            let field = child.meta.parent_field.as_deref().unwrap_or_default();
1141            if handled_fields.contains(&field) {
1142                continue;
1143            }
1144            if nested_parent_qualifier(child)
1145                .is_some_and(|q| !rebuilt_entry_qualifier_matches(instance, def, q))
1146            {
1147                continue;
1148            }
1149            let elements: Vec<&serde_json::Value> = match bo4e_value.get(field) {
1150                Some(serde_json::Value::Array(arr)) => arr.iter().collect(),
1151                Some(serde_json::Value::Null) | None => continue,
1152                Some(other) => vec![other],
1153            };
1154
1155            let mut reps: Vec<AssembledGroupInstance> = Vec::new();
1156            if let Some(tag) = child.meta.repeat_on_tag.as_deref() {
1157                // All elements share one group repetition: the non-repeating
1158                // segments (entry segment) once, then one repeating segment each.
1159                let mut merged: Option<AssembledGroupInstance> = None;
1160                for element in elements {
1161                    let sub = self.map_reverse_single(element, child);
1162                    if sub.segments.is_empty() {
1163                        continue;
1164                    }
1165                    match merged.as_mut() {
1166                        None => merged = Some(sub),
1167                        Some(m) => m.segments.extend(
1168                            sub.segments
1169                                .into_iter()
1170                                .filter(|s| s.tag.eq_ignore_ascii_case(tag)),
1171                        ),
1172                    }
1173                }
1174                reps.extend(merged);
1175            } else {
1176                for element in elements {
1177                    let mut sub = self.map_reverse_single(element, child);
1178                    if sub.segments.is_empty() {
1179                        continue;
1180                    }
1181                    // Grandchildren nested in this element (multi-level nesting).
1182                    self.reverse_nested_children(element, child, &mut sub);
1183                    reps.push(sub);
1184                }
1185            }
1186            if reps.is_empty() {
1187                continue;
1188            }
1189            handled_fields.push(field);
1190
1191            let (leaf_id, _) = nested_child_leaf(child);
1192            match instance
1193                .child_groups
1194                .iter_mut()
1195                .find(|g| g.group_id.eq_ignore_ascii_case(&leaf_id))
1196            {
1197                Some(group) => group.repetitions.extend(reps),
1198                None => instance.child_groups.push(AssembledGroup {
1199                    group_id: leaf_id,
1200                    repetitions: reps,
1201                }),
1202            }
1203        }
1204    }
1205
1206    /// Extract the discriminator's qualifier value from a definition's `[meta]`.
1207    ///
1208    /// `discriminator` strings look like `"RFF.0.0=Z13"` (numeric, post path-resolution)
1209    /// or `"RFF.c506.d1153=TN"` (named, pre-resolution). The qualifier is the
1210    /// substring after the first `=`. Returns `None` when no discriminator is set
1211    /// or the format is unexpected.
1212    pub(crate) fn discriminator_qualifier(def: &MappingDefinition) -> Option<String> {
1213        def.meta
1214            .discriminator
1215            .as_deref()
1216            .and_then(|d| d.split_once('=').map(|(_, v)| v.to_string()))
1217    }
1218
1219    /// The discriminator value when the discriminator selects on `segment_tag`
1220    /// itself (`RFF.0.0=Z13` for an RFF field). A discriminator on another segment
1221    /// (`SEQ.0.0=Z98` for a CCI field) says nothing about which variant of
1222    /// `segment_tag` a field reads.
1223    pub(crate) fn discriminator_qualifier_for_tag(
1224        def: &MappingDefinition,
1225        segment_tag: &str,
1226    ) -> Option<String> {
1227        let (lhs, value) = def.meta.discriminator.as_deref()?.split_once('=')?;
1228        let disc_tag = lhs.split('.').next().unwrap_or(lhs);
1229        disc_tag
1230            .eq_ignore_ascii_case(segment_tag)
1231            .then(|| value.to_string())
1232    }
1233
1234    /// Map a PID struct field's segments to BO4E JSON.
1235    ///
1236    /// `segments` are the `OwnedSegment`s from a PID wrapper field.
1237    /// Converts to `AssembledSegment` format for compatibility with existing
1238    /// field extraction logic, then applies the definition's field mappings.
1239    pub fn map_forward_from_segments(
1240        &self,
1241        segments: &[OwnedSegment],
1242        def: &MappingDefinition,
1243    ) -> serde_json::Value {
1244        let assembled_segments: Vec<AssembledSegment> = segments
1245            .iter()
1246            .map(|s| AssembledSegment {
1247                tag: s.id.clone(),
1248                elements: s.elements.clone(),
1249                mig_number: None,
1250                segment_number: Some(s.segment_number),
1251            })
1252            .collect();
1253
1254        let instance = AssembledGroupInstance {
1255            segments: assembled_segments,
1256            child_groups: vec![],
1257            entry_mig_number: None,
1258            variant_mig_numbers: vec![],
1259            skipped_segments: Vec::new(),
1260            skipped_positions: Vec::new(),
1261        };
1262
1263        let mut result = serde_json::Map::new();
1264        self.extract_fields_from_instance(&instance, def, &mut result, true);
1265        serde_json::Value::Object(result)
1266    }
1267
1268    // ── Reverse mapping: BO4E → tree ──
1269
1270    /// Map a BO4E JSON object back to an assembled group instance.
1271    ///
1272    /// Uses the definition's field mappings to populate segment elements.
1273    /// Fields with `default` values are used when no BO4E value is present
1274    /// (useful for fixed qualifiers like LOC qualifier "Z16").
1275    ///
1276    /// Supports:
1277    /// - Named paths: `"d3227"` → element\[0\]\[0\], `"c517.d3225"` → element\[1\]\[0\]
1278    /// - Numeric index: `"0"` → element\[0\]\[0\], `"1.2"` → element\[1\]\[2\]
1279    /// - Qualifier selection: `"dtm[92].0.1"` → DTM segment with qualifier "92"
1280    pub fn map_reverse(
1281        &self,
1282        bo4e_value: &serde_json::Value,
1283        def: &MappingDefinition,
1284    ) -> AssembledGroupInstance {
1285        // repeat_on_tag + array input: reverse each element independently, merge segments
1286        if def.meta.repeat_on_tag.is_some() {
1287            if let Some(arr) = bo4e_value.as_array() {
1288                let mut all_segments = Vec::new();
1289                for elem in arr {
1290                    let sub = self.map_reverse_single(elem, def);
1291                    all_segments.extend(sub.segments);
1292                }
1293                return AssembledGroupInstance {
1294                    segments: all_segments,
1295                    child_groups: vec![],
1296                    entry_mig_number: None,
1297                    variant_mig_numbers: vec![],
1298                    skipped_segments: Vec::new(),
1299                    skipped_positions: Vec::new(),
1300                };
1301            }
1302        }
1303        let mut instance = self.map_reverse_single(bo4e_value, def);
1304        if def.meta.parent_field.is_none() && !instance.segments.is_empty() {
1305            self.reverse_nested_children(bo4e_value, def, &mut instance);
1306        }
1307        instance
1308    }
1309
1310    fn map_reverse_single(
1311        &self,
1312        bo4e_value: &serde_json::Value,
1313        def: &MappingDefinition,
1314    ) -> AssembledGroupInstance {
1315        // Collect (segment_key, element_index, component_index, value) tuples.
1316        // segment_key includes qualifier for disambiguation: "DTM" or "DTM[92]".
1317        let mut field_values: Vec<(String, String, usize, usize, String)> =
1318            Vec::with_capacity(def.fields.len());
1319
1320        // Track whether any field with a non-empty target resolved to an actual
1321        // BO4E value.  When a definition has data fields but none resolved to
1322        // values, only defaults (qualifiers) would be emitted — producing phantom
1323        // segments for groups not present in the original EDIFACT message.
1324        // Definitions with ONLY qualifier/default fields (no data targets) are
1325        // "container" definitions (e.g., SEQ entry segments) and are always kept.
1326        let mut has_real_data = false;
1327        let mut has_data_fields = false;
1328        // Per-segment phantom tracking: segments with data fields but no resolved
1329        // data are phantoms — their entries should be removed from field_values.
1330        let mut seg_has_data_field: HashSet<String> = HashSet::new();
1331        let mut seg_has_real_data: HashSet<String> = HashSet::new();
1332        let mut injected_qualifiers: HashSet<String> = HashSet::new();
1333
1334        for (path, field_mapping) in &def.fields {
1335            let (target, default, enum_map, when_filled, also_target, also_enum_map) =
1336                match field_mapping {
1337                    FieldMapping::Simple(t) => (t.as_str(), None, None, None, None, None),
1338                    FieldMapping::Structured(s) => (
1339                        s.target.as_str(),
1340                        s.default.as_ref(),
1341                        s.enum_map.as_ref(),
1342                        s.when_filled.as_ref(),
1343                        s.also_target.as_deref(),
1344                        s.also_enum_map.as_ref(),
1345                    ),
1346                    FieldMapping::Nested(_) => continue,
1347                };
1348
1349            let parts: Vec<&str> = path.split('.').collect();
1350            if parts.len() < 2 {
1351                continue;
1352            }
1353
1354            let (seg_tag, qualifier, _occ) = parse_tag_qualifier(parts[0]);
1355            // Use the raw first part as segment key to group fields by segment instance.
1356            // Indexed qualifiers like "RFF[Z34,1]" produce a distinct key from "RFF[Z34]".
1357            let seg_key = parts[0].to_uppercase();
1358            let sub_path = &parts[1..];
1359
1360            // Determine (element_idx, component_idx) from path
1361            let (element_idx, component_idx) = if let Ok(ei) = sub_path[0].parse::<usize>() {
1362                let ci = if sub_path.len() > 1 {
1363                    sub_path[1].parse::<usize>().unwrap_or(0)
1364                } else {
1365                    0
1366                };
1367                (ei, ci)
1368            } else {
1369                match sub_path.len() {
1370                    1 => (0, 0),
1371                    2 => (1, 0),
1372                    _ => continue,
1373                }
1374            };
1375
1376            // Try BO4E value first, fall back to default
1377            let val = if target.is_empty() {
1378                match (default, when_filled) {
1379                    // has when_filled → conditional injection
1380                    (Some(d), Some(fields)) => {
1381                        let any_filled = fields
1382                            .iter()
1383                            .any(|f| self.populate_field(bo4e_value, f).is_some());
1384                        if any_filled {
1385                            // A successful when_filled check confirms real data
1386                            // exists — prevent phantom suppression.
1387                            has_real_data = true;
1388                            Some(d.clone())
1389                        } else {
1390                            None
1391                        }
1392                    }
1393                    // no when_filled → unconditional (backward compat)
1394                    (Some(d), None) => Some(d.clone()),
1395                    (None, _) => None,
1396                }
1397            } else {
1398                has_data_fields = true;
1399                seg_has_data_field.insert(seg_key.clone());
1400                let bo4e_val = self.populate_field(bo4e_value, target);
1401                if bo4e_val.is_some() {
1402                    has_real_data = true;
1403                    seg_has_real_data.insert(seg_key.clone());
1404                }
1405                // Apply reverse enum_map: BO4E value → EDIFACT value
1406                let mapped_val = match (bo4e_val, enum_map) {
1407                    (Some(v), Some(map)) => {
1408                        // Dual decomposition (`also_target`): one EDIFACT code was
1409                        // split across two BO4E fields, so neither alone identifies
1410                        // it. Find the code both maps agree on; several codes share
1411                        // a `partnerrolle` and are told apart only by the second
1412                        // field. Falls back to the single-map lookup when the
1413                        // second field is absent or no code matches both.
1414                        let joint = match (also_target, also_enum_map) {
1415                            (Some(also), Some(also_map)) => {
1416                                self.populate_field(bo4e_value, also).and_then(|also_v| {
1417                                    map.iter()
1418                                        .find(|(code, bo4e_v)| {
1419                                            *bo4e_v == &v && also_map.get(*code) == Some(&also_v)
1420                                        })
1421                                        .map(|(code, _)| code.clone())
1422                                })
1423                            }
1424                            _ => None,
1425                        };
1426                        joint
1427                            .or_else(|| {
1428                                // Reverse lookup: find EDIFACT key for BO4E value
1429                                map.iter()
1430                                    .find(|(_, bo4e_v)| *bo4e_v == &v)
1431                                    .map(|(edifact_k, _)| edifact_k.clone())
1432                            })
1433                            .or(Some(v))
1434                    }
1435                    (v, _) => v,
1436                };
1437                mapped_val.or_else(|| default.cloned())
1438            };
1439
1440            if let Some(val) = val {
1441                field_values.push((
1442                    seg_key.clone(),
1443                    seg_tag.clone(),
1444                    element_idx,
1445                    component_idx,
1446                    val,
1447                ));
1448            }
1449
1450            // If there's a qualifier, also inject it at elements[0][0]
1451            if let Some(q) = qualifier {
1452                if injected_qualifiers.insert(seg_key.clone()) {
1453                    field_values.push((seg_key, seg_tag, 0, 0, q.to_string()));
1454                }
1455            }
1456        }
1457
1458        // Per-segment phantom prevention for qualified segments: remove entries
1459        // for segments using tag[qualifier] syntax (e.g., FTX[ACB], DTM[Z07])
1460        // that have data fields but none resolved to actual BO4E values.  This
1461        // prevents phantom segments when a definition maps multiple segment types
1462        // and optional qualified segments are not in the original message.
1463        // Unqualified segments (plain tags like SEQ, IDE) are always kept — they
1464        // are typically entry/mandatory segments of their group.
1465        field_values.retain(|(seg_key, _, _, _, _)| {
1466            if !seg_key.contains('[') {
1467                return true; // unqualified segments always kept
1468            }
1469            !seg_has_data_field.contains(seg_key) || seg_has_real_data.contains(seg_key)
1470        });
1471
1472        // If the definition has data fields but none resolved to actual BO4E values,
1473        // return an empty instance to prevent phantom segments for groups not
1474        // present in the original EDIFACT message.  Definitions with only
1475        // qualifier/default fields (has_data_fields=false) are always kept.
1476        if has_data_fields && !has_real_data {
1477            return AssembledGroupInstance {
1478                segments: vec![],
1479                child_groups: vec![],
1480                entry_mig_number: None,
1481                variant_mig_numbers: vec![],
1482                skipped_segments: Vec::new(),
1483                skipped_positions: Vec::new(),
1484            };
1485        }
1486
1487        // Build segments with elements/components in correct positions.
1488        // Group by segment_key to create separate segments for "DTM[92]" vs "DTM[93]".
1489        let mut segments: Vec<AssembledSegment> = Vec::with_capacity(field_values.len());
1490        let mut seen_keys: HashMap<String, usize> = HashMap::new();
1491
1492        for (seg_key, seg_tag, element_idx, component_idx, val) in &field_values {
1493            let seg = if let Some(&pos) = seen_keys.get(seg_key) {
1494                &mut segments[pos]
1495            } else {
1496                let pos = segments.len();
1497                seen_keys.insert(seg_key.clone(), pos);
1498                segments.push(AssembledSegment {
1499                    tag: seg_tag.clone(),
1500                    elements: vec![],
1501                    mig_number: None,
1502                    segment_number: None,
1503                });
1504                &mut segments[pos]
1505            };
1506
1507            while seg.elements.len() <= *element_idx {
1508                seg.elements.push(vec![]);
1509            }
1510            while seg.elements[*element_idx].len() <= *component_idx {
1511                seg.elements[*element_idx].push(String::new());
1512            }
1513            seg.elements[*element_idx][*component_idx] = val.clone();
1514        }
1515
1516        // Pad intermediate empty elements: any [] between position 0 and the last
1517        // populated position becomes [""] so the EDIFACT renderer emits the `+` separator.
1518        for seg in &mut segments {
1519            let last_populated = seg.elements.iter().rposition(|e| !e.is_empty());
1520            if let Some(last_idx) = last_populated {
1521                for i in 0..last_idx {
1522                    if seg.elements[i].is_empty() {
1523                        seg.elements[i] = vec![String::new()];
1524                    }
1525                }
1526            }
1527        }
1528
1529        // MIG-aware trailing padding: extend each segment to the MIG-defined element count.
1530        if let Some(ref ss) = self.segment_structure {
1531            for seg in &mut segments {
1532                if let Some(expected) = ss.element_count(&seg.tag) {
1533                    while seg.elements.len() < expected {
1534                        seg.elements.push(vec![String::new()]);
1535                    }
1536                }
1537            }
1538        }
1539
1540        AssembledGroupInstance {
1541            segments,
1542            child_groups: vec![],
1543            entry_mig_number: None,
1544            variant_mig_numbers: vec![],
1545            skipped_segments: Vec::new(),
1546            skipped_positions: Vec::new(),
1547        }
1548    }
1549
1550    /// Resolve a field path within a segment to extract a value.
1551    ///
1552    /// Two path conventions are supported:
1553    ///
1554    /// **Named paths** (backward compatible):
1555    /// - 1-part `"d3227"` → elements\[0\]\[0\]
1556    /// - 2-part `"c517.d3225"` → elements\[1\]\[0\]
1557    ///
1558    /// **Numeric index paths** (for multi-component access):
1559    /// - `"0"` → elements\[0\]\[0\]
1560    /// - `"1.0"` → elements\[1\]\[0\]
1561    /// - `"1.2"` → elements\[1\]\[2\]
1562    fn resolve_field_path(segment: &AssembledSegment, path: &[&str]) -> Option<String> {
1563        if path.is_empty() {
1564            return None;
1565        }
1566
1567        // Numeric paths only: index-based resolution.
1568        if let Ok(element_idx) = path[0].parse::<usize>() {
1569            let component_idx = if path.len() > 1 {
1570                path[1].parse::<usize>().unwrap_or(0)
1571            } else {
1572                0
1573            };
1574            return segment
1575                .elements
1576                .get(element_idx)?
1577                .get(component_idx)
1578                .filter(|v| !v.is_empty())
1579                .cloned();
1580        }
1581
1582        // Non-numeric path[0] indicates an EDIFACT ID path that the PathResolver
1583        // failed to normalize (e.g. composite/element absent from any loaded PID
1584        // schema). Returning None lets the field be omitted from output instead
1585        // of silently guessing element index 1, which previously surfaced
1586        // unrelated data (e.g. NAD c819.d3229 read as c082.d3039 / rollencodenummer).
1587        None
1588    }
1589
1590    /// Parse element and component indices from path parts after the segment tag.
1591    /// E.g., ["2"] -> (2, 0), ["0", "3"] -> (0, 3), ["1", "0"] -> (1, 0)
1592    pub(crate) fn parse_element_component(parts: &[&str]) -> (usize, usize) {
1593        if parts.is_empty() {
1594            return (0, 0);
1595        }
1596        let element_idx = parts[0].parse::<usize>().unwrap_or(0);
1597        let component_idx = if parts.len() > 1 {
1598            parts[1].parse::<usize>().unwrap_or(0)
1599        } else {
1600            0
1601        };
1602        (element_idx, component_idx)
1603    }
1604
1605    /// Extract a value from a BO4E JSON object by target field name.
1606    /// Supports dotted paths like "nested.field_name".
1607    pub fn populate_field(
1608        &self,
1609        bo4e_value: &serde_json::Value,
1610        target_field: &str,
1611    ) -> Option<String> {
1612        let mut current = bo4e_value;
1613        for part in target_field.split('.') {
1614            current = current.get(part)?;
1615        }
1616        // Handle enriched code objects: {"code": "Z15", "meaning": "..."}
1617        if let Some(code) = current.get("code").and_then(|v| v.as_str()) {
1618            return Some(code.to_string());
1619        }
1620        current.as_str().map(|s| s.to_string())
1621    }
1622
1623    /// Build a segment from BO4E values using the reverse mapping.
1624    pub fn build_segment_from_bo4e(
1625        &self,
1626        bo4e_value: &serde_json::Value,
1627        segment_tag: &str,
1628        target_field: &str,
1629    ) -> AssembledSegment {
1630        let value = self.populate_field(bo4e_value, target_field);
1631        let elements = if let Some(val) = value {
1632            vec![vec![val]]
1633        } else {
1634            vec![]
1635        };
1636        AssembledSegment {
1637            tag: segment_tag.to_uppercase(),
1638            elements,
1639            mig_number: None,
1640            segment_number: None,
1641        }
1642    }
1643
1644    // ── Multi-entity forward mapping ──
1645
1646    /// Parse a discriminator string (e.g., "SEQ.0.0=Z79") and find the matching
1647    /// repetition index within the given group path.
1648    ///
1649    /// Discriminator format: `"TAG.element_idx.component_idx=expected_value"`
1650    /// Scans all repetitions of the leaf group and returns the first rep index
1651    /// where the entry segment matches.
1652    pub fn resolve_repetition(
1653        tree: &AssembledTree,
1654        group_path: &str,
1655        discriminator: &str,
1656    ) -> Option<usize> {
1657        let (spec, expected) = discriminator.split_once('=')?;
1658        let parts: Vec<&str> = spec.split('.').collect();
1659        if parts.len() != 3 {
1660            return None;
1661        }
1662        let tag = parts[0];
1663        let element_idx: usize = parts[1].parse().ok()?;
1664        let component_idx: usize = parts[2].parse().ok()?;
1665
1666        // Navigate to the parent and get the leaf group with all its repetitions
1667        let path_parts: Vec<&str> = group_path.split('.').collect();
1668
1669        let leaf_group = if path_parts.len() == 1 {
1670            let (group_id, _) = parse_group_spec(path_parts[0]);
1671            tree.groups.iter().find(|g| g.group_id == group_id)?
1672        } else {
1673            // Navigate to the parent instance, then find the leaf group
1674            let parent_parts = &path_parts[..path_parts.len() - 1];
1675            let mut current_instance = {
1676                let (first_id, first_rep) = parse_group_spec(parent_parts[0]);
1677                let first_group = tree.groups.iter().find(|g| g.group_id == first_id)?;
1678                first_group.repetitions.get(first_rep.unwrap_or(0))?
1679            };
1680            for part in &parent_parts[1..] {
1681                let (group_id, explicit_rep) = parse_group_spec(part);
1682                let child_group = current_instance
1683                    .child_groups
1684                    .iter()
1685                    .find(|g| g.group_id == group_id)?;
1686                current_instance = child_group.repetitions.get(explicit_rep.unwrap_or(0))?;
1687            }
1688            let (leaf_id, _) = parse_group_spec(path_parts.last()?);
1689            current_instance
1690                .child_groups
1691                .iter()
1692                .find(|g| g.group_id == leaf_id)?
1693        };
1694
1695        // Scan all repetitions for the matching discriminator
1696        let expected_values: Vec<&str> = expected.split('|').collect();
1697        for (rep_idx, instance) in leaf_group.repetitions.iter().enumerate() {
1698            let matches = instance.segments.iter().any(|s| {
1699                s.tag.eq_ignore_ascii_case(tag)
1700                    && s.elements
1701                        .get(element_idx)
1702                        .and_then(|e| e.get(component_idx))
1703                        .map(|v| expected_values.iter().any(|ev| v == ev))
1704                        .unwrap_or(false)
1705            });
1706            if matches {
1707                return Some(rep_idx);
1708            }
1709        }
1710
1711        None
1712    }
1713
1714    /// Like `resolve_repetition`, but returns ALL matching rep indices instead of just the first.
1715    ///
1716    /// This is used for multi-Zeitscheibe support where multiple SG6 reps may match
1717    /// the same discriminator (e.g., multiple RFF+Z49 time slices).
1718    pub fn resolve_all_repetitions(
1719        tree: &AssembledTree,
1720        group_path: &str,
1721        discriminator: &str,
1722    ) -> Vec<usize> {
1723        let Some((spec, expected)) = discriminator.split_once('=') else {
1724            return Vec::new();
1725        };
1726        let parts: Vec<&str> = spec.split('.').collect();
1727        if parts.len() != 3 {
1728            return Vec::new();
1729        }
1730        let tag = parts[0];
1731        let element_idx: usize = match parts[1].parse() {
1732            Ok(v) => v,
1733            Err(_) => return Vec::new(),
1734        };
1735        let component_idx: usize = match parts[2].parse() {
1736            Ok(v) => v,
1737            Err(_) => return Vec::new(),
1738        };
1739
1740        // Navigate to the parent and get the leaf group with all its repetitions
1741        let path_parts: Vec<&str> = group_path.split('.').collect();
1742
1743        let leaf_group = if path_parts.len() == 1 {
1744            let (group_id, _) = parse_group_spec(path_parts[0]);
1745            match tree.groups.iter().find(|g| g.group_id == group_id) {
1746                Some(g) => g,
1747                None => return Vec::new(),
1748            }
1749        } else {
1750            let parent_parts = &path_parts[..path_parts.len() - 1];
1751            let mut current_instance = {
1752                let (first_id, first_rep) = parse_group_spec(parent_parts[0]);
1753                let first_group = match tree.groups.iter().find(|g| g.group_id == first_id) {
1754                    Some(g) => g,
1755                    None => return Vec::new(),
1756                };
1757                match first_group.repetitions.get(first_rep.unwrap_or(0)) {
1758                    Some(i) => i,
1759                    None => return Vec::new(),
1760                }
1761            };
1762            for part in &parent_parts[1..] {
1763                let (group_id, explicit_rep) = parse_group_spec(part);
1764                let child_group = match current_instance
1765                    .child_groups
1766                    .iter()
1767                    .find(|g| g.group_id == group_id)
1768                {
1769                    Some(g) => g,
1770                    None => return Vec::new(),
1771                };
1772                current_instance = match child_group.repetitions.get(explicit_rep.unwrap_or(0)) {
1773                    Some(i) => i,
1774                    None => return Vec::new(),
1775                };
1776            }
1777            let (leaf_id, _) = match path_parts.last() {
1778                Some(p) => parse_group_spec(p),
1779                None => return Vec::new(),
1780            };
1781            match current_instance
1782                .child_groups
1783                .iter()
1784                .find(|g| g.group_id == leaf_id)
1785            {
1786                Some(g) => g,
1787                None => return Vec::new(),
1788            }
1789        };
1790
1791        // Parse optional occurrence index from expected value: "TN#1" → ("TN", Some(1))
1792        let (expected_raw, occurrence) = parse_discriminator_occurrence(expected);
1793
1794        // Collect ALL matching rep indices
1795        let expected_values: Vec<&str> = expected_raw.split('|').collect();
1796        let mut result = Vec::new();
1797        for (rep_idx, instance) in leaf_group.repetitions.iter().enumerate() {
1798            let matches = instance.segments.iter().any(|s| {
1799                s.tag.eq_ignore_ascii_case(tag)
1800                    && s.elements
1801                        .get(element_idx)
1802                        .and_then(|e| e.get(component_idx))
1803                        .map(|v| expected_values.iter().any(|ev| v == ev))
1804                        .unwrap_or(false)
1805            });
1806            if matches {
1807                result.push(rep_idx);
1808            }
1809        }
1810
1811        // If occurrence index specified, return only that match
1812        if let Some(occ) = occurrence {
1813            result.into_iter().nth(occ).into_iter().collect()
1814        } else {
1815            result
1816        }
1817    }
1818
1819    /// Resolve a discriminated instance using source_path for parent navigation.
1820    ///
1821    /// Like `resolve_repetition` + `resolve_group_instance`, but navigates to the
1822    /// parent group via source_path qualifier suffixes. Returns the matching instance
1823    /// directly (not just a rep index) to avoid re-navigation in `map_forward_inner`.
1824    ///
1825    /// For example, `source_path = "sg4.sg8_z98.sg10"` with `discriminator = "CCI.2.0=ZB3"`
1826    /// navigates to the SG8 instance with SEQ qualifier Z98, then finds the SG10 rep
1827    /// where CCI element 2 component 0 equals "ZB3".
1828    /// Map all definitions against a tree, returning a JSON object with entity names as keys.
1829    ///
1830    /// For each definition:
1831    /// - Has discriminator → find matching rep via `resolve_repetition`, map single instance
1832    /// - Root-level (empty source_group) → map rep 0 as single object
1833    /// - No discriminator, 1 rep in tree → map as single object
1834    /// - No discriminator, multiple reps in tree → map ALL reps into a JSON array
1835    ///
1836    /// When multiple definitions share the same `entity` name, their fields are
1837    /// deep-merged into a single JSON object. This allows related TOML files
1838    /// (e.g., LOC location + SEQ info + SG10 characteristics) to contribute
1839    /// fields to the same BO4E entity.
1840    pub fn map_all_forward(&self, tree: &AssembledTree) -> serde_json::Value {
1841        self.map_all_forward_inner(tree, true).0
1842    }
1843
1844    /// Like [`map_all_forward`](Self::map_all_forward) but with explicit
1845    /// `enrich_codes` control (when `false`, code fields are plain strings
1846    /// instead of `{"code": …, "meaning": …}` objects).
1847    pub fn map_all_forward_enriched(
1848        &self,
1849        tree: &AssembledTree,
1850        enrich_codes: bool,
1851    ) -> serde_json::Value {
1852        self.map_all_forward_inner(tree, enrich_codes).0
1853    }
1854
1855    /// Inner implementation with enrichment control.
1856    ///
1857    /// Returns `(json_value, nesting_info)`, where `nesting_info` maps entity
1858    /// keys to the parent rep index for each child element (used by the reverse
1859    /// mapper to distribute nested group children among their parent reps).
1860    fn map_all_forward_inner(
1861        &self,
1862        tree: &AssembledTree,
1863        enrich_codes: bool,
1864    ) -> (
1865        serde_json::Value,
1866        std::collections::HashMap<String, Vec<usize>>,
1867    ) {
1868        self.map_all_forward_inner_with_tx(tree, enrich_codes, self.transaction_group.as_deref())
1869    }
1870
1871    /// Like `map_all_forward_inner` but with an explicit transaction-group
1872    /// override. Used by `map_interchange`, which knows the tx group even when
1873    /// the caller-supplied tx_engine wasn't built with `with_transaction_group`.
1874    fn map_all_forward_inner_with_tx(
1875        &self,
1876        tree: &AssembledTree,
1877        enrich_codes: bool,
1878        tx_group_override: Option<&str>,
1879    ) -> (
1880        serde_json::Value,
1881        std::collections::HashMap<String, Vec<usize>>,
1882    ) {
1883        let mut result = serde_json::Map::new();
1884        let mut nesting_info: std::collections::HashMap<String, Vec<usize>> =
1885            std::collections::HashMap::new();
1886
1887        for def in &self.definitions {
1888            // `parent_field` children are mapped inside their parent's instance
1889            // (see `extract_nested_children`), never as top-level entities.
1890            if def.meta.parent_field.is_some() {
1891                continue;
1892            }
1893            let entity = &def.meta.entity;
1894
1895            let bo4e = if let Some(ref disc) = def.meta.discriminator {
1896                // Has discriminator — resolve to matching rep(s).
1897                // Use source_path navigation when qualifiers are present
1898                // (e.g., "sg4.sg8_z98.sg10" navigates to Z98's SG10 reps,
1899                //  "sg4.sg5_z17" finds all LOC+Z17 when there are multiple).
1900                let use_source_path = def
1901                    .meta
1902                    .source_path
1903                    .as_ref()
1904                    .is_some_and(|sp| has_source_path_qualifiers(sp));
1905                if use_source_path {
1906                    // Navigate via source_path, then filter by discriminator.
1907                    let sp = def.meta.source_path.as_deref().unwrap();
1908                    let all_instances = Self::resolve_all_by_source_path(tree, sp);
1909                    // Apply discriminator filter to resolved instances (respects #N occurrence)
1910                    let instances: Vec<_> = if let Some(matcher) = DiscriminatorMatcher::parse(disc)
1911                    {
1912                        matcher.filter_instances(all_instances)
1913                    } else {
1914                        all_instances
1915                    };
1916                    let extract = |instance: &AssembledGroupInstance| {
1917                        let mut r = serde_json::Map::new();
1918                        self.extract_fields_from_instance(instance, def, &mut r, enrich_codes);
1919                        serde_json::Value::Object(r)
1920                    };
1921                    match instances.len() {
1922                        0 => None,
1923                        1 => Some(extract(instances[0])),
1924                        _ => Some(serde_json::Value::Array(
1925                            instances.iter().map(|i| extract(i)).collect(),
1926                        )),
1927                    }
1928                } else {
1929                    let reps = Self::resolve_all_repetitions(tree, &def.meta.source_group, disc);
1930                    match reps.len() {
1931                        0 => None,
1932                        1 => Some(self.map_forward_inner(tree, def, reps[0], enrich_codes)),
1933                        _ => Some(serde_json::Value::Array(
1934                            reps.iter()
1935                                .map(|&rep| self.map_forward_inner(tree, def, rep, enrich_codes))
1936                                .collect(),
1937                        )),
1938                    }
1939                }
1940            } else if def.meta.source_group.is_empty() {
1941                // Root-level mapping — always single object
1942                Some(self.map_forward_inner(tree, def, 0, enrich_codes))
1943            } else if def.meta.source_path.as_ref().is_some_and(|sp| {
1944                has_source_path_qualifiers(sp) || def.meta.source_group.contains('.')
1945            }) {
1946                // Multi-level source path — navigate via source_path to collect all
1947                // instances across all parent repetitions. Handles both qualified
1948                // paths (e.g., "sg4.sg8_zd7.sg10") and unqualified paths (e.g.,
1949                // "sg17.sg36.sg40") where multiple parent reps each have children.
1950                let sp = def.meta.source_path.as_deref().unwrap();
1951                let mut indexed = Self::resolve_all_with_parent_indices(tree, sp);
1952
1953                // When the LAST part of source_path has no qualifier (e.g., "sg29.sg30"),
1954                // exclude reps that match a qualified sibling definition's qualifier
1955                // (e.g., "sg29.sg30_z35"). This prevents double-extraction when both
1956                // qualified and unqualified definitions target the same group.
1957                if let Some(last_part) = sp.rsplit('.').next() {
1958                    if !last_part.contains('_') {
1959                        // Collect qualifiers from sibling definitions that share the
1960                        // same base group name. E.g., for "sg29.sg30", only match
1961                        // "sg29.sg30_z35" (same base "sg30"), NOT "sg29.sg31_z35".
1962                        let base_prefix = if let Some(parent) = sp.rsplit_once('.') {
1963                            format!("{}.", parent.0)
1964                        } else {
1965                            String::new()
1966                        };
1967                        let sibling_qualifiers: Vec<String> = self
1968                            .definitions
1969                            .iter()
1970                            .filter_map(|d| d.meta.source_path.as_deref())
1971                            .filter(|other_sp| {
1972                                *other_sp != sp
1973                                    && other_sp.starts_with(&base_prefix)
1974                                    && other_sp.split('.').count() == sp.split('.').count()
1975                            })
1976                            .filter_map(|other_sp| {
1977                                let other_last = other_sp.rsplit('.').next()?;
1978                                // Only match siblings with the same base group name
1979                                // e.g., "sg30_z35" has base "sg30", must match "sg30"
1980                                let (base, q) = other_last.split_once('_')?;
1981                                if base == last_part {
1982                                    Some(q.to_string())
1983                                } else {
1984                                    None
1985                                }
1986                            })
1987                            .collect();
1988
1989                        if !sibling_qualifiers.is_empty() {
1990                            indexed.retain(|(_, inst)| {
1991                                let entry_qual = inst
1992                                    .segments
1993                                    .first()
1994                                    .and_then(|seg| seg.elements.first())
1995                                    .and_then(|el| el.first())
1996                                    .map(|v| v.to_lowercase());
1997                                // Keep reps whose entry qualifier does NOT match
1998                                // any sibling's qualifier
1999                                !entry_qual.is_some_and(|q| {
2000                                    sibling_qualifiers.iter().any(|sq| {
2001                                        sq.split('_').any(|part| part.eq_ignore_ascii_case(&q))
2002                                    })
2003                                })
2004                            });
2005                        }
2006                    }
2007                }
2008                let extract = |instance: &AssembledGroupInstance| {
2009                    let mut r = serde_json::Map::new();
2010                    self.extract_fields_from_instance(instance, def, &mut r, enrich_codes);
2011                    serde_json::Value::Object(r)
2012                };
2013                // Track parent rep indices for nesting reconstruction.
2014                // Key by source_path (not entity or source_group) so that definitions
2015                // at different depths or with different qualifiers don't collide.
2016                // e.g., "sg5.sg8_z41.sg9" vs "sg5.sg8_z42.sg9" are distinct keys.
2017                if def.meta.source_group.contains('.') && !indexed.is_empty() {
2018                    if let Some(sp) = &def.meta.source_path {
2019                        let parent_indices: Vec<usize> =
2020                            indexed.iter().map(|(idx, _)| *idx).collect();
2021                        nesting_info.entry(sp.clone()).or_insert(parent_indices);
2022
2023                        // Also store child rep indices (position within the leaf group)
2024                        // for depth-1 reverse placement. Key: "{sp}#child".
2025                        let child_key = format!("{sp}#child");
2026                        if let std::collections::hash_map::Entry::Vacant(e) =
2027                            nesting_info.entry(child_key)
2028                        {
2029                            let child_indices: Vec<usize> =
2030                                Self::compute_child_indices(tree, sp, &indexed);
2031                            if !child_indices.is_empty() {
2032                                e.insert(child_indices);
2033                            }
2034                        }
2035                    }
2036                }
2037                match indexed.len() {
2038                    0 => None,
2039                    1 => Some(extract(indexed[0].1)),
2040                    _ => Some(serde_json::Value::Array(
2041                        indexed.iter().map(|(_, i)| extract(i)).collect(),
2042                    )),
2043                }
2044            } else {
2045                let num_reps = Self::count_repetitions(tree, &def.meta.source_group);
2046                if num_reps <= 1 {
2047                    Some(self.map_forward_inner(tree, def, 0, enrich_codes))
2048                } else {
2049                    // Multiple reps, no discriminator — map all into array
2050                    let mut items = Vec::with_capacity(num_reps);
2051                    for rep in 0..num_reps {
2052                        items.push(self.map_forward_inner(tree, def, rep, enrich_codes));
2053                    }
2054                    Some(serde_json::Value::Array(items))
2055                }
2056            };
2057
2058            if let Some(bo4e) = bo4e {
2059                let key = to_camel_case(entity);
2060                match def.meta.target_list.as_deref() {
2061                    Some(list_field) => append_to_list_field(&mut result, &key, list_field, bo4e),
2062                    None => deep_merge_insert(&mut result, &key, bo4e),
2063                }
2064            }
2065        }
2066
2067        // Post-process: nest child entities under their parent entities.
2068        // E.g., Kontakt (source_group="SG2.SG3") moves under Marktteilnehmer (source_group="SG2").
2069        // Children whose parent group is the transaction root (e.g. SG4 for UTILMD) are
2070        // left at the top level — see MappingEngine::transaction_group.
2071        nest_child_entities_in_result(
2072            &mut result,
2073            &self.definitions,
2074            &nesting_info,
2075            tx_group_override,
2076        );
2077
2078        (serde_json::Value::Object(result), nesting_info)
2079    }
2080
2081    /// Reverse-map a BO4E entity map back to an AssembledTree.
2082    ///
2083    /// For each definition:
2084    /// 1. Look up entity in input by `meta.entity` name
2085    /// 2. If entity value is an array, map each element as a separate group repetition
2086    /// 3. Place results by `source_group`: `""` → root segments, `"SGn"` → groups
2087    ///
2088    /// This is the inverse of `map_all_forward()`.
2089    pub fn map_all_reverse(
2090        &self,
2091        entities: &serde_json::Value,
2092        nesting_info: Option<&std::collections::HashMap<String, Vec<usize>>>,
2093    ) -> AssembledTree {
2094        self.map_all_reverse_with_mig(entities, nesting_info, None)
2095    }
2096
2097    /// [`map_all_reverse`](Self::map_all_reverse) with the PID-filtered MIG,
2098    /// which decides the parent of a nested child entity the BO4E JSON does
2099    /// not link to a parent (see the nesting step below).
2100    pub fn map_all_reverse_with_mig(
2101        &self,
2102        entities: &serde_json::Value,
2103        nesting_info: Option<&std::collections::HashMap<String, Vec<usize>>>,
2104        mig: Option<&MigSchema>,
2105    ) -> AssembledTree {
2106        let mut root_segments: Vec<AssembledSegment> = Vec::new();
2107        let mut groups: Vec<AssembledGroup> = Vec::new();
2108        // Track parent rep indices for child entities extracted from map-keyed
2109        // or array parents.  Used as fallback when nesting_info is empty.
2110        let mut inferred_nesting: std::collections::HashMap<String, Vec<usize>> =
2111            std::collections::HashMap::new();
2112
2113        for def in &self.definitions {
2114            // `parent_field` children are reversed with their parent object
2115            // (see `reverse_nested_children`).
2116            if def.meta.parent_field.is_some() {
2117                continue;
2118            }
2119            let entity_key = to_camel_case(&def.meta.entity);
2120
2121            // Look up entity value — first at top level, then nested under parent.
2122            // `_extracted` keeps the owned value alive for the borrow below.
2123            let _extracted: Option<serde_json::Value>;
2124            let entity_value = if let Some(list_field) = def.meta.target_list.as_deref() {
2125                // `target_list`: this definition's data is not the entity object,
2126                // it is the elements of a list field on it. Handing the array
2127                // straight to the array branch below turns each element back into
2128                // one group repetition, which is the exact inverse of the forward
2129                // "one repetition -> one element" rule.
2130                match entities.get(&entity_key).and_then(|e| e.get(list_field)) {
2131                    Some(v) if v.is_array() => {
2132                        _extracted = None;
2133                        v
2134                    }
2135                    _ => continue,
2136                }
2137            } else if let Some(v) = entities.get(&entity_key) {
2138                _extracted = None;
2139                v
2140            } else if def.meta.source_group.contains('.') {
2141                // Child entity not at top level — try extracting from parent entity
2142                match extract_child_from_parent_with_indices(entities, &self.definitions, def) {
2143                    Some((v, parent_indices)) => {
2144                        // Record inferred parent rep indices for nesting distribution
2145                        if let Some(sp) = def.meta.source_path.as_deref() {
2146                            inferred_nesting
2147                                .entry(sp.to_string())
2148                                .or_insert(parent_indices);
2149                        }
2150                        _extracted = Some(v);
2151                        _extracted.as_ref().unwrap()
2152                    }
2153                    None => continue,
2154                }
2155            } else {
2156                continue;
2157            };
2158
2159            // Support map-keyed entities from typed PID format.
2160            // E.g., geschaeftspartner: {"Z04": {name1: "..."}} with discriminator NAD.0.0=Z04.
2161            // Extract inner value using discriminator's qualifier value as key,
2162            // and inject the qualifier into the inner object so companion fields find it.
2163            //
2164            // Also handles non-discriminated maps (e.g., marktteilnehmer: {"MS": {...}, "MR": {...}})
2165            // by converting them to arrays of inner values.
2166            let unwrapped: Option<serde_json::Value>;
2167            let entity_value = if entity_value.is_object() && !entity_value.is_array() {
2168                if let Some(disc_value) = def
2169                    .meta
2170                    .discriminator
2171                    .as_deref()
2172                    .and_then(|d| d.split_once('='))
2173                    .map(|(_, v)| v)
2174                {
2175                    // Discriminated definition: try to extract map key matching qualifier
2176                    if let Some(inner) = entity_value.get(disc_value) {
2177                        let mut injected = inner.clone();
2178                        // Find the field that maps to the discriminator's EDIFACT path
2179                        // and inject the map key as that field's value (e.g., nadQualifier = "Z04")
2180                        if let Some(qualifier_field) =
2181                            find_qualifier_companion_field(&self.definitions, &def.meta.entity)
2182                        {
2183                            if let Some(obj) = injected.as_object_mut() {
2184                                let entry = obj
2185                                    .entry(qualifier_field)
2186                                    .or_insert(serde_json::Value::Null);
2187                                if entry.is_null() {
2188                                    *entry = serde_json::Value::String(disc_value.to_string());
2189                                }
2190                            }
2191                        }
2192                        unwrapped = Some(injected);
2193                        unwrapped.as_ref().unwrap()
2194                    } else {
2195                        entity_value
2196                    }
2197                } else if is_map_keyed_object(entity_value) {
2198                    // Non-discriminated definition: convert map to array
2199                    // e.g., marktteilnehmer: {"MS": {...}, "MR": {...}} → [{...}, {...}]
2200                    // Inject each map key into its inner object using the companion field
2201                    // that maps to the discriminator path (if identifiable from other defs).
2202                    let map = entity_value.as_object().unwrap();
2203                    let arr: Vec<serde_json::Value> = map
2204                        .iter()
2205                        .map(|(key, val)| {
2206                            let mut item = val.clone();
2207                            // Try to find a qualifier companion field from peer definitions
2208                            // that share this entity name and have a discriminator
2209                            if let Some(obj) = item.as_object_mut() {
2210                                if let Some(qualifier_field) = find_qualifier_companion_field(
2211                                    &self.definitions,
2212                                    &def.meta.entity,
2213                                ) {
2214                                    let entry = obj
2215                                        .entry(qualifier_field)
2216                                        .or_insert(serde_json::Value::Null);
2217                                    if entry.is_null() {
2218                                        *entry = serde_json::Value::String(key.clone());
2219                                    }
2220                                }
2221                            }
2222                            item
2223                        })
2224                        .collect();
2225                    unwrapped = Some(serde_json::Value::Array(arr));
2226                    unwrapped.as_ref().unwrap()
2227                } else {
2228                    entity_value
2229                }
2230            } else {
2231                entity_value
2232            };
2233
2234            // Determine target group from source_group (use leaf part after last dot)
2235            let leaf_group = def
2236                .meta
2237                .source_group
2238                .rsplit('.')
2239                .next()
2240                .unwrap_or(&def.meta.source_group);
2241
2242            if def.meta.source_group.is_empty() {
2243                // Root-level: reverse into root segments
2244                let instance = self.map_reverse(entity_value, def);
2245                root_segments.extend(instance.segments);
2246            } else if entity_value.is_array() {
2247                // Array entity: each element becomes a group repetition
2248                let arr = entity_value.as_array().unwrap();
2249                let reps: Vec<_> = arr.iter().map(|item| self.map_reverse(item, def)).collect();
2250
2251                // Merge into existing group or create new one
2252                if let Some(existing) = groups.iter_mut().find(|g| g.group_id == leaf_group) {
2253                    existing.repetitions.extend(reps);
2254                } else {
2255                    groups.push(AssembledGroup {
2256                        group_id: leaf_group.to_string(),
2257                        repetitions: reps,
2258                    });
2259                }
2260            } else {
2261                // Single object: one repetition
2262                let instance = self.map_reverse(entity_value, def);
2263
2264                if let Some(existing) = groups.iter_mut().find(|g| g.group_id == leaf_group) {
2265                    existing.repetitions.push(instance);
2266                } else {
2267                    groups.push(AssembledGroup {
2268                        group_id: leaf_group.to_string(),
2269                        repetitions: vec![instance],
2270                    });
2271                }
2272            }
2273        }
2274
2275        // Post-process: move nested groups under their parent repetitions.
2276        // Definitions with multi-level source_group (e.g., "SG2.SG3") produce
2277        // top-level groups that must be nested inside their parent group.
2278        // Children are distributed sequentially among parent reps (child[i] → parent[i])
2279        // matching the forward mapper's extraction order.
2280        let nested_specs: Vec<(String, String)> = self
2281            .definitions
2282            .iter()
2283            .filter(|def| def.meta.parent_field.is_none())
2284            .filter_map(|def| {
2285                let parts: Vec<&str> = def.meta.source_group.split('.').collect();
2286                if parts.len() > 1 {
2287                    Some((parts[0].to_string(), parts[parts.len() - 1].to_string()))
2288                } else {
2289                    None
2290                }
2291            })
2292            .collect();
2293        for (parent_id, child_id) in &nested_specs {
2294            // Only nest if both parent and child exist at the top level
2295            let has_parent = groups.iter().any(|g| g.group_id == *parent_id);
2296            let has_child = groups.iter().any(|g| g.group_id == *child_id);
2297            if has_parent && has_child {
2298                let child_idx = groups.iter().position(|g| g.group_id == *child_id).unwrap();
2299                let child_group = groups.remove(child_idx);
2300                let parent = groups
2301                    .iter_mut()
2302                    .find(|g| g.group_id == *parent_id)
2303                    .unwrap();
2304                // Distribute child reps among parent reps using nesting info
2305                // if available, falling back to all-under-first when not.
2306                // Nesting info is keyed by source_path (e.g., "sg2.sg3").
2307                let child_source_path = self
2308                    .definitions
2309                    .iter()
2310                    .find(|d| {
2311                        let parts: Vec<&str> = d.meta.source_group.split('.').collect();
2312                        d.meta.parent_field.is_none()
2313                            && parts.len() > 1
2314                            && parts[parts.len() - 1] == *child_id
2315                    })
2316                    .and_then(|d| d.meta.source_path.as_deref());
2317                let distribution = child_source_path.and_then(|key| {
2318                    nesting_info
2319                        .and_then(|ni| ni.get(key))
2320                        .or_else(|| inferred_nesting.get(key))
2321                });
2322                // Without a link from the JSON, the parent follows from the MIG:
2323                // the first repetition (in MIG variant order) whose variant
2324                // defines this child group — e.g. the SG2 NAD+MS repetition for
2325                // the sender's SG3 contact. Not "the first array element": BO4E
2326                // carries no ordering information.
2327                let unlinked_target = mig
2328                    .and_then(|m| {
2329                        mig_assembly::repetition_order::preferred_parent_repetition(
2330                            parent,
2331                            &m.segment_groups,
2332                            child_id,
2333                        )
2334                    })
2335                    .unwrap_or(0);
2336                for (i, child_rep) in child_group.repetitions.into_iter().enumerate() {
2337                    let target_idx = distribution
2338                        .and_then(|dist| dist.get(i))
2339                        .copied()
2340                        .unwrap_or(unlinked_target);
2341
2342                    if let Some(target_rep) = parent.repetitions.get_mut(target_idx) {
2343                        if let Some(existing) = target_rep
2344                            .child_groups
2345                            .iter_mut()
2346                            .find(|g| g.group_id == *child_id)
2347                        {
2348                            existing.repetitions.push(child_rep);
2349                        } else {
2350                            target_rep.child_groups.push(AssembledGroup {
2351                                group_id: child_id.clone(),
2352                                repetitions: vec![child_rep],
2353                            });
2354                        }
2355                    }
2356                }
2357            }
2358        }
2359
2360        let post_group_start = root_segments.len();
2361        AssembledTree {
2362            segments: root_segments,
2363            groups,
2364            post_group_start,
2365            inter_group_segments: std::collections::BTreeMap::new(),
2366        }
2367    }
2368
2369    /// Count the number of repetitions available for a group path in the tree.
2370    fn count_repetitions(tree: &AssembledTree, group_path: &str) -> usize {
2371        let parts: Vec<&str> = group_path.split('.').collect();
2372
2373        let (first_id, first_rep) = parse_group_spec(parts[0]);
2374        let first_group = match tree.groups.iter().find(|g| g.group_id == first_id) {
2375            Some(g) => g,
2376            None => return 0,
2377        };
2378
2379        if parts.len() == 1 {
2380            return first_group.repetitions.len();
2381        }
2382
2383        // Navigate to parent, then count leaf group reps
2384        let mut current_instance = match first_group.repetitions.get(first_rep.unwrap_or(0)) {
2385            Some(i) => i,
2386            None => return 0,
2387        };
2388
2389        for (i, part) in parts[1..].iter().enumerate() {
2390            let (group_id, explicit_rep) = parse_group_spec(part);
2391            let child_group = match current_instance
2392                .child_groups
2393                .iter()
2394                .find(|g| g.group_id == group_id)
2395            {
2396                Some(g) => g,
2397                None => return 0,
2398            };
2399
2400            if i == parts.len() - 2 {
2401                // Last part — return rep count
2402                return child_group.repetitions.len();
2403            }
2404            current_instance = match child_group.repetitions.get(explicit_rep.unwrap_or(0)) {
2405                Some(i) => i,
2406                None => return 0,
2407            };
2408        }
2409
2410        0
2411    }
2412
2413    /// Translate an assembled tree into BO4E, without code enrichment.
2414    ///
2415    /// This is the translation proper: every code field is a plain string, as it
2416    /// appears in the EDIFACT message. Enrichment (`{code, meaning, enum}`) is a
2417    /// display concern and is applied separately by [`Self::enrich_bo4e_types`],
2418    /// so a caller that does not need it never pays for it and never has to
2419    /// strip it back out.
2420    pub fn translate_edifact_to_bo4e(
2421        msg_engine: &MappingEngine,
2422        tx_engine: &MappingEngine,
2423        tree: &AssembledTree,
2424        transaction_group: &str,
2425    ) -> crate::model::MappedMessage {
2426        Self::map_interchange_inner(msg_engine, tx_engine, tree, transaction_group, false)
2427    }
2428
2429    /// Decorate code fields of an already-translated message in place.
2430    ///
2431    /// Replaces the plain string at each code position with
2432    /// `{"code": …, "meaning": …, "enum": …}`. Needs a [`CodeLookup`] on the
2433    /// engines; without one this is a no-op, which is why CI — which never
2434    /// attaches a lookup — sees the unenriched shape.
2435    ///
2436    /// Works from the mapping definitions rather than from the EDIFACT tree: a
2437    /// definition knows both where a value came from (`source_path` plus the
2438    /// segment/element coordinates of the field) and where it went (`target`),
2439    /// which is all the lookup needs. The original EDIFACT value is recovered by
2440    /// inverting `enum_map` the same way the reverse mapper does, including the
2441    /// `also_target` disambiguation for codes that share a primary value.
2442    pub fn enrich_bo4e_types(
2443        msg_engine: &MappingEngine,
2444        tx_engine: &MappingEngine,
2445        mapped: &mut crate::model::MappedMessage,
2446    ) {
2447        msg_engine.enrich_entities(&mut mapped.stammdaten);
2448        for tx in &mut mapped.transaktionen {
2449            tx_engine.enrich_entities(&mut tx.stammdaten);
2450        }
2451
2452        // The metadata slots hold mapped entities too. The forward pass splits
2453        // them out of `stammdaten`, so walking `stammdaten` alone no longer
2454        // reaches them — and their code fields would silently stay plain.
2455        msg_engine.enrich_named_entity(
2456            &mut mapped.nachricht_meta,
2457            crate::model::MSG_METADATA_ENTITY,
2458        );
2459        for tx in &mut mapped.transaktionen {
2460            tx_engine
2461                .enrich_named_entity(&mut tx.transaktionsdaten, crate::model::TX_METADATA_ENTITY);
2462        }
2463    }
2464
2465    /// Apply the code sites of one named entity to a value holding that entity.
2466    ///
2467    /// The entity-map walk keys on the enclosing object's field name; a metadata
2468    /// slot has no such name, so the entity is named explicitly here.
2469    fn enrich_named_entity(&self, value: &mut serde_json::Value, entity_key: &str) {
2470        if self.code_lookup.is_none() || value.is_null() {
2471            return;
2472        }
2473        let sites = self.code_sites();
2474        if let Some(entity_sites) = sites.get(entity_key) {
2475            Self::apply_sites(self, value, entity_sites);
2476        }
2477    }
2478
2479    /// Apply this engine's code enrichment to one entity map.
2480    ///
2481    /// Entities are located by key at any depth, because the forward pass moves
2482    /// them after extraction: `nest_child_entities_in_result` puts children
2483    /// under their parents.
2484    fn enrich_entities(&self, value: &mut serde_json::Value) {
2485        if self.code_lookup.is_none() {
2486            return;
2487        }
2488        let sites: HashMap<String, Vec<CodeSite<'_>>> = self.code_sites();
2489        if sites.is_empty() {
2490            return;
2491        }
2492        Self::walk_and_enrich(self, value, &sites);
2493    }
2494
2495    /// Every code-field position this engine's definitions write to, grouped by
2496    /// the entity key the value ends up under.
2497    fn code_sites(&self) -> HashMap<String, Vec<CodeSite<'_>>> {
2498        let Some(ref code_lookup) = self.code_lookup else {
2499            return HashMap::new();
2500        };
2501        let mut sites: HashMap<String, Vec<CodeSite<'_>>> = HashMap::new();
2502
2503        for def in &self.definitions {
2504            let Some(ref source_path) = def.meta.source_path else {
2505                continue;
2506            };
2507            let entity_key = to_camel_case(&def.meta.entity);
2508
2509            for (path, field_mapping) in &def.fields {
2510                let (target, enum_map, also_target, also_enum_map) = match field_mapping {
2511                    FieldMapping::Simple(t) => (t.as_str(), None, None, None),
2512                    FieldMapping::Structured(s) => (
2513                        s.target.as_str(),
2514                        s.enum_map.as_ref(),
2515                        s.also_target.as_deref(),
2516                        s.also_enum_map.as_ref(),
2517                    ),
2518                    FieldMapping::Nested(_) => continue,
2519                };
2520                if target.is_empty() {
2521                    continue;
2522                }
2523
2524                let parts: Vec<&str> = path.split('.').collect();
2525                let (seg_tag, path_qualifier, _occ) = parse_tag_qualifier(parts[0]);
2526                let (element_idx, component_idx) = Self::parse_element_component(&parts[1..]);
2527                // Same predicate, and the same two qualifiers, as the pre-split
2528                // path in `extract_fields_from_instance`: the field key's own
2529                // qualifier selects the schema variant, the discriminator's only
2530                // where the key has none. Asking with one merged qualifier — as
2531                // this did — calls `cav[Z30]`'s device number a code field and
2532                // decorates it, which the pre-split path never did.
2533                let disc_qualifier = Self::discriminator_qualifier_for_tag(def, &seg_tag);
2534                if code_lookup
2535                    .enrichment_codes(
2536                        source_path,
2537                        &seg_tag,
2538                        path_qualifier,
2539                        disc_qualifier.as_deref(),
2540                        element_idx,
2541                        component_idx,
2542                    )
2543                    .is_none()
2544                {
2545                    continue;
2546                }
2547
2548                sites.entry(entity_key.clone()).or_default().push(CodeSite {
2549                    target,
2550                    parent_field: def.meta.parent_field.as_deref(),
2551                    source_path,
2552                    seg_tag,
2553                    path_qualifier: path_qualifier.map(str::to_string),
2554                    disc_qualifier,
2555                    element_idx,
2556                    component_idx,
2557                    enum_map,
2558                    also_target,
2559                    also_enum_map,
2560                });
2561            }
2562        }
2563        sites
2564    }
2565
2566    /// Descend through the result, enriching every object that sits under a key
2567    /// naming an entity this engine maps.
2568    fn walk_and_enrich(
2569        engine: &MappingEngine,
2570        value: &mut serde_json::Value,
2571        sites: &HashMap<String, Vec<CodeSite<'_>>>,
2572    ) {
2573        match value {
2574            serde_json::Value::Object(map) => {
2575                for (key, child) in map.iter_mut() {
2576                    if let Some(entity_sites) = sites.get(key.as_str()) {
2577                        Self::apply_sites(engine, child, entity_sites);
2578                    }
2579                    Self::walk_and_enrich(engine, child, sites);
2580                }
2581            }
2582            serde_json::Value::Array(items) => {
2583                for item in items.iter_mut() {
2584                    Self::walk_and_enrich(engine, item, sites);
2585                }
2586            }
2587            _ => {}
2588        }
2589    }
2590
2591    /// Apply one entity's code sites to an entity value (an object, or an array
2592    /// of them when the group repeats).
2593    fn apply_sites(engine: &MappingEngine, value: &mut serde_json::Value, sites: &[CodeSite<'_>]) {
2594        match value {
2595            serde_json::Value::Array(items) => {
2596                for item in items.iter_mut() {
2597                    Self::apply_sites(engine, item, sites);
2598                }
2599            }
2600            serde_json::Value::Object(_) => {
2601                for site in sites {
2602                    match site.parent_field {
2603                        None => engine.enrich_one(value, site),
2604                        Some(field) => {
2605                            if let Some(nested) = value.get_mut(field) {
2606                                Self::apply_nested_site(engine, nested, site);
2607                            }
2608                        }
2609                    }
2610                }
2611            }
2612            _ => {}
2613        }
2614    }
2615
2616    /// Apply one nested site to every element of the `parent_field` array.
2617    fn apply_nested_site(
2618        engine: &MappingEngine,
2619        value: &mut serde_json::Value,
2620        site: &CodeSite<'_>,
2621    ) {
2622        match value {
2623            serde_json::Value::Array(items) => {
2624                for item in items.iter_mut() {
2625                    Self::apply_nested_site(engine, item, site);
2626                }
2627            }
2628            serde_json::Value::Object(_) => engine.enrich_one(value, site),
2629            _ => {}
2630        }
2631    }
2632
2633    /// Enrich a single position, if it currently holds a plain string.
2634    fn enrich_one(&self, entity: &mut serde_json::Value, site: &CodeSite<'_>) {
2635        let Some(ref code_lookup) = self.code_lookup else {
2636            return;
2637        };
2638        // Already an object means another definition enriched this position.
2639        let Some(mapped_val) = Self::read_plain_string(entity, site.target) else {
2640            return;
2641        };
2642
2643        // Recover the EDIFACT value: the schema's codes are raw ("293"), while
2644        // the JSON holds the enum_map target ("BDEW").
2645        let raw = match site.enum_map {
2646            None => mapped_val.clone(),
2647            Some(map) => {
2648                let joint = match (site.also_target, site.also_enum_map) {
2649                    (Some(also), Some(also_map)) => {
2650                        Self::read_plain_string(entity, also).and_then(|also_v| {
2651                            map.iter()
2652                                .find(|(code, bo4e_v)| {
2653                                    *bo4e_v == &mapped_val && also_map.get(*code) == Some(&also_v)
2654                                })
2655                                .map(|(code, _)| code.clone())
2656                        })
2657                    }
2658                    _ => None,
2659                };
2660                joint
2661                    .or_else(|| {
2662                        map.iter()
2663                            .find(|(_, bo4e_v)| *bo4e_v == &mapped_val)
2664                            .map(|(code, _)| code.clone())
2665                    })
2666                    .unwrap_or_else(|| mapped_val.clone())
2667            }
2668        };
2669
2670        let Some(codes) = code_lookup.enrichment_codes(
2671            site.source_path,
2672            &site.seg_tag,
2673            site.path_qualifier.as_deref(),
2674            site.disc_qualifier.as_deref(),
2675            site.element_idx,
2676            site.component_idx,
2677        ) else {
2678            return;
2679        };
2680
2681        // Class C: PID self-reference stays a plain string.
2682        if let Some(ref pid) = self.current_pid {
2683            if codes.len() == 1 && codes.contains_key(pid.as_str()) {
2684                return;
2685            }
2686        }
2687
2688        let enrichment = codes.get(&raw);
2689        let meaning = enrichment
2690            .map(|e| serde_json::Value::String(e.meaning.clone()))
2691            .unwrap_or(serde_json::Value::Null);
2692
2693        let mut obj = serde_json::Map::new();
2694        obj.insert("code".into(), serde_json::json!(mapped_val));
2695        obj.insert("meaning".into(), meaning);
2696        if let Some(enum_key) = enrichment.and_then(|e| e.enum_key.as_ref()) {
2697            obj.insert("enum".into(), serde_json::json!(enum_key));
2698        }
2699
2700        if let serde_json::Value::Object(map) = entity {
2701            set_nested_value_json(map, site.target, serde_json::Value::Object(obj));
2702        }
2703    }
2704
2705    /// The string at a dotted target path, or `None` when it is absent or has
2706    /// already been replaced by an enrichment object.
2707    fn read_plain_string(entity: &serde_json::Value, target: &str) -> Option<String> {
2708        let mut current = entity;
2709        for part in target.split('.') {
2710            current = current.get(part)?;
2711        }
2712        current.as_str().map(str::to_string)
2713    }
2714
2715    /// Map an assembled tree into message-level and transaction-level results.
2716    ///
2717    /// - `msg_engine`: MappingEngine loaded with message-level definitions (SG2, SG3, root segments)
2718    /// - `tx_engine`: MappingEngine loaded with transaction-level definitions (relative to SG4)
2719    /// - `tree`: The assembled tree for one message
2720    /// - `transaction_group`: The group ID that represents transactions (e.g., "SG4")
2721    ///
2722    /// Returns a `MappedMessage` with message stammdaten and per-transaction results.
2723    pub fn map_interchange(
2724        msg_engine: &MappingEngine,
2725        tx_engine: &MappingEngine,
2726        tree: &AssembledTree,
2727        transaction_group: &str,
2728        enrich_codes: bool,
2729    ) -> crate::model::MappedMessage {
2730        let mut mapped =
2731            Self::translate_edifact_to_bo4e(msg_engine, tx_engine, tree, transaction_group);
2732        if enrich_codes {
2733            Self::enrich_bo4e_types(msg_engine, tx_engine, &mut mapped);
2734        }
2735        mapped
2736    }
2737
2738    /// The translation itself, with enrichment still inlined in the extraction.
2739    ///
2740    /// Retained so the split can be proven equivalent: `map_interchange_inner`
2741    /// with `enrich_codes = true` must produce exactly what
2742    /// `translate_edifact_to_bo4e` followed by `enrich_bo4e_types` produces.
2743    /// See `enrich_split_parity_test`.
2744    /// Test-only door onto the pre-split path, so the parity gate can compare
2745    /// the two. Not part of the public pipeline.
2746    #[doc(hidden)]
2747    pub fn map_interchange_inner_for_test(
2748        msg_engine: &MappingEngine,
2749        tx_engine: &MappingEngine,
2750        tree: &AssembledTree,
2751        transaction_group: &str,
2752        enrich_codes: bool,
2753    ) -> crate::model::MappedMessage {
2754        Self::map_interchange_inner(msg_engine, tx_engine, tree, transaction_group, enrich_codes)
2755    }
2756
2757    pub(crate) fn map_interchange_inner(
2758        msg_engine: &MappingEngine,
2759        tx_engine: &MappingEngine,
2760        tree: &AssembledTree,
2761        transaction_group: &str,
2762        enrich_codes: bool,
2763    ) -> crate::model::MappedMessage {
2764        // Map message-level entities (also captures nesting info)
2765        let (stammdaten, nesting_info) = msg_engine.map_all_forward_inner(tree, enrich_codes);
2766
2767        // Find the transaction group and map each repetition
2768        let transaktionen = tree
2769            .groups
2770            .iter()
2771            .find(|g| g.group_id == transaction_group)
2772            .map(|sg| {
2773                sg.repetitions
2774                    .iter()
2775                    .map(|instance| {
2776                        // Wrap the instance in its group so that definitions with
2777                        // source_group paths like "SG4.SG5" can resolve correctly.
2778                        let wrapped_tree = AssembledTree {
2779                            segments: vec![],
2780                            groups: vec![AssembledGroup {
2781                                group_id: transaction_group.to_string(),
2782                                repetitions: vec![instance.clone()],
2783                            }],
2784                            post_group_start: 0,
2785                            inter_group_segments: std::collections::BTreeMap::new(),
2786                        };
2787
2788                        // Pass the transaction_group into the tx_engine so its direct
2789                        // children (Marktlokation etc.) stay top-level peers of
2790                        // Prozessdaten rather than nested under it.
2791                        let (tx_result, tx_nesting) = tx_engine.map_all_forward_inner_with_tx(
2792                            &wrapped_tree,
2793                            enrich_codes,
2794                            Some(transaction_group),
2795                        );
2796
2797                        // Split the transaction's own metadata out of its
2798                        // business objects. The engine maps `Prozessdaten` like
2799                        // any other entity; it just does not belong among the
2800                        // BOs once mapped.
2801                        let mut tx_result = tx_result;
2802                        let transaktionsdaten = crate::model::take_entity(
2803                            &mut tx_result,
2804                            crate::model::TX_METADATA_ENTITY,
2805                        );
2806
2807                        crate::model::MappedTransaktion {
2808                            stammdaten: tx_result,
2809                            transaktionsdaten,
2810                            nesting_info: tx_nesting,
2811                        }
2812                    })
2813                    .collect()
2814            })
2815            .unwrap_or_default();
2816
2817        // Same split one level up: `Nachricht` is metadata about the message.
2818        let mut stammdaten = stammdaten;
2819        let nachricht_meta =
2820            crate::model::take_entity(&mut stammdaten, crate::model::MSG_METADATA_ENTITY);
2821
2822        crate::model::MappedMessage {
2823            stammdaten,
2824            nachricht_meta,
2825            transaktionen,
2826            nesting_info,
2827            inter_group_segments: tree.inter_group_segments.clone(),
2828        }
2829    }
2830
2831    /// Reverse-map a `MappedMessage` back to an `AssembledTree`.
2832    ///
2833    /// Two-engine approach mirroring `map_interchange()`:
2834    /// - `msg_engine` handles message-level stammdaten → SG2/SG3 groups
2835    /// - `tx_engine` handles per-transaction stammdaten → SG4 instances
2836    ///
2837    /// All entities (including prozessdaten/nachricht) are in `tx.stammdaten`.
2838    /// Results are merged into one `AssembledGroupInstance` per transaction,
2839    /// collected into an SG4 `AssembledGroup`, then combined with message-level groups.
2840    pub fn map_interchange_reverse(
2841        msg_engine: &MappingEngine,
2842        tx_engine: &MappingEngine,
2843        mapped: &crate::model::MappedMessage,
2844        transaction_group: &str,
2845        filtered_mig: Option<&MigSchema>,
2846    ) -> AssembledTree {
2847        // Step 1: Reverse message-level stammdaten.
2848        //
2849        // The message's metadata entity goes back in here first: the forward
2850        // pass split `Nachricht` out into its own slot, but the definitions
2851        // resolve against one flat entity map, so without this the BGM/DTM
2852        // segments it feeds cannot be rebuilt. Clone only when there is
2853        // metadata to restore — keeps the common path zero-copy.
2854        let _owned_msg: Option<serde_json::Value>;
2855        let msg_stammdaten = if !mapped.nachricht_meta.is_null() {
2856            let mut merged = mapped.stammdaten.clone();
2857            crate::model::restore_entity(
2858                &mut merged,
2859                crate::model::MSG_METADATA_ENTITY,
2860                &mapped.nachricht_meta,
2861            );
2862            _owned_msg = Some(merged);
2863            _owned_msg.as_ref().unwrap()
2864        } else {
2865            _owned_msg = None;
2866            &mapped.stammdaten
2867        };
2868
2869        let msg_tree = msg_engine.map_all_reverse_with_mig(
2870            msg_stammdaten,
2871            if mapped.nesting_info.is_empty() {
2872                None
2873            } else {
2874                Some(&mapped.nesting_info)
2875            },
2876            filtered_mig,
2877        );
2878
2879        // Step 2: Build transaction instances from each Transaktion
2880        let mut sg4_reps: Vec<AssembledGroupInstance> = Vec::new();
2881
2882        // Collect all definitions with their relative paths and sort by depth.
2883        // Shallower paths (SG8) must be processed before deeper ones (SG8:0.SG10)
2884        // so that parent group repetitions exist before children are added.
2885        struct DefWithMeta<'a> {
2886            def: &'a MappingDefinition,
2887            relative: String,
2888            depth: usize,
2889        }
2890
2891        let mut sorted_defs: Vec<DefWithMeta> = tx_engine
2892            .definitions
2893            .iter()
2894            // `parent_field` children are reversed with their parent object
2895            // (see `reverse_nested_children`).
2896            .filter(|def| def.meta.parent_field.is_none())
2897            .map(|def| {
2898                let relative = strip_tx_group_prefix(&def.meta.source_group, transaction_group);
2899                let depth = if relative.is_empty() {
2900                    0
2901                } else {
2902                    relative.chars().filter(|c| *c == '.').count() + 1
2903                };
2904                DefWithMeta {
2905                    def,
2906                    relative,
2907                    depth,
2908                }
2909            })
2910            .collect();
2911
2912        // Build parent source_path → rep_index map from deeper definitions.
2913        // SG10 defs like "SG4.SG8:0.SG10" with source_path "sg4.sg8_z79.sg10"
2914        // tell us that the SG8 def with source_path "sg4.sg8_z79" should be rep 0.
2915        let mut parent_rep_map: std::collections::HashMap<String, usize> =
2916            std::collections::HashMap::new();
2917        for dm in &sorted_defs {
2918            if dm.depth >= 2 {
2919                let parts: Vec<&str> = dm.relative.split('.').collect();
2920                let (_, parent_rep) = parse_group_spec(parts[0]);
2921                if let Some(rep_idx) = parent_rep {
2922                    if let Some(sp) = &dm.def.meta.source_path {
2923                        if let Some((parent_path, _)) = sp.rsplit_once('.') {
2924                            parent_rep_map
2925                                .entry(parent_path.to_string())
2926                                .or_insert(rep_idx);
2927                        }
2928                    }
2929                }
2930            }
2931        }
2932
2933        // Augment shallow definitions with explicit rep indices from the map,
2934        // but only for single-rep cases (no multi-rep — those use dynamic tracking).
2935        for dm in &mut sorted_defs {
2936            if dm.depth == 1 && !dm.relative.contains(':') {
2937                if let Some(sp) = &dm.def.meta.source_path {
2938                    if let Some(rep_idx) = parent_rep_map.get(sp.as_str()) {
2939                        dm.relative = format!("{}:{}", dm.relative, rep_idx);
2940                    }
2941                }
2942            }
2943        }
2944
2945        // Sort: shallower depth first, so SG8 defs create reps before SG8:N.SG10 defs.
2946        // Within same depth, sort by MIG group position (if available) for correct emission order,
2947        // falling back to alphabetical relative path for deterministic ordering.
2948        //
2949        // For variant groups (SG8 with Z01/Z03/Z07 etc.), use per-variant MIG positions
2950        // extracted from each definition's source_path qualifier suffix (e.g., "sg4.sg8_z01" → "Z01").
2951        if let Some(mig) = filtered_mig {
2952            let mig_order = build_reverse_mig_group_order(mig, transaction_group);
2953            sorted_defs.sort_by(|a, b| {
2954                a.depth.cmp(&b.depth).then_with(|| {
2955                    let a_id = a.relative.split(':').next().unwrap_or(&a.relative);
2956                    let b_id = b.relative.split(':').next().unwrap_or(&b.relative);
2957                    // Try per-variant lookup from source_path (e.g., "sg4.sg8_z01" → "SG8_Z01")
2958                    let a_pos = variant_mig_position(a.def, a_id, &mig_order);
2959                    let b_pos = variant_mig_position(b.def, b_id, &mig_order);
2960                    a_pos.cmp(&b_pos).then(a.relative.cmp(&b.relative))
2961                })
2962            });
2963        } else {
2964            sorted_defs.sort_by(|a, b| a.depth.cmp(&b.depth).then(a.relative.cmp(&b.relative)));
2965        }
2966
2967        for tx in &mapped.transaktionen {
2968            let mut root_segs: Vec<AssembledSegment> = Vec::new();
2969            let mut child_groups: Vec<AssembledGroup> = Vec::new();
2970
2971            // `transaktionsdaten` is merged back for the same reason as the
2972            // message's metadata above — the definitions expect one flat map.
2973            let _owned_tx: Option<serde_json::Value>;
2974            let tx_stammdaten: &serde_json::Value = if !tx.transaktionsdaten.is_null() {
2975                let mut merged = tx.stammdaten.clone();
2976                crate::model::restore_entity(
2977                    &mut merged,
2978                    crate::model::TX_METADATA_ENTITY,
2979                    &tx.transaktionsdaten,
2980                );
2981                _owned_tx = Some(merged);
2982                _owned_tx.as_ref().unwrap()
2983            } else {
2984                _owned_tx = None;
2985                &tx.stammdaten
2986            };
2987
2988            // Track source_path → repetition indices for parent groups (top-down).
2989            // Built during depth-1 processing, used by depth-2+ defs without
2990            // explicit rep indices to find their correct parent via source_path.
2991            // Vec<usize> supports multi-rep parents (e.g., two SG8+ZF3 reps).
2992            let mut source_path_to_rep: std::collections::HashMap<String, Vec<usize>> =
2993                std::collections::HashMap::new();
2994
2995            for dm in &sorted_defs {
2996                // Determine the BO4E value to reverse-map from.
2997                // Check top level first, then nested under parent entity.
2998                let entity_key = to_camel_case(&dm.def.meta.entity);
2999                let _tx_extracted: Option<serde_json::Value>;
3000                let bo4e_value = if let Some(v) = tx_stammdaten.get(&entity_key) {
3001                    _tx_extracted = None;
3002                    v
3003                } else if dm.def.meta.source_group.contains('.') {
3004                    match extract_child_from_parent(tx_stammdaten, &tx_engine.definitions, dm.def) {
3005                        Some(v) => {
3006                            _tx_extracted = Some(v);
3007                            _tx_extracted.as_ref().unwrap()
3008                        }
3009                        None => continue,
3010                    }
3011                } else {
3012                    continue;
3013                };
3014
3015                // Support map-keyed entities from typed PID format (same logic as map_all_reverse).
3016                let unwrapped_value: Option<serde_json::Value>;
3017                let bo4e_value = if bo4e_value.is_object() && !bo4e_value.is_array() {
3018                    if let Some(disc_value) = dm
3019                        .def
3020                        .meta
3021                        .discriminator
3022                        .as_deref()
3023                        .and_then(|d| d.split_once('='))
3024                        .map(|(_, v)| v)
3025                    {
3026                        if let Some(inner) = bo4e_value.get(disc_value) {
3027                            let mut injected = inner.clone();
3028                            if let Some(qualifier_field) = find_qualifier_companion_field(
3029                                &tx_engine.definitions,
3030                                &dm.def.meta.entity,
3031                            ) {
3032                                if let Some(obj) = injected.as_object_mut() {
3033                                    obj.entry(qualifier_field).or_insert_with(|| {
3034                                        serde_json::Value::String(disc_value.to_string())
3035                                    });
3036                                }
3037                            }
3038                            unwrapped_value = Some(injected);
3039                            unwrapped_value.as_ref().unwrap()
3040                        } else {
3041                            bo4e_value
3042                        }
3043                    } else if is_map_keyed_object(bo4e_value) {
3044                        let map = bo4e_value.as_object().unwrap();
3045                        let arr: Vec<serde_json::Value> = map
3046                            .iter()
3047                            .map(|(key, val)| {
3048                                let mut item = val.clone();
3049                                if let Some(obj) = item.as_object_mut() {
3050                                    if let Some(qualifier_field) = find_qualifier_companion_field(
3051                                        &tx_engine.definitions,
3052                                        &dm.def.meta.entity,
3053                                    ) {
3054                                        let entry = obj
3055                                            .entry(qualifier_field)
3056                                            .or_insert(serde_json::Value::Null);
3057                                        if entry.is_null() {
3058                                            *entry = serde_json::Value::String(key.clone());
3059                                        }
3060                                    }
3061                                }
3062                                item
3063                            })
3064                            .collect();
3065                        unwrapped_value = Some(serde_json::Value::Array(arr));
3066                        unwrapped_value.as_ref().unwrap()
3067                    } else {
3068                        bo4e_value
3069                    }
3070                } else {
3071                    bo4e_value
3072                };
3073
3074                // Handle array entities: each element becomes a separate group rep.
3075                // This supports both the NAD/SG12 pattern (multiple qualifiers) and
3076                // the multi-rep pattern (e.g., two LOC+Z17 Messlokationen).
3077                let items: Vec<&serde_json::Value> = if bo4e_value.is_array() {
3078                    bo4e_value.as_array().unwrap().iter().collect()
3079                } else {
3080                    vec![bo4e_value]
3081                };
3082
3083                for (item_idx, item) in items.iter().enumerate() {
3084                    let instance = tx_engine.map_reverse(item, dm.def);
3085
3086                    // Skip empty instances (definition had no real BO4E data)
3087                    if instance.segments.is_empty() && instance.child_groups.is_empty() {
3088                        continue;
3089                    }
3090
3091                    if dm.relative.is_empty() {
3092                        // The definition maps the transaction group itself
3093                        // (CONTRL's SG1, UTILMD's SG4): its segments are the
3094                        // instance's own root segments. Children it nested with
3095                        // `parent_field` (CONTRL SG1.SG2, the UCS/UCD errors of
3096                        // this checked message) are already built as child
3097                        // groups of that instance and must travel with it.
3098                        root_segs.extend(instance.segments);
3099                        for child in instance.child_groups {
3100                            match child_groups
3101                                .iter_mut()
3102                                .find(|g| g.group_id == child.group_id)
3103                            {
3104                                Some(existing) => existing.repetitions.extend(child.repetitions),
3105                                None => child_groups.push(child),
3106                            }
3107                        }
3108                    } else {
3109                        // For depth-2+ defs without explicit rep index, resolve
3110                        // parent rep from source_path matching (qualifier-based).
3111                        // item_idx selects the correct parent rep for multi-rep entities.
3112                        let effective_relative = if dm.depth >= 2 {
3113                            // Multi-rep: strip hardcoded parent :N indices so
3114                            // resolve_child_relative uses source_path lookup instead.
3115                            let rel = if items.len() > 1 {
3116                                strip_all_rep_indices(&dm.relative)
3117                            } else {
3118                                dm.relative.clone()
3119                            };
3120                            // Use tx nesting info for multi-rep arrays, BUT skip it
3121                            // when source_path is present and resolves to a single
3122                            // parent rep. In that case, nesting_info indices (from the
3123                            // original tree) may not match the reverse tree's rep layout.
3124                            // resolve_child_relative uses reverse-tree source_path_to_rep
3125                            // which is always correct.
3126                            let skip_nesting = dm
3127                                .def
3128                                .meta
3129                                .source_path
3130                                .as_ref()
3131                                .and_then(|sp| sp.rsplit_once('.'))
3132                                .and_then(|(parent_path, _)| source_path_to_rep.get(parent_path))
3133                                .is_some_and(|reps| reps.len() == 1);
3134                            let nesting_idx = if items.len() > 1 && !skip_nesting {
3135                                dm.def
3136                                    .meta
3137                                    .source_path
3138                                    .as_ref()
3139                                    .and_then(|sp| tx.nesting_info.get(sp))
3140                                    .and_then(|dist| dist.get(item_idx))
3141                                    .copied()
3142                            } else {
3143                                None
3144                            };
3145                            if let Some(parent_rep) = nesting_idx {
3146                                // Direct placement using known nesting distribution
3147                                let parts: Vec<&str> = rel.split('.').collect();
3148                                let parent_id = parts[0].split(':').next().unwrap_or(parts[0]);
3149                                let rest = parts[1..].join(".");
3150                                format!("{}:{}.{}", parent_id, parent_rep, rest)
3151                            } else {
3152                                resolve_child_relative(
3153                                    &rel,
3154                                    dm.def.meta.source_path.as_deref(),
3155                                    &source_path_to_rep,
3156                                    item_idx,
3157                                )
3158                            }
3159                        } else if dm.depth == 1 {
3160                            // Depth-1: use nesting_info child indices for correct
3161                            // rep placement (preserves original interleaving order).
3162                            let child_key = dm
3163                                .def
3164                                .meta
3165                                .source_path
3166                                .as_ref()
3167                                .map(|sp| format!("{sp}#child"));
3168                            if let Some(child_indices) =
3169                                child_key.as_ref().and_then(|ck| tx.nesting_info.get(ck))
3170                            {
3171                                if let Some(&target) = child_indices.get(item_idx) {
3172                                    if target != usize::MAX {
3173                                        let base =
3174                                            dm.relative.split(':').next().unwrap_or(&dm.relative);
3175                                        format!("{}:{}", base, target)
3176                                    } else {
3177                                        dm.relative.clone()
3178                                    }
3179                                } else if items.len() > 1 && item_idx > 0 {
3180                                    strip_rep_index(&dm.relative)
3181                                } else {
3182                                    dm.relative.clone()
3183                                }
3184                            } else if items.len() > 1 && item_idx > 0 {
3185                                strip_rep_index(&dm.relative)
3186                            } else {
3187                                dm.relative.clone()
3188                            }
3189                        } else if items.len() > 1 && item_idx > 0 {
3190                            // Multi-rep entity with hardcoded :N index: first item uses
3191                            // the original index, subsequent items append (strip :N).
3192                            strip_rep_index(&dm.relative)
3193                        } else {
3194                            dm.relative.clone()
3195                        };
3196
3197                        let rep_used =
3198                            place_in_groups(&mut child_groups, &effective_relative, instance);
3199
3200                        // Track source_path → rep_index for depth-1 (parent) defs
3201                        if dm.depth == 1 {
3202                            if let Some(sp) = &dm.def.meta.source_path {
3203                                source_path_to_rep
3204                                    .entry(sp.clone())
3205                                    .or_default()
3206                                    .push(rep_used);
3207                            }
3208                        }
3209                    }
3210                }
3211            }
3212
3213            sg4_reps.push(AssembledGroupInstance {
3214                segments: root_segs,
3215                child_groups,
3216                entry_mig_number: None,
3217                variant_mig_numbers: vec![],
3218                skipped_segments: Vec::new(),
3219                skipped_positions: Vec::new(),
3220            });
3221        }
3222
3223        // Step 3: Combine message tree with transaction group.
3224        // Move UNS section separator from root segments to inter_group_segments.
3225        // UNS+D (detail) goes BEFORE the tx group (MSCONS: header/detail boundary).
3226        // UNS+S (summary) goes AFTER the tx group (ORDERS: detail/summary boundary).
3227        // Any segments that follow UNS in the sequence (e.g., summary MOA in REMADV)
3228        // are also placed in inter_group_segments alongside UNS.
3229        let mut root_segments = Vec::new();
3230        let mut uns_segments = Vec::new();
3231        let mut uns_is_summary = false;
3232        let mut found_uns = false;
3233        for seg in msg_tree.segments {
3234            if seg.tag == "UNS" {
3235                // Check if this is UNS+S (summary separator) vs UNS+D (detail separator)
3236                uns_is_summary = seg
3237                    .elements
3238                    .first()
3239                    .and_then(|el| el.first())
3240                    .map(|v| v == "S")
3241                    .unwrap_or(false);
3242                uns_segments.push(seg);
3243                found_uns = true;
3244            } else if found_uns {
3245                // Segments after UNS belong in the same inter_group position
3246                uns_segments.push(seg);
3247            } else {
3248                root_segments.push(seg);
3249            }
3250        }
3251
3252        let pre_group_count = root_segments.len();
3253        let mut all_groups = msg_tree.groups;
3254        let mut inter_group = msg_tree.inter_group_segments;
3255
3256        // Helper: parse SG number from group_id (e.g., "SG26" → 26).
3257        let sg_num = |id: &str| -> usize {
3258            id.strip_prefix("SG")
3259                .and_then(|n| n.parse::<usize>().ok())
3260                .unwrap_or(0)
3261        };
3262
3263        if !sg4_reps.is_empty() {
3264            if uns_is_summary {
3265                // UNS+S: place AFTER the transaction group (detail/summary boundary)
3266                all_groups.push(AssembledGroup {
3267                    group_id: transaction_group.to_string(),
3268                    repetitions: sg4_reps,
3269                });
3270                if !uns_segments.is_empty() {
3271                    // Sort groups by SG number so the disassembler emits them
3272                    // in MIG order.  Insert UNS right after the tx_group —
3273                    // any groups with higher SG numbers (e.g., SG50/SG52 in
3274                    // INVOIC) are post-UNS summary groups.
3275                    all_groups.sort_by_key(|g| sg_num(&g.group_id));
3276                    let tx_num = sg_num(transaction_group);
3277                    let uns_pos = all_groups
3278                        .iter()
3279                        .rposition(|g| sg_num(&g.group_id) <= tx_num)
3280                        .map(|i| i + 1)
3281                        .unwrap_or(all_groups.len());
3282                    inter_group.insert(uns_pos, uns_segments);
3283                }
3284            } else {
3285                // UNS+D: place BEFORE the transaction group (header/detail boundary)
3286                if !uns_segments.is_empty() {
3287                    inter_group.insert(all_groups.len(), uns_segments);
3288                }
3289                all_groups.push(AssembledGroup {
3290                    group_id: transaction_group.to_string(),
3291                    repetitions: sg4_reps,
3292                });
3293            }
3294        } else if !uns_segments.is_empty() {
3295            if transaction_group.is_empty() {
3296                // Truly message-only (tx_group=""): UNS is a section separator.
3297                // UNS+S (summary) goes AFTER all groups — e.g., ORDCHG UNS+S
3298                // follows SG1 (NAD+CTA+COM) groups.
3299                // UNS+D (detail) goes BEFORE groups.
3300                all_groups.sort_by_key(|g| sg_num(&g.group_id));
3301                if uns_is_summary {
3302                    inter_group.insert(all_groups.len(), uns_segments);
3303                } else {
3304                    inter_group.insert(0, uns_segments);
3305                }
3306            } else {
3307                // Has a tx_group but no tx reps (e.g., INVOIC PID 31004
3308                // Storno — no SG26 data).  Sort groups and insert UNS after
3309                // the last group with SG number ≤ tx_group number.
3310                all_groups.sort_by_key(|g| sg_num(&g.group_id));
3311                let tx_num = sg_num(transaction_group);
3312                let uns_pos = all_groups
3313                    .iter()
3314                    .rposition(|g| sg_num(&g.group_id) <= tx_num)
3315                    .map(|i| i + 1)
3316                    .unwrap_or(all_groups.len());
3317                inter_group.insert(uns_pos, uns_segments);
3318            }
3319        }
3320
3321        // Restore inter_group_segments captured during forward mapping
3322        // (e.g. PID-foreign top-level segments preserved by the assembler's
3323        // skip-unknown mode — see `Assembler::assemble_generic`). Without
3324        // this, BO4E forward + reverse drops anything not represented in a
3325        // TOML mapping definition. We append rather than overwrite so the
3326        // UNS placement computed above survives — same-key collisions are
3327        // rare in practice (UNS goes at well-known positions).
3328        for (k, segs) in &mapped.inter_group_segments {
3329            if segs.is_empty() {
3330                continue;
3331            }
3332            let existing_tags: std::collections::HashSet<String> = inter_group
3333                .get(k)
3334                .map(|v| v.iter().map(|s| s.tag.clone()).collect())
3335                .unwrap_or_default();
3336            for seg in segs {
3337                if existing_tags.contains(&seg.tag) {
3338                    continue;
3339                }
3340                inter_group.entry(*k).or_default().push(seg.clone());
3341            }
3342        }
3343
3344        let mut tree = AssembledTree {
3345            segments: root_segments,
3346            groups: all_groups,
3347            post_group_start: pre_group_count,
3348            inter_group_segments: inter_group,
3349        };
3350
3351        // Order repetitions of same-ID group variants (SG2 NAD+MS / NAD+MR,
3352        // SG12 NAD+Z07 / NAD+Z08, SG10 CCI variants, …) by MIG variant order.
3353        // The reps above were appended in definition order and, within one
3354        // definition, in the order of the BO4E JSON array — which carries no
3355        // ordering information. The transaction group itself keeps its order:
3356        // the order of transactions is data.
3357        if let Some(mig) = filtered_mig {
3358            mig_assembly::repetition_order::sort_repetitions_by_mig_variant(
3359                &mut tree,
3360                mig,
3361                (!transaction_group.is_empty()).then_some(transaction_group),
3362            );
3363        }
3364        tree
3365    }
3366
3367    /// Build an assembled group from BO4E values and a definition.
3368    pub fn build_group_from_bo4e(
3369        &self,
3370        bo4e_value: &serde_json::Value,
3371        def: &MappingDefinition,
3372    ) -> AssembledGroup {
3373        let instance = self.map_reverse(bo4e_value, def);
3374        let leaf_group = def
3375            .meta
3376            .source_group
3377            .rsplit('.')
3378            .next()
3379            .unwrap_or(&def.meta.source_group);
3380
3381        AssembledGroup {
3382            group_id: leaf_group.to_string(),
3383            repetitions: vec![instance],
3384        }
3385    }
3386
3387    /// Forward-map an assembled tree to a typed interchange.
3388    ///
3389    /// Runs the dynamic mapping pipeline, wraps the result with metadata,
3390    /// then converts via JSON serialization into the caller's typed structs.
3391    ///
3392    /// - `M`: message-level stammdaten type (e.g., `Pid55001MsgStammdaten`)
3393    /// - `T`: transaction-level stammdaten type (e.g., `Pid55001TxStammdaten`)
3394    pub fn map_interchange_typed<M, T>(
3395        msg_engine: &MappingEngine,
3396        tx_engine: &MappingEngine,
3397        tree: &AssembledTree,
3398        tx_group: &str,
3399        enrich_codes: bool,
3400        nachrichtendaten: crate::model::Nachrichtendaten,
3401        interchangedaten: crate::model::Interchangedaten,
3402    ) -> Result<crate::model::Interchange<M, T>, serde_json::Error>
3403    where
3404        M: serde::de::DeserializeOwned,
3405        T: serde::de::DeserializeOwned,
3406    {
3407        let mapped = Self::map_interchange(msg_engine, tx_engine, tree, tx_group, enrich_codes);
3408        let nachricht = mapped.into_dynamic_nachricht(nachrichtendaten);
3409        let dynamic = crate::model::DynamicInterchange {
3410            interchangedaten,
3411            nachrichten: vec![nachricht],
3412        };
3413        let value = serde_json::to_value(&dynamic)?;
3414        serde_json::from_value(value)
3415    }
3416
3417    /// Reverse-map a typed interchange nachricht back to an assembled tree.
3418    ///
3419    /// Serializes the typed struct to JSON, then runs the dynamic reverse pipeline.
3420    ///
3421    /// - `M`: message-level stammdaten type
3422    /// - `T`: transaction-level stammdaten type
3423    pub fn map_interchange_reverse_typed<M, T>(
3424        msg_engine: &MappingEngine,
3425        tx_engine: &MappingEngine,
3426        nachricht: &crate::model::Nachricht<M, T>,
3427        tx_group: &str,
3428    ) -> Result<AssembledTree, serde_json::Error>
3429    where
3430        M: serde::Serialize,
3431        T: serde::Serialize,
3432    {
3433        // The reverse resolves definitions against one flat entity map, so both
3434        // metadata slots go back where the mappings expect to find them.
3435        let mut stammdaten = serde_json::to_value(&nachricht.stammdaten)?;
3436        crate::model::restore_message_metadata(&mut stammdaten, &nachricht.nachrichtendaten);
3437        let transaktionen: Vec<crate::model::MappedTransaktion> = nachricht
3438            .transaktionen
3439            .iter()
3440            .map(|t| {
3441                Ok(crate::model::MappedTransaktion {
3442                    stammdaten: serde_json::to_value(t)?,
3443                    transaktionsdaten: serde_json::Value::Null,
3444                    nesting_info: Default::default(),
3445                })
3446            })
3447            .collect::<Result<Vec<_>, serde_json::Error>>()?;
3448        let mapped = crate::model::MappedMessage {
3449            stammdaten,
3450            nachricht_meta: serde_json::Value::Null,
3451            transaktionen,
3452            nesting_info: Default::default(),
3453            inter_group_segments: Default::default(),
3454        };
3455        Ok(Self::map_interchange_reverse(
3456            msg_engine, tx_engine, &mapped, tx_group, None,
3457        ))
3458    }
3459}
3460
3461/// Parse a group path part with optional repetition: "SG8:1" → ("SG8", Some(1)).
3462/// Parse a source_path part into (group_id, optional_qualifier).
3463///
3464/// `"sg8_z98"` → `("sg8", Some("z98"))`
3465/// `"sg4"` → `("sg4", None)`
3466/// `"sg10"` → `("sg10", None)`
3467fn parse_source_path_part(part: &str) -> (&str, Option<&str>) {
3468    // Find the first underscore that separates group from qualifier.
3469    // Source path parts look like "sg8_z98", "sg4", "sg10", "sg12_z04".
3470    // The group ID is always "sgN", so the underscore after the digits is the separator.
3471    if let Some(pos) = part.find('_') {
3472        let group = &part[..pos];
3473        let qualifier = &part[pos + 1..];
3474        if !qualifier.is_empty() {
3475            return (group, Some(qualifier));
3476        }
3477    }
3478    (part, None)
3479}
3480
3481/// Build a map from group ID (e.g., "SG5", "SG8") to its position index
3482/// within the transaction group's nested_groups Vec.
3483/// Used by `map_interchange_reverse` to sort definitions in MIG order.
3484///
3485/// For variant groups (same ID with variant_code set, e.g., SG8 with Z01, Z03, Z07),
3486/// stores per-variant positions (e.g., "SG8_Z01" → 0, "SG8_Z03" → 1) so that
3487/// definitions are sorted in MIG XML order rather than alphabetical qualifier order.
3488fn build_reverse_mig_group_order(mig: &MigSchema, tx_group_id: &str) -> HashMap<String, usize> {
3489    let mut order = HashMap::new();
3490    if let Some(tg) = mig.segment_groups.iter().find(|g| g.id == tx_group_id) {
3491        for (i, nested) in tg.nested_groups.iter().enumerate() {
3492            // For variant groups, store per-variant key (e.g., "SG8_Z01" → i)
3493            if let Some(ref vc) = nested.variant_code {
3494                let variant_key = format!("{}_{}", nested.id, vc.to_uppercase());
3495                order.insert(variant_key, i);
3496            }
3497            // Always store base group ID for fallback
3498            order.entry(nested.id.clone()).or_insert(i);
3499        }
3500    }
3501    order
3502}
3503
3504/// Extract the MIG position for a definition, using per-variant lookup when possible.
3505///
3506/// For a definition with source_path "sg4.sg8_z01", extracts the variant qualifier "Z01"
3507/// and looks up "SG8_Z01" in the MIG order map. Falls back to the base group ID (e.g., "SG8")
3508/// if no variant qualifier is found or if the per-variant key isn't in the map.
3509fn variant_mig_position(
3510    def: &MappingDefinition,
3511    base_group_id: &str,
3512    mig_order: &HashMap<String, usize>,
3513) -> usize {
3514    // Try to extract variant qualifier from source_path.
3515    // source_path like "sg4.sg8_z01" or "sg4.sg8_z01.sg10" — we want the part matching base_group_id.
3516    if let Some(ref sp) = def.meta.source_path {
3517        // Find the path segment matching the base group (e.g., "sg8_z01" for base "SG8")
3518        let base_lower = base_group_id.to_lowercase();
3519        for part in sp.split('.') {
3520            if part.starts_with(&base_lower)
3521                || part.starts_with(base_group_id.to_lowercase().as_str())
3522            {
3523                // Extract qualifier suffix: "sg8_z01" → "z01"
3524                if let Some(underscore_pos) = part.find('_') {
3525                    let qualifier = &part[underscore_pos + 1..];
3526                    let variant_key = format!("{}_{}", base_group_id, qualifier.to_uppercase());
3527                    if let Some(&pos) = mig_order.get(&variant_key) {
3528                        return pos;
3529                    }
3530                }
3531            }
3532        }
3533    }
3534    // Fallback to base group position
3535    mig_order.get(base_group_id).copied().unwrap_or(usize::MAX)
3536}
3537
3538/// Find a group repetition whose entry segment has a matching qualifier.
3539///
3540/// The entry segment is the first segment in the instance (e.g., SEQ for SG8).
3541/// The qualifier is matched against `elements[0][0]` (case-insensitive).
3542fn find_rep_by_entry_qualifier<'a>(
3543    reps: &'a [AssembledGroupInstance],
3544    qualifier: &str,
3545) -> Option<&'a AssembledGroupInstance> {
3546    // Support compound qualifiers like "za1_za2" — match any part.
3547    let parts: Vec<&str> = qualifier.split('_').collect();
3548    reps.iter().find(|inst| {
3549        inst.segments.first().is_some_and(|seg| {
3550            seg.elements
3551                .first()
3552                .and_then(|e| e.first())
3553                .is_some_and(|v| parts.iter().any(|part| v.eq_ignore_ascii_case(part)))
3554        })
3555    })
3556}
3557
3558/// Find ALL repetitions whose entry segment qualifier matches (case-insensitive).
3559fn find_all_reps_by_entry_qualifier<'a>(
3560    reps: &'a [AssembledGroupInstance],
3561    qualifier: &str,
3562) -> Vec<&'a AssembledGroupInstance> {
3563    // Support compound qualifiers like "za1_za2" — match any part.
3564    let parts: Vec<&str> = qualifier.split('_').collect();
3565    reps.iter()
3566        .filter(|inst| {
3567            inst.segments.first().is_some_and(|seg| {
3568                seg.elements
3569                    .first()
3570                    .and_then(|e| e.first())
3571                    .is_some_and(|v| parts.iter().any(|part| v.eq_ignore_ascii_case(part)))
3572            })
3573        })
3574        .collect()
3575}
3576
3577/// Check if a source_path contains qualifier suffixes (e.g., "sg8_z98").
3578fn has_source_path_qualifiers(source_path: &str) -> bool {
3579    source_path.split('.').any(|part| {
3580        if let Some(pos) = part.find('_') {
3581            pos < part.len() - 1
3582        } else {
3583            false
3584        }
3585    })
3586}
3587
3588fn parse_group_spec(part: &str) -> (&str, Option<usize>) {
3589    if let Some(colon_pos) = part.find(':') {
3590        let id = &part[..colon_pos];
3591        let rep = part[colon_pos + 1..].parse::<usize>().ok();
3592        (id, rep)
3593    } else {
3594        (part, None)
3595    }
3596}
3597
3598/// Strip the transaction group prefix from a source_group path.
3599///
3600/// Given `source_group = "SG4.SG8:0.SG10"` and `tx_group = "SG4"`,
3601/// returns `"SG8:0.SG10"`.
3602/// Given `source_group = "SG4"` and `tx_group = "SG4"`, returns `""`.
3603fn strip_tx_group_prefix(source_group: &str, tx_group: &str) -> String {
3604    if source_group == tx_group || source_group.is_empty() {
3605        String::new()
3606    } else if let Some(rest) = source_group.strip_prefix(tx_group) {
3607        rest.strip_prefix('.').unwrap_or(rest).to_string()
3608    } else {
3609        source_group.to_string()
3610    }
3611}
3612
3613/// Place a reverse-mapped group instance into the correct nesting position.
3614///
3615/// `relative_path` is the group path relative to the transaction group:
3616/// - `"SG5"` → top-level child group
3617/// - `"SG8:0.SG10"` → SG10 inside SG8 repetition 0
3618///
3619/// Returns the repetition index used at the first nesting level.
3620fn place_in_groups(
3621    groups: &mut Vec<AssembledGroup>,
3622    relative_path: &str,
3623    instance: AssembledGroupInstance,
3624) -> usize {
3625    let parts: Vec<&str> = relative_path.split('.').collect();
3626
3627    if parts.len() == 1 {
3628        // Leaf group: "SG5", "SG8", "SG12", or with explicit index "SG8:0"
3629        let (id, rep) = parse_group_spec(parts[0]);
3630
3631        // Find or create the group
3632        let group = if let Some(g) = groups.iter_mut().find(|g| g.group_id == id) {
3633            g
3634        } else {
3635            groups.push(AssembledGroup {
3636                group_id: id.to_string(),
3637                repetitions: vec![],
3638            });
3639            groups.last_mut().unwrap()
3640        };
3641
3642        if let Some(rep_idx) = rep {
3643            // Explicit index: place at specific position, merging into existing
3644            while group.repetitions.len() <= rep_idx {
3645                group.repetitions.push(AssembledGroupInstance {
3646                    segments: vec![],
3647                    child_groups: vec![],
3648                    entry_mig_number: None,
3649                    variant_mig_numbers: vec![],
3650                    skipped_segments: Vec::new(),
3651                    skipped_positions: Vec::new(),
3652                });
3653            }
3654            group.repetitions[rep_idx]
3655                .segments
3656                .extend(instance.segments);
3657            group.repetitions[rep_idx]
3658                .child_groups
3659                .extend(instance.child_groups);
3660            rep_idx
3661        } else {
3662            // No index: append new repetition
3663            let pos = group.repetitions.len();
3664            group.repetitions.push(instance);
3665            pos
3666        }
3667    } else {
3668        // Nested path: e.g., "SG8:0.SG10" → place SG10 inside SG8 rep 0
3669        let (parent_id, parent_rep) = parse_group_spec(parts[0]);
3670        let rep_idx = parent_rep.unwrap_or(0);
3671
3672        // Find or create the parent group
3673        let parent_group = if let Some(g) = groups.iter_mut().find(|g| g.group_id == parent_id) {
3674            g
3675        } else {
3676            groups.push(AssembledGroup {
3677                group_id: parent_id.to_string(),
3678                repetitions: vec![],
3679            });
3680            groups.last_mut().unwrap()
3681        };
3682
3683        // Ensure the target repetition exists (extend with empty instances if needed)
3684        while parent_group.repetitions.len() <= rep_idx {
3685            parent_group.repetitions.push(AssembledGroupInstance {
3686                segments: vec![],
3687                child_groups: vec![],
3688                entry_mig_number: None,
3689                variant_mig_numbers: vec![],
3690                skipped_segments: Vec::new(),
3691                skipped_positions: Vec::new(),
3692            });
3693        }
3694
3695        let remaining = parts[1..].join(".");
3696        place_in_groups(
3697            &mut parent_group.repetitions[rep_idx].child_groups,
3698            &remaining,
3699            instance,
3700        );
3701        rep_idx
3702    }
3703}
3704
3705/// Resolve the effective relative path for a child definition (depth >= 2).
3706///
3707/// If the child's relative already has an explicit parent rep index (e.g., "SG8:5.SG10"),
3708/// use it as-is. Otherwise, use the `source_path` to look up the parent's actual
3709/// repetition index from `source_path_to_rep`.
3710///
3711/// `item_idx` selects which parent rep to use when the parent created multiple reps
3712/// (e.g., two SG8 reps with ZF3 → item_idx 0 picks the first, 1 picks the second).
3713///
3714/// Example: relative = "SG8.SG10", source_path = "sg4.sg8_zf3.sg10"
3715/// → looks up "sg4.sg8_zf3" in map → finds reps [3, 4] → item_idx=1 → returns "SG8:4.SG10"
3716fn resolve_child_relative(
3717    relative: &str,
3718    source_path: Option<&str>,
3719    source_path_to_rep: &std::collections::HashMap<String, Vec<usize>>,
3720    item_idx: usize,
3721) -> String {
3722    let parts: Vec<&str> = relative.split('.').collect();
3723    if parts.is_empty() {
3724        return relative.to_string();
3725    }
3726
3727    // If first part already has explicit index, keep as-is
3728    let (parent_id, parent_rep) = parse_group_spec(parts[0]);
3729    if parent_rep.is_some() {
3730        return relative.to_string();
3731    }
3732
3733    // Try to resolve from source_path: extract parent path and look up its rep
3734    if let Some(sp) = source_path {
3735        if let Some((parent_path, _child)) = sp.rsplit_once('.') {
3736            // Exact match first.
3737            if let Some(rep_indices) = source_path_to_rep.get(parent_path) {
3738                let rep_idx = rep_indices
3739                    .get(item_idx)
3740                    .or_else(|| rep_indices.last())
3741                    .copied()
3742                    .unwrap_or(0);
3743                let rest = parts[1..].join(".");
3744                return format!("{}:{}.{}", parent_id, rep_idx, rest);
3745            }
3746            // Fallback: variant wildcard. When TOMLs use a flat parent path
3747            // like "sg4" but the schema splits it into variants (e.g. sg4_su,
3748            // sg4_z10..z21), union the reps from every matching variant so a
3749            // per-item iteration can place each child under its own parent.
3750            // `PidSchemaIndex::has_group` already accepts this style for
3751            // forward mapping — reverse mapping needs the same or children
3752            // from all-but-one variant get dropped (PARTIN 12 SG4 reps).
3753            let prefix = format!("{}_", parent_path);
3754            let mut unioned: Vec<usize> = source_path_to_rep
3755                .iter()
3756                .filter(|(k, _)| k.starts_with(&prefix))
3757                .flat_map(|(_, v)| v.iter().copied())
3758                .collect();
3759            if !unioned.is_empty() {
3760                unioned.sort_unstable();
3761                unioned.dedup();
3762                let rep_idx = unioned
3763                    .get(item_idx)
3764                    .or_else(|| unioned.last())
3765                    .copied()
3766                    .unwrap_or(0);
3767                let rest = parts[1..].join(".");
3768                return format!("{}:{}.{}", parent_id, rep_idx, rest);
3769            }
3770        }
3771    }
3772
3773    // No resolution possible, keep original
3774    relative.to_string()
3775}
3776
3777/// Parsed discriminator for filtering assembled group instances.
3778///
3779/// Discriminator format: "TAG.element_idx.component_idx=VALUE" or
3780/// "TAG.element_idx.component_idx=VAL1|VAL2" (pipe-separated multi-value).
3781/// E.g., "LOC.0.0=Z17" → match LOC segments where elements[0][0] == "Z17"
3782/// E.g., "RFF.0.0=Z49|Z53" → match RFF where elements[0][0] is Z49 OR Z53
3783struct DiscriminatorMatcher<'a> {
3784    tag: &'a str,
3785    element_idx: usize,
3786    component_idx: usize,
3787    expected_values: Vec<&'a str>,
3788    /// Optional occurrence index: `#N` selects the Nth match among instances.
3789    occurrence: Option<usize>,
3790}
3791
3792impl<'a> DiscriminatorMatcher<'a> {
3793    fn parse(disc: &'a str) -> Option<Self> {
3794        let (spec, expected) = disc.split_once('=')?;
3795        let parts: Vec<&str> = spec.split('.').collect();
3796        if parts.len() != 3 {
3797            return None;
3798        }
3799        let (expected_raw, occurrence) = parse_discriminator_occurrence(expected);
3800        Some(Self {
3801            tag: parts[0],
3802            element_idx: parts[1].parse().ok()?,
3803            component_idx: parts[2].parse().ok()?,
3804            expected_values: expected_raw.split('|').collect(),
3805            occurrence,
3806        })
3807    }
3808
3809    fn matches(&self, instance: &AssembledGroupInstance) -> bool {
3810        instance.segments.iter().any(|s| {
3811            s.tag.eq_ignore_ascii_case(self.tag)
3812                && s.elements
3813                    .get(self.element_idx)
3814                    .and_then(|e| e.get(self.component_idx))
3815                    .map(|v| self.expected_values.iter().any(|ev| v == ev))
3816                    .unwrap_or(false)
3817        })
3818    }
3819
3820    /// Filter instances, respecting the occurrence index if present.
3821    fn filter_instances<'b>(
3822        &self,
3823        instances: Vec<&'b AssembledGroupInstance>,
3824    ) -> Vec<&'b AssembledGroupInstance> {
3825        let matching: Vec<_> = instances
3826            .into_iter()
3827            .filter(|inst| self.matches(inst))
3828            .collect();
3829        if let Some(occ) = self.occurrence {
3830            matching.into_iter().nth(occ).into_iter().collect()
3831        } else {
3832            matching
3833        }
3834    }
3835}
3836
3837/// Parse an optional occurrence index from a discriminator expected value.
3838///
3839/// `"TN#1"` → `("TN", Some(1))` — select the 2nd matching rep
3840/// `"TN"`   → `("TN", None)` — select all matching reps
3841/// `"Z13|Z14#0"` → `("Z13|Z14", Some(0))` — first match among Z13 or Z14
3842fn parse_discriminator_occurrence(expected: &str) -> (&str, Option<usize>) {
3843    if let Some(hash_pos) = expected.rfind('#') {
3844        if let Ok(occ) = expected[hash_pos + 1..].parse::<usize>() {
3845            return (&expected[..hash_pos], Some(occ));
3846        }
3847    }
3848    (expected, None)
3849}
3850
3851/// Strip explicit rep index from a relative path: "SG5:4" → "SG5", "SG8:3" → "SG8".
3852/// Used for multi-rep entities where subsequent items should append rather than
3853/// merge into the same rep position.
3854fn strip_rep_index(relative: &str) -> String {
3855    let (id, _) = parse_group_spec(relative);
3856    id.to_string()
3857}
3858
3859/// Strip all explicit rep indices from a multi-part relative path:
3860/// "SG8:3.SG10" → "SG8.SG10", "SG8:3.SG10:0" → "SG8.SG10".
3861/// Used for multi-rep depth-2+ entities so resolve_child_relative uses
3862/// source_path lookup instead of hardcoded indices.
3863pub(crate) fn strip_all_rep_indices(relative: &str) -> String {
3864    relative
3865        .split('.')
3866        .map(|part| {
3867            let (id, _) = parse_group_spec(part);
3868            id
3869        })
3870        .collect::<Vec<_>>()
3871        .join(".")
3872}
3873
3874// ── Nested child groups (`[meta] parent_field`) ──
3875
3876/// Whether `child` is a `parent_field` definition nested directly below `parent`
3877/// (which may itself be a `parent_field` definition — nesting can span several
3878/// group levels, one `parent_field` per level):
3879/// same entity, `source_group` exactly one level deeper, and (when both carry a
3880/// `source_path`) a structurally compatible parent path. Qualifiers on the parent
3881/// part are compared only when both sides specify one; the instance-level check
3882/// is [`nested_parent_qualifier`] + [`entry_qualifier_matches`].
3883pub fn is_nested_child_of(child: &MappingDefinition, parent: &MappingDefinition) -> bool {
3884    if child.meta.parent_field.is_none() || child.meta.entity != parent.meta.entity {
3885        return false;
3886    }
3887    let child_sg = strip_all_rep_indices(&child.meta.source_group);
3888    let parent_sg = strip_all_rep_indices(&parent.meta.source_group);
3889    match child_sg.rsplit_once('.') {
3890        Some((head, _)) if head.eq_ignore_ascii_case(&parent_sg) => {}
3891        _ => return false,
3892    }
3893    let (Some(child_sp), Some(parent_sp)) = (
3894        child.meta.source_path.as_deref(),
3895        parent.meta.source_path.as_deref(),
3896    ) else {
3897        return true;
3898    };
3899    let Some((child_parent_sp, _)) = child_sp.rsplit_once('.') else {
3900        return false;
3901    };
3902    let child_parts: Vec<&str> = child_parent_sp.split('.').collect();
3903    let parent_parts: Vec<&str> = parent_sp.split('.').collect();
3904    child_parts.len() == parent_parts.len()
3905        && child_parts.iter().zip(&parent_parts).all(|(c, p)| {
3906            let (c_id, c_q) = parse_source_path_part(c);
3907            let (p_id, p_q) = parse_source_path_part(p);
3908            c_id.eq_ignore_ascii_case(p_id)
3909                && match (c_q, p_q) {
3910                    (Some(cq), Some(pq)) => cq.eq_ignore_ascii_case(pq),
3911                    _ => true,
3912                }
3913        })
3914}
3915
3916/// Entry qualifier the parent group instance must carry for a nested child
3917/// definition to apply (e.g. `"z08"` for `source_path = "sg4.sg12_z08.sg13"`).
3918fn nested_parent_qualifier(child: &MappingDefinition) -> Option<&str> {
3919    let (parent_path, _) = child.meta.source_path.as_deref()?.rsplit_once('.')?;
3920    let last = parent_path.rsplit('.').next()?;
3921    parse_source_path_part(last).1
3922}
3923
3924/// Leaf group id and optional entry qualifier of a nested child definition
3925/// (e.g. `("SG13", None)` for `source_group = "SG4.SG12.SG13"`).
3926fn nested_child_leaf(child: &MappingDefinition) -> (String, Option<&str>) {
3927    let leaf_group = strip_all_rep_indices(
3928        child
3929            .meta
3930            .source_group
3931            .rsplit('.')
3932            .next()
3933            .unwrap_or(&child.meta.source_group),
3934    );
3935    let leaf_qualifier = child
3936        .meta
3937        .source_path
3938        .as_deref()
3939        .and_then(|sp| sp.rsplit('.').next())
3940        .and_then(|part| parse_source_path_part(part).1);
3941    (leaf_group, leaf_qualifier)
3942}
3943
3944/// Whether the instance's entry segment (its first segment) carries `qualifier`
3945/// at `elements[0][0]`. Compound qualifiers (`"z53_z54"`) match any part.
3946fn entry_qualifier_matches(instance: &AssembledGroupInstance, qualifier: &str) -> bool {
3947    segment_qualifier_matches(instance.segments.first(), qualifier)
3948}
3949
3950/// [`entry_qualifier_matches`] for a group repetition rebuilt by the reverse
3951/// mapping from `def`. Its segments follow the order of `def`'s fields, so the
3952/// entry segment need not come first (e.g. `PIA` listed before `SEQ`). When
3953/// `def` has a discriminator, its segment tag names the entry segment.
3954fn rebuilt_entry_qualifier_matches(
3955    instance: &AssembledGroupInstance,
3956    def: &MappingDefinition,
3957    qualifier: &str,
3958) -> bool {
3959    let entry_tag = def
3960        .meta
3961        .discriminator
3962        .as_deref()
3963        .and_then(|d| d.split('.').next())
3964        .filter(|tag| !tag.is_empty());
3965    let entry = match entry_tag {
3966        Some(tag) => instance
3967            .segments
3968            .iter()
3969            .find(|s| s.tag.eq_ignore_ascii_case(tag)),
3970        None => instance.segments.first(),
3971    };
3972    segment_qualifier_matches(entry, qualifier)
3973}
3974
3975fn segment_qualifier_matches(segment: Option<&AssembledSegment>, qualifier: &str) -> bool {
3976    segment
3977        .and_then(|seg| seg.elements.first())
3978        .and_then(|e| e.first())
3979        .is_some_and(|v| qualifier.split('_').any(|q| v.eq_ignore_ascii_case(q)))
3980}
3981
3982/// Parse a segment tag with optional qualifier and occurrence index.
3983///
3984/// - `"dtm[92]"`    → `("DTM", Some("92"), 0)` — first (default) occurrence
3985/// - `"rff[Z34,1]"` → `("RFF", Some("Z34"), 1)` — second occurrence (0-indexed)
3986/// - `"rff[Z34,*]"` → `("RFF", Some("Z34"), 0)` — wildcard occurrence
3987/// - `"rff"`         → `("RFF", None, 0)`
3988pub(crate) fn parse_tag_qualifier(tag_part: &str) -> (String, Option<&str>, usize) {
3989    if let Some(bracket_start) = tag_part.find('[') {
3990        let tag = tag_part[..bracket_start].to_uppercase();
3991        let inner = tag_part[bracket_start + 1..].trim_end_matches(']');
3992        if let Some(comma_pos) = inner.find(',') {
3993            let qualifier = &inner[..comma_pos];
3994            let index = inner[comma_pos + 1..].parse::<usize>().unwrap_or(0);
3995            // "*" wildcard means no qualifier filter — positional access only
3996            if qualifier == "*" {
3997                (tag, None, index)
3998            } else {
3999                (tag, Some(qualifier), index)
4000            }
4001        } else {
4002            (tag, Some(inner), 0)
4003        }
4004    } else {
4005        (tag_part.to_uppercase(), None, 0)
4006    }
4007}
4008
4009/// Deep-merge a BO4E value into the result map.
4010///
4011/// If the entity already exists as an object, new fields are merged in
4012/// (existing fields are NOT overwritten). This allows multiple TOML
4013/// definitions with the same `entity` name to contribute fields to one object.
4014pub fn deep_merge_insert(
4015    result: &mut serde_json::Map<String, serde_json::Value>,
4016    entity: &str,
4017    bo4e: serde_json::Value,
4018) {
4019    if let Some(existing) = result.get_mut(entity) {
4020        // Array + Array: element-wise merge (same entity from multiple TOML defs,
4021        // each producing an array for multi-rep groups like two LOC+Z17).
4022        if let (Some(existing_arr), Some(new_arr)) =
4023            (existing.as_array().map(|a| a.len()), bo4e.as_array())
4024        {
4025            if existing_arr == new_arr.len() {
4026                let existing_arr = existing.as_array_mut().unwrap();
4027                for (existing_elem, new_elem) in existing_arr.iter_mut().zip(new_arr) {
4028                    if let (Some(existing_map), Some(new_map)) =
4029                        (existing_elem.as_object_mut(), new_elem.as_object())
4030                    {
4031                        for (k, v) in new_map {
4032                            if let Some(existing_v) = existing_map.get_mut(k) {
4033                                if let (Some(existing_inner), Some(new_inner)) =
4034                                    (existing_v.as_object_mut(), v.as_object())
4035                                {
4036                                    for (ik, iv) in new_inner {
4037                                        existing_inner
4038                                            .entry(ik.clone())
4039                                            .or_insert_with(|| iv.clone());
4040                                    }
4041                                }
4042                            } else {
4043                                existing_map.insert(k.clone(), v.clone());
4044                            }
4045                        }
4046                    }
4047                }
4048                return;
4049            }
4050        }
4051        // Object + Object: field-level merge
4052        if let (Some(existing_map), serde_json::Value::Object(new_map)) =
4053            (existing.as_object_mut(), &bo4e)
4054        {
4055            for (k, v) in new_map {
4056                if let Some(existing_v) = existing_map.get_mut(k) {
4057                    // Recursively merge nested objects (e.g., companion types)
4058                    if let (Some(existing_inner), Some(new_inner)) =
4059                        (existing_v.as_object_mut(), v.as_object())
4060                    {
4061                        for (ik, iv) in new_inner {
4062                            existing_inner
4063                                .entry(ik.clone())
4064                                .or_insert_with(|| iv.clone());
4065                        }
4066                    }
4067                    // Don't overwrite existing scalar/array values
4068                } else {
4069                    existing_map.insert(k.clone(), v.clone());
4070                }
4071            }
4072            return;
4073        }
4074    }
4075    result.insert(entity.to_string(), bo4e);
4076}
4077
4078/// Append a definition's per-repetition output to a **list-valued field** on an
4079/// entity — the write half of `MappingMeta::target_list`.
4080///
4081/// This cannot go through `deep_merge_insert`, which documents that it does
4082/// "not overwrite existing scalar/array values". That rule is right for ordinary
4083/// fields and wrong here: a list field is the one place where several
4084/// definitions are *expected* to contribute to the same key (four separate
4085/// `Obis*` definitions all feed `zaehlwerke`), and under `deep_merge_insert`
4086/// every contribution after the first would be dropped without a trace.
4087///
4088/// Empty elements are skipped so an absent optional group does not leave a
4089/// `[{}]` behind, which would reverse into a phantom segment.
4090fn append_to_list_field(
4091    result: &mut serde_json::Map<String, serde_json::Value>,
4092    entity: &str,
4093    list_field: &str,
4094    bo4e: serde_json::Value,
4095) {
4096    let mut items = match bo4e {
4097        serde_json::Value::Array(a) => a,
4098        other => vec![other],
4099    };
4100    items.retain(|v| !v.as_object().is_some_and(|o| o.is_empty()));
4101    if items.is_empty() {
4102        return;
4103    }
4104    let entry = result
4105        .entry(entity.to_string())
4106        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
4107    // An entity carrying a list field is a single object. If it is already an
4108    // array, some other definition made it multi-rep and the two shapes are
4109    // incompatible — leave it alone rather than corrupt it silently.
4110    let Some(obj) = entry.as_object_mut() else {
4111        return;
4112    };
4113    match obj.get_mut(list_field).and_then(|v| v.as_array_mut()) {
4114        Some(existing) => existing.extend(items),
4115        None => {
4116            obj.insert(list_field.to_string(), serde_json::Value::Array(items));
4117        }
4118    }
4119}
4120
4121/// Convert a PascalCase name to camelCase by lowering the first character.
4122///
4123/// E.g., `"Ansprechpartner"` → `"ansprechpartner"`,
4124/// `"AnsprechpartnerEdifact"` → `"ansprechpartnerEdifact"`,
4125/// `"ProduktpaketPriorisierung"` → `"produktpaketPriorisierung"`.
4126/// Detect whether a JSON object looks like a map-keyed entity (typed PID format).
4127///
4128/// Map-keyed objects have short uppercase/alphanumeric keys that look like qualifier
4129/// codes (e.g., `{"Z04": {...}, "Z09": {...}}` or `{"MS": {...}, "MR": {...}}`),
4130/// as opposed to normal field-name objects (e.g., `{"name1": "...", "adresse": {...}}`).
4131fn is_map_keyed_object(value: &serde_json::Value) -> bool {
4132    let Some(obj) = value.as_object() else {
4133        return false;
4134    };
4135    if obj.is_empty() {
4136        return false;
4137    }
4138    // All keys must be short (≤5 chars), uppercase/digit only, and all values must be objects
4139    obj.iter().all(|(k, v)| {
4140        k.len() <= 5
4141            && k.chars()
4142                .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
4143            && v.is_object()
4144    })
4145}
4146
4147/// Find the BO4E companion field name used for the qualifier/discriminator
4148/// across definitions that share the same entity name.
4149///
4150/// For example, if `Geschaeftspartner` has a definition with discriminator
4151/// `NAD.0.0=Z04` and companion field `nad.0.0 → nadQualifier`, this returns
4152/// `Some("nadQualifier")`.
4153///
4154/// Used to inject map keys into inner objects when converting map-keyed entities.
4155fn find_qualifier_companion_field(
4156    definitions: &[crate::definition::MappingDefinition],
4157    entity: &str,
4158) -> Option<String> {
4159    for def in definitions {
4160        if def.meta.entity != *entity || def.meta.parent_field.is_some() {
4161            continue;
4162        }
4163        let disc = def.meta.discriminator.as_deref()?;
4164        let (disc_path, _) = disc.split_once('=')?;
4165        let disc_path_lower = disc_path.to_lowercase();
4166
4167        // Search [fields] for the qualifier field (e.g., Marktteilnehmer has
4168        // "marktrolle" in [fields]).
4169        for (path, mapping) in &def.fields {
4170            let cf_path = path.to_lowercase();
4171            let matches = cf_path == disc_path_lower || format!("{}.0", cf_path) == disc_path_lower;
4172            if matches {
4173                let target = match mapping {
4174                    FieldMapping::Simple(t) => t.as_str(),
4175                    FieldMapping::Structured(s) => s.target.as_str(),
4176                    FieldMapping::Nested(_) => continue,
4177                };
4178                if !target.is_empty() {
4179                    return Some(target.to_string());
4180                }
4181            }
4182        }
4183    }
4184    None
4185}
4186
4187/// Extract a child entity from its parent entity in the reverse mapping input.
4188///
4189/// When a child entity (e.g., Kontakt with source_group="SG2.SG3") isn't found
4190/// at the top level, look inside the parent entity (e.g., Marktteilnehmer with
4191/// source_group="SG2") for a nested field matching the child's camelCase name.
4192///
4193/// For map-keyed parents ({"MS": {...}, "MR": {...}}), collects child values
4194/// from all inner objects that have the field, returning them as an array.
4195fn extract_child_from_parent(
4196    entities: &serde_json::Value,
4197    definitions: &[MappingDefinition],
4198    child_def: &MappingDefinition,
4199) -> Option<serde_json::Value> {
4200    extract_child_from_parent_with_indices(entities, definitions, child_def).map(|(v, _)| v)
4201}
4202
4203/// Like `extract_child_from_parent`, but also returns the parent rep indices
4204/// from which each child was extracted.  This allows the nesting distribution
4205/// to place child groups under the correct parent rep even when `nesting_info`
4206/// is unavailable (e.g., typed struct / manual JSON construction).
4207fn extract_child_from_parent_with_indices(
4208    entities: &serde_json::Value,
4209    definitions: &[MappingDefinition],
4210    child_def: &MappingDefinition,
4211) -> Option<(serde_json::Value, Vec<usize>)> {
4212    let parts: Vec<&str> = child_def.meta.source_group.split('.').collect();
4213    if parts.len() < 2 {
4214        return None;
4215    }
4216    let parent_group = parts[0];
4217    let parent_def = definitions
4218        .iter()
4219        .find(|d| d.meta.source_group == parent_group && d.meta.entity != child_def.meta.entity)?;
4220    let parent_key = to_camel_case(&parent_def.meta.entity);
4221    let child_key = to_camel_case(&child_def.meta.entity);
4222    let parent_value = entities.get(&parent_key)?;
4223
4224    // Map-keyed parent: collect child from each inner object
4225    if let Some(parent_map) = parent_value.as_object() {
4226        if is_map_keyed_value(parent_map) {
4227            let mut children: Vec<serde_json::Value> = Vec::new();
4228            let mut indices: Vec<usize> = Vec::new();
4229            for (i, (_key, inner)) in parent_map.iter().enumerate() {
4230                if let Some(child) = inner.get(&child_key) {
4231                    if !child.is_null() {
4232                        children.push(child.clone());
4233                        indices.push(i);
4234                    }
4235                }
4236            }
4237            return match children.len() {
4238                0 => None,
4239                1 => Some((children.into_iter().next().unwrap(), indices)),
4240                _ => Some((serde_json::Value::Array(children), indices)),
4241            };
4242        }
4243    }
4244
4245    // Array parent: collect child from each element
4246    if let Some(parent_arr) = parent_value.as_array() {
4247        let mut children: Vec<serde_json::Value> = Vec::new();
4248        let mut indices: Vec<usize> = Vec::new();
4249        for (i, item) in parent_arr.iter().enumerate() {
4250            if let Some(child) = item.get(&child_key) {
4251                if !child.is_null() {
4252                    children.push(child.clone());
4253                    indices.push(i);
4254                }
4255            }
4256        }
4257        return match children.len() {
4258            0 => None,
4259            1 => Some((children.into_iter().next().unwrap(), indices)),
4260            _ => Some((serde_json::Value::Array(children), indices)),
4261        };
4262    }
4263
4264    // Single parent object — always index 0
4265    let child = parent_value.get(&child_key)?;
4266    if child.is_null() {
4267        return None;
4268    }
4269    Some((child.clone(), vec![0]))
4270}
4271
4272/// Move child entities under their parent entities in the forward-mapped result.
4273///
4274/// For each definition with a dotted `source_group` (e.g., "SG2.SG3"), finds the
4275/// parent definition (e.g., "SG2") and moves the child entity from the top-level
4276/// result into the parent entity as a nested field.
4277fn nest_child_entities_in_result(
4278    result: &mut serde_json::Map<String, serde_json::Value>,
4279    definitions: &[MappingDefinition],
4280    nesting_info: &std::collections::HashMap<String, Vec<usize>>,
4281    transaction_group: Option<&str>,
4282) {
4283    let nesting_pairs = child_entity_nesting_pairs(definitions, transaction_group);
4284
4285    for (_parent_group, parent_entity, child_entity, child_source_path) in nesting_pairs {
4286        let parent_key = to_camel_case(&parent_entity);
4287        let child_key = to_camel_case(&child_entity);
4288
4289        // Remove child from top level (if present)
4290        let child_value = match result.remove(&child_key) {
4291            Some(v) => v,
4292            None => continue,
4293        };
4294
4295        // Get parent value.
4296        // If the parent is a plain array (not map-keyed), nesting would silently
4297        // place the child into arbitrary array elements. Skip and leave the child
4298        // at the top level where the reverse mapper can find it.
4299        let Some(parent_value) = result.get_mut(&parent_key) else {
4300            // Parent doesn't exist — put child back
4301            result.insert(child_key, child_value);
4302            continue;
4303        };
4304        if parent_value.is_array() {
4305            result.insert(child_key, child_value);
4306            continue;
4307        }
4308
4309        // Get the nesting distribution (which parent rep each child rep belongs to)
4310        let distribution = child_source_path
4311            .as_deref()
4312            .and_then(|sp| nesting_info.get(sp));
4313
4314        // Normalize child to a list of (index, value) pairs
4315        let child_items: Vec<(usize, &serde_json::Value)> = match &child_value {
4316            serde_json::Value::Array(arr) => arr.iter().enumerate().collect(),
4317            other => vec![(0, other)],
4318        };
4319
4320        // Helper: insert or append child value into a parent object field.
4321        // First call inserts the value; subsequent calls convert to array and append.
4322        let insert_or_append = |obj: &mut serde_json::Map<String, serde_json::Value>,
4323                                key: &str,
4324                                val: &serde_json::Value| {
4325            match obj.get_mut(key) {
4326                Some(existing) => {
4327                    // Convert single value to array, then push
4328                    if !existing.is_array() {
4329                        let prev = existing.take();
4330                        *existing = serde_json::Value::Array(vec![prev]);
4331                    }
4332                    if let Some(arr) = existing.as_array_mut() {
4333                        arr.push(val.clone());
4334                    }
4335                }
4336                None => {
4337                    obj.insert(key.to_string(), val.clone());
4338                }
4339            }
4340        };
4341
4342        // Handle parent as map-keyed object: {"MS": {...}, "MR": {...}}
4343        if let Some(parent_map) = parent_value.as_object_mut() {
4344            if is_map_keyed_value(parent_map) {
4345                // Map keys in insertion order correspond to rep indices
4346                let keys: Vec<String> = parent_map.keys().cloned().collect();
4347                for (i, child_item) in &child_items {
4348                    let target_idx = distribution
4349                        .and_then(|dist| dist.get(*i))
4350                        .copied()
4351                        .unwrap_or(0);
4352                    if let Some(key) = keys.get(target_idx) {
4353                        if let Some(inner) = parent_map.get_mut(key).and_then(|v| v.as_object_mut())
4354                        {
4355                            insert_or_append(inner, &child_key, child_item);
4356                        }
4357                    }
4358                }
4359                continue;
4360            }
4361        }
4362
4363        // Handle parent as array
4364        if let Some(parent_arr) = parent_value.as_array_mut() {
4365            for (i, child_item) in &child_items {
4366                let target_idx = distribution
4367                    .and_then(|dist| dist.get(*i))
4368                    .copied()
4369                    .unwrap_or(0);
4370                if let Some(parent_obj) = parent_arr
4371                    .get_mut(target_idx)
4372                    .and_then(|v| v.as_object_mut())
4373                {
4374                    insert_or_append(parent_obj, &child_key, child_item);
4375                }
4376            }
4377            continue;
4378        }
4379
4380        // Handle parent as single object
4381        if let Some(parent_obj) = parent_value.as_object_mut() {
4382            for (_i, child_item) in &child_items {
4383                insert_or_append(parent_obj, &child_key, child_item);
4384            }
4385            continue;
4386        }
4387
4388        // Fallback: put child back at top level
4389        result.insert(child_key, child_value);
4390    }
4391}
4392
4393/// Parent/child entity pairs the forward mapping nests (see
4394/// [`nest_child_entities_in_result`]): `(parent_group, parent_entity,
4395/// child_entity, child_source_path)`.
4396///
4397/// A child entity (dotted `source_group`, e.g. `SG2.SG3` Kontakt) is moved into
4398/// the object of the entity mapped from its parent group (e.g. `SG2`
4399/// Marktteilnehmer) — unless the parent group is the transaction root, the
4400/// child also has a definition at the parent level (same-entity enrichment), or
4401/// the parent maps a dotted field of the child's name.
4402pub(crate) fn child_entity_nesting_pairs(
4403    definitions: &[MappingDefinition],
4404    transaction_group: Option<&str>,
4405) -> Vec<(String, String, String, Option<String>)> {
4406    // Collect parent→child relationships from definitions.
4407    // parent_group → (parent_entity, child_entity, child_source_path)
4408    let mut nesting_pairs: Vec<(String, String, String, Option<String>)> = Vec::new();
4409    for def in definitions {
4410        let parts: Vec<&str> = def.meta.source_group.split('.').collect();
4411        if parts.len() < 2 || def.meta.parent_field.is_some() {
4412            continue;
4413        }
4414        let parent_group = parts[0];
4415        // Skip nesting when the parent group is the transaction root. SG4 in UTILMD
4416        // IS the transaction — its direct children (Marktlokation, Geschaeftspartner,
4417        // ProduktpaketDaten, …) are peers of the transaction metadata (Prozessdaten),
4418        // not sub-objects of it. Nesting still applies to other parents (e.g. SG2.SG3
4419        // Kontakt stays nested under SG2 Marktteilnehmer).
4420        if transaction_group.is_some_and(|tx| tx == parent_group) {
4421            continue;
4422        }
4423        let child_entity = def.meta.entity.clone();
4424        // Skip if the child entity also has a definition at the parent group level.
4425        // E.g., Prozessdaten at SG4.SG6 enriches Prozessdaten at SG4 via deep_merge —
4426        // this is same-entity enrichment, not a parent-child nesting relationship.
4427        let child_has_parent_level_def = definitions
4428            .iter()
4429            .any(|d| d.meta.source_group == parent_group && d.meta.entity == child_entity);
4430        if child_has_parent_level_def {
4431            continue;
4432        }
4433        // Find the parent definition (a different entity at the parent group level)
4434        let parent_entity = definitions
4435            .iter()
4436            .find(|d| d.meta.source_group == parent_group && d.meta.entity != child_entity)
4437            .map(|d| d.meta.entity.clone());
4438        if let Some(ref parent_entity) = parent_entity {
4439            // Skip nesting if the parent definition has a dotted field target
4440            // that creates a sub-object with the same name as the child entity.
4441            // E.g., Prozessdaten has "zeitscheibe.referenz" which creates
4442            // prozessdaten.zeitscheibe — collides with nesting Zeitscheibe entity.
4443            let child_key_lc = to_camel_case(&child_entity);
4444            let parent_defs: Vec<_> = definitions
4445                .iter()
4446                .filter(|d| d.meta.entity == *parent_entity)
4447                .collect();
4448            let has_conflicting_field = parent_defs.iter().any(|pd| {
4449                pd.fields.values().any(|fm| {
4450                    let target = match fm {
4451                        crate::definition::FieldMapping::Simple(t) => t.as_str(),
4452                        crate::definition::FieldMapping::Structured(s) => s.target.as_str(),
4453                        crate::definition::FieldMapping::Nested(_) => "",
4454                    };
4455                    target.starts_with(&child_key_lc)
4456                        && target.get(child_key_lc.len()..child_key_lc.len() + 1) == Some(".")
4457                })
4458            });
4459            if has_conflicting_field {
4460                continue;
4461            }
4462            // Avoid duplicates
4463            if nesting_pairs
4464                .iter()
4465                .any(|(_, pe, ce, _)| *pe == *parent_entity && *ce == child_entity)
4466            {
4467                continue;
4468            }
4469            nesting_pairs.push((
4470                parent_group.to_string(),
4471                parent_entity.clone(),
4472                child_entity,
4473                def.meta.source_path.clone(),
4474            ));
4475        }
4476    }
4477
4478    nesting_pairs
4479}
4480
4481/// Check if a JSON map looks like a map-keyed entity (short uppercase/code keys → objects).
4482fn is_map_keyed_value(map: &serde_json::Map<String, serde_json::Value>) -> bool {
4483    if map.is_empty() {
4484        return false;
4485    }
4486    map.values().all(|v| v.is_object())
4487        && map.keys().all(|k| {
4488            k.len() <= 5
4489                || k.chars()
4490                    .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
4491        })
4492}
4493
4494/// One code-field position recovered from a mapping definition: where the value
4495/// came from in EDIFACT, and where it landed in BO4E.
4496struct CodeSite<'a> {
4497    target: &'a str,
4498    /// `[meta] parent_field`: the site is a key of an element of that array on
4499    /// the entity, not a key of the entity. Without this the split enrichment
4500    /// looks for the target directly on the carrier and finds nothing, while
4501    /// the pre-split path enriched it at write time — the two would disagree on
4502    /// every nested rule.
4503    parent_field: Option<&'a str>,
4504    source_path: &'a str,
4505    seg_tag: String,
4506    /// The qualifier on the field key itself (`cav[Z30]...`).
4507    path_qualifier: Option<String>,
4508    /// The qualifier the definition's discriminator pins for this tag.
4509    disc_qualifier: Option<String>,
4510    element_idx: usize,
4511    component_idx: usize,
4512    enum_map: Option<&'a std::collections::BTreeMap<String, String>>,
4513    also_target: Option<&'a str>,
4514    also_enum_map: Option<&'a std::collections::BTreeMap<String, String>>,
4515}
4516
4517pub(crate) fn to_camel_case(name: &str) -> String {
4518    let mut chars = name.chars();
4519    match chars.next() {
4520        Some(c) => c.to_lowercase().to_string() + chars.as_str(),
4521        None => String::new(),
4522    }
4523}
4524
4525/// Set a value in a nested JSON map using a dotted path.
4526/// E.g., "address.city" sets `{"address": {"city": "value"}}`.
4527fn set_nested_value(map: &mut serde_json::Map<String, serde_json::Value>, path: &str, val: String) {
4528    set_nested_value_json(map, path, serde_json::Value::String(val));
4529}
4530
4531/// Like `set_nested_value` but accepts a `serde_json::Value` instead of a `String`.
4532fn set_nested_value_json(
4533    map: &mut serde_json::Map<String, serde_json::Value>,
4534    path: &str,
4535    val: serde_json::Value,
4536) {
4537    if let Some((prefix, leaf)) = path.rsplit_once('.') {
4538        let mut current = map;
4539        for part in prefix.split('.') {
4540            let entry = current
4541                .entry(part.to_string())
4542                .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
4543            current = entry.as_object_mut().expect("expected object in path");
4544        }
4545        current.insert(leaf.to_string(), val);
4546    } else {
4547        map.insert(path.to_string(), val);
4548    }
4549}
4550
4551/// Precompiled cache for a single format-version/variant (e.g., FV2504/UTILMD_Strom).
4552///
4553/// Contains all engines with paths pre-resolved, ready for immediate use.
4554/// Loading one `VariantCache` file replaces thousands of individual `.bin` reads.
4555#[derive(serde::Serialize, serde::Deserialize)]
4556pub struct VariantCache {
4557    /// Message-level definitions (shared across PIDs).
4558    pub message_defs: Vec<MappingDefinition>,
4559    /// Per-PID transaction definitions (key: "pid_55001").
4560    pub transaction_defs: BTreeMap<String, Vec<MappingDefinition>>,
4561    /// Per-PID combined definitions (key: "pid_55001").
4562    pub combined_defs: BTreeMap<String, Vec<MappingDefinition>>,
4563    /// Per-PID code lookups (key: "pid_55001"). Cached to avoid reading schema JSONs at load time.
4564    #[serde(default)]
4565    pub code_lookups: BTreeMap<String, crate::code_lookup::CodeLookup>,
4566    /// Parsed MIG schema — cached to avoid re-parsing MIG XML at startup.
4567    #[serde(default)]
4568    pub mig_schema: Option<mig_types::schema::mig::MigSchema>,
4569    /// Segment element counts derived from MIG — cached for reverse mapping padding.
4570    #[serde(default)]
4571    pub segment_structure: Option<crate::segment_structure::SegmentStructure>,
4572    /// Per-PID AHB segment numbers (key: "pid_55001"). Used for MIG filtering at runtime.
4573    /// Eliminates the need to parse AHB XML files at startup.
4574    #[serde(default)]
4575    pub pid_segment_numbers: BTreeMap<String, Vec<String>>,
4576    /// Per-PID field requirements (key: "pid_55001"). Built from PID schema + TOML definitions.
4577    /// Used by `validate_pid()` to check field completeness.
4578    #[serde(default)]
4579    pub pid_requirements: BTreeMap<String, crate::pid_requirements::PidRequirements>,
4580    /// Per-PID pre-built AHB workflow (key: "pid_55001"). The EDIFACT-side rulebook
4581    /// (segment-path keyed), twin of `pid_requirements` (BO4E-entity keyed). Built at
4582    /// compile-mappings from the PID schema JSON so downstream consumers can run full
4583    /// raw-EDIFACT validation (`Mapper::validate_edifact`) without the schema files.
4584    #[serde(default)]
4585    pub pid_ahb_workflows: BTreeMap<String, ahb_types::AhbWorkflow>,
4586    /// Per-PID transaction group ID (key: "pid_55001", value: "SG4").
4587    /// Derived from the common `source_group` prefix of transaction definitions.
4588    /// Empty string for message-only variants (e.g., ORDCHG).
4589    #[serde(default)]
4590    pub tx_groups: BTreeMap<String, String>,
4591}
4592
4593impl VariantCache {
4594    /// Save this variant cache to a single JSON file.
4595    pub fn save(&self, path: &Path) -> Result<(), MappingError> {
4596        let encoded = serde_json::to_vec(self).map_err(|e| MappingError::CacheWrite {
4597            path: path.display().to_string(),
4598            message: e.to_string(),
4599        })?;
4600        if let Some(parent) = path.parent() {
4601            std::fs::create_dir_all(parent)?;
4602        }
4603        std::fs::write(path, encoded)?;
4604        Ok(())
4605    }
4606
4607    /// Load a variant cache from a single JSON file.
4608    pub fn load(path: &Path) -> Result<Self, MappingError> {
4609        let bytes = std::fs::read(path)?;
4610        serde_json::from_slice(&bytes).map_err(|e| MappingError::CacheRead {
4611            path: path.display().to_string(),
4612            message: e.to_string(),
4613        })
4614    }
4615
4616    /// Get the transaction group for a PID (e.g., "SG4" for UTILMD PIDs).
4617    /// Returns `None` if the PID is not in this variant.
4618    /// Returns `Some("")` for message-only variants (no transaction group).
4619    pub fn tx_group(&self, pid: &str) -> Option<&str> {
4620        self.tx_groups
4621            .get(&format!("pid_{pid}"))
4622            .map(|s| s.as_str())
4623    }
4624
4625    /// Build a `MappingEngine` from the message-level definitions, attaching
4626    /// the per-PID code lookup so forward mapping enriches code fields with
4627    /// `{ code, meaning, enum }` objects.
4628    pub fn msg_engine(&self, pid: &str) -> MappingEngine {
4629        let mut eng = MappingEngine::from_definitions(self.message_defs.clone()).with_pid(pid);
4630        if let Some(cl) = self.code_lookups.get(&format!("pid_{pid}")) {
4631            eng = eng.with_code_lookup(cl.clone());
4632        }
4633        eng
4634    }
4635
4636    /// Build a `MappingEngine` from the transaction-level definitions for a PID,
4637    /// attaching the per-PID code lookup. Returns `None` if the PID is not in
4638    /// this variant.
4639    pub fn tx_engine(&self, pid: &str) -> Option<MappingEngine> {
4640        self.transaction_defs
4641            .get(&format!("pid_{pid}"))
4642            .map(|defs| {
4643                let mut eng = MappingEngine::from_definitions(defs.clone()).with_pid(pid);
4644                if let Some(cl) = self.code_lookups.get(&format!("pid_{pid}")) {
4645                    eng = eng.with_code_lookup(cl.clone());
4646                }
4647                eng
4648            })
4649    }
4650
4651    /// Get a PID-filtered MIG schema.
4652    /// Returns `None` if no MIG schema or no segment numbers for this PID.
4653    ///
4654    /// Falls back to the empty-PID workflow's segment numbers when the AHB
4655    /// has no Pruefidentifikator attribute (e.g., APERAK — one workflow for
4656    /// all BGM doc codes). This lets `from_edifact` work for variants whose
4657    /// AHB doesn't enumerate per-PID segment numbers.
4658    pub fn filtered_mig(&self, pid: &str) -> Option<mig_types::schema::mig::MigSchema> {
4659        let mig = self.mig_schema.as_ref()?;
4660        let numbers = self
4661            .pid_segment_numbers
4662            .get(&format!("pid_{pid}"))
4663            .or_else(|| self.pid_segment_numbers.get("pid_"))?;
4664        let number_set: std::collections::HashSet<String> = numbers.iter().cloned().collect();
4665        Some(mig_assembly::pid_filter::filter_mig_for_pid(
4666            mig,
4667            &number_set,
4668        ))
4669    }
4670}
4671
4672/// Bundled data for a single format version (e.g., FV2504).
4673///
4674/// Contains all VariantCaches for every message type in that FV,
4675/// serialized as one bincode file for distribution via GitHub releases.
4676#[derive(serde::Serialize, serde::Deserialize)]
4677pub struct DataBundle {
4678    pub format_version: String,
4679    pub bundle_version: u32,
4680    pub variants: BTreeMap<String, VariantCache>,
4681    /// PID-agnostic BO4E type catalog (parsed from `bo4e-german` source).
4682    ///
4683    /// Populated by the bundle generator at compile-mappings time. Older bundles
4684    /// without this field deserialize to an empty catalog.
4685    #[serde(default)]
4686    pub bo4e_catalog: crate::bo4e_catalog::Bo4eCatalog,
4687}
4688
4689impl DataBundle {
4690    pub const CURRENT_VERSION: u32 = 2;
4691
4692    pub fn variant(&self, name: &str) -> Option<&VariantCache> {
4693        self.variants.get(name)
4694    }
4695
4696    pub fn write_to<W: std::io::Write>(&self, writer: &mut W) -> Result<(), MappingError> {
4697        let encoded = serde_json::to_vec(self).map_err(|e| MappingError::CacheWrite {
4698            path: "<stream>".to_string(),
4699            message: e.to_string(),
4700        })?;
4701        writer.write_all(&encoded).map_err(MappingError::Io)
4702    }
4703
4704    pub fn read_from<R: std::io::Read>(reader: &mut R) -> Result<Self, MappingError> {
4705        let mut bytes = Vec::new();
4706        reader.read_to_end(&mut bytes).map_err(MappingError::Io)?;
4707        serde_json::from_slice(&bytes).map_err(|e| MappingError::CacheRead {
4708            path: "<stream>".to_string(),
4709            message: e.to_string(),
4710        })
4711    }
4712
4713    pub fn read_from_checked<R: std::io::Read>(reader: &mut R) -> Result<Self, MappingError> {
4714        let bundle = Self::read_from(reader)?;
4715        if bundle.bundle_version != Self::CURRENT_VERSION {
4716            return Err(MappingError::CacheRead {
4717                path: "<stream>".to_string(),
4718                message: format!(
4719                    "Incompatible bundle version {}, expected version {}. \
4720                     Run `edifact-data update` to fetch compatible bundles.",
4721                    bundle.bundle_version,
4722                    Self::CURRENT_VERSION
4723                ),
4724            });
4725        }
4726        Ok(bundle)
4727    }
4728
4729    pub fn save(&self, path: &Path) -> Result<(), MappingError> {
4730        if let Some(parent) = path.parent() {
4731            std::fs::create_dir_all(parent)?;
4732        }
4733        let mut file = std::fs::File::create(path).map_err(MappingError::Io)?;
4734        self.write_to(&mut file)
4735    }
4736
4737    pub fn load(path: &Path) -> Result<Self, MappingError> {
4738        let mut file = std::fs::File::open(path).map_err(MappingError::Io)?;
4739        Self::read_from_checked(&mut file)
4740    }
4741}
4742
4743#[cfg(test)]
4744mod variant_cache_helper_tests {
4745    use super::*;
4746
4747    fn make_test_cache() -> VariantCache {
4748        let mut tx_groups = BTreeMap::new();
4749        tx_groups.insert("pid_55001".to_string(), "SG4".to_string());
4750        tx_groups.insert("pid_21007".to_string(), "SG14".to_string());
4751
4752        let mut transaction_defs = BTreeMap::new();
4753        transaction_defs.insert("pid_55001".to_string(), vec![]);
4754        transaction_defs.insert("pid_21007".to_string(), vec![]);
4755
4756        VariantCache {
4757            message_defs: vec![],
4758            transaction_defs,
4759            combined_defs: BTreeMap::new(),
4760            code_lookups: BTreeMap::new(),
4761            mig_schema: None,
4762            segment_structure: None,
4763            pid_segment_numbers: BTreeMap::new(),
4764            pid_requirements: BTreeMap::new(),
4765            pid_ahb_workflows: BTreeMap::new(),
4766            tx_groups,
4767        }
4768    }
4769
4770    #[test]
4771    fn test_tx_group_returns_correct_group() {
4772        let vc = make_test_cache();
4773        assert_eq!(vc.tx_group("55001").unwrap(), "SG4");
4774        assert_eq!(vc.tx_group("21007").unwrap(), "SG14");
4775    }
4776
4777    #[test]
4778    fn test_tx_group_unknown_pid_returns_none() {
4779        let vc = make_test_cache();
4780        assert!(vc.tx_group("99999").is_none());
4781    }
4782
4783    #[test]
4784    fn test_msg_engine_returns_engine() {
4785        let vc = make_test_cache();
4786        let engine = vc.msg_engine("55001");
4787        assert_eq!(engine.definitions().len(), 0);
4788    }
4789
4790    #[test]
4791    fn test_tx_engine_returns_engine_for_known_pid() {
4792        let vc = make_test_cache();
4793        assert!(vc.tx_engine("55001").is_some());
4794    }
4795
4796    #[test]
4797    fn test_tx_engine_returns_none_for_unknown_pid() {
4798        let vc = make_test_cache();
4799        assert!(vc.tx_engine("99999").is_none());
4800    }
4801
4802    /// Build a cache whose every map holds many keys. Each call creates fresh
4803    /// `HashMap`s (fresh random hash seeds), so an order-dependent serializer
4804    /// produces different bytes on different calls.
4805    fn make_populated_cache() -> VariantCache {
4806        let pids: Vec<String> = (0..40).map(|i| format!("pid_{}", 55000 + i * 7)).collect();
4807        let schema: serde_json::Value = serde_json::from_str(include_str!(
4808            "../../mig-types/src/generated/fv2504/utilmd/pids/pid_55001_schema.json"
4809        ))
4810        .unwrap();
4811        let code_lookup = crate::code_lookup::CodeLookup::from_schema_value(&schema);
4812        let element_counts: serde_json::Map<String, serde_json::Value> = (0..40)
4813            .map(|i| (format!("T{i:02}"), serde_json::json!(i)))
4814            .collect();
4815        let segment_structure: SegmentStructure =
4816            serde_json::from_value(serde_json::json!({ "element_counts": element_counts }))
4817                .unwrap();
4818        let ubs: serde_json::Map<String, serde_json::Value> = (0..40)
4819            .map(|i| (format!("UB{i}"), serde_json::json!({ "Ref": i })))
4820            .collect();
4821        let workflow: ahb_types::AhbWorkflow = serde_json::from_value(serde_json::json!({
4822            "pruefidentifikator": "55001",
4823            "description": "",
4824            "communication_direction": null,
4825            "fields": [],
4826            "ub_definitions": ubs,
4827        }))
4828        .unwrap();
4829
4830        let mut vc = make_test_cache();
4831        vc.segment_structure = Some(segment_structure);
4832        for pid in &pids {
4833            vc.transaction_defs.insert(pid.clone(), vec![]);
4834            vc.combined_defs.insert(pid.clone(), vec![]);
4835            vc.code_lookups.insert(pid.clone(), code_lookup.clone());
4836            vc.pid_segment_numbers
4837                .insert(pid.clone(), vec!["00001".to_string()]);
4838            vc.pid_ahb_workflows.insert(pid.clone(), workflow.clone());
4839            vc.tx_groups.insert(pid.clone(), "SG4".to_string());
4840        }
4841        vc
4842    }
4843
4844    /// Enrichment of qualified field paths uses the codes of the segment variant
4845    /// the field reads, never those of a sibling variant of the same tag.
4846    #[test]
4847    fn test_enrichment_uses_codes_of_the_path_qualifier_variant() {
4848        let comp = |sub: u64, id: &str, codes: Option<serde_json::Value>| match codes {
4849            Some(c) => serde_json::json!({"sub_index": sub, "id": id, "type": "code", "codes": c}),
4850            None => serde_json::json!({"sub_index": sub, "id": id, "type": "data"}),
4851        };
4852        let code = |v: &str, n: &str| serde_json::json!([{"value": v, "name": n}]);
4853        let seg = |tag: &str, composite: &str, comps: Vec<serde_json::Value>| serde_json::json!({"id": tag, "elements": [{"index": 0, "composite": composite, "components": comps}]});
4854        let schema = serde_json::json!({"fields": {"sg15": {"segments": [
4855            seg("RFF", "C506", vec![comp(0, "1153", Some(code("Z13", "PID"))), comp(1, "1154", Some(code("21037", "RD / NB-Bewertung")))]),
4856            seg("RFF", "C506", vec![comp(0, "1153", Some(code("ACW", "Referenz"))), comp(1, "1154", None)]),
4857            seg("CAV", "C889", vec![comp(0, "7111", Some(code("Z91", "Z91"))), comp(1, "7110", Some(code("A", "Alpha")))]),
4858            seg("CAV", "C889", vec![comp(0, "7111", Some(code("ZF0", "ZF0"))), comp(1, "7110", Some(code("C", "Gamma")))]),
4859        ]}}});
4860        let engine = MappingEngine::new_empty()
4861            .with_code_lookup(crate::code_lookup::CodeLookup::from_schema_value(&schema));
4862        let def = MappingDefinition::from_toml_str(
4863            r#"
4864[meta]
4865entity = "Status"
4866bo4e_type = "Status"
4867source_group = "SG15"
4868source_path = "sg15"
4869discriminator = "RFF.0.0=Z13"
4870
4871[fields]
4872"rff.0.1" = "pruefidentifikator"
4873"rff[ACW].0.1" = "referenz"
4874"cav[Z91].0.1" = "z91Wert"
4875"cav[ZF0].0.1" = "zf0Wert"
4876"#,
4877        )
4878        .unwrap();
4879        let segment = |tag: &str, elements: &[&[&str]]| OwnedSegment {
4880            id: tag.to_string(),
4881            elements: elements
4882                .iter()
4883                .map(|e| e.iter().map(|c| c.to_string()).collect())
4884                .collect(),
4885            segment_number: 1,
4886        };
4887        let json = engine.map_forward_from_segments(
4888            &[
4889                segment("RFF", &[&["Z13", "21037"]]),
4890                segment("RFF", &[&["ACW", "REF-1"]]),
4891                segment("CAV", &[&["Z91", "C"]]),
4892                segment("CAV", &[&["ZF0", "C"]]),
4893            ],
4894            &def,
4895        );
4896        assert_eq!(
4897            json["referenz"],
4898            serde_json::json!("REF-1"),
4899            "RFF+ACW d1154 is data; RFF+Z13's codes must not apply: {json}"
4900        );
4901        assert_eq!(json["pruefidentifikator"]["meaning"], "RD / NB-Bewertung");
4902        assert_eq!(
4903            json["z91Wert"]["meaning"],
4904            serde_json::Value::Null,
4905            "'C' is a CAV+ZF0 code, unknown to CAV+Z91: {json}"
4906        );
4907        assert_eq!(json["zf0Wert"]["meaning"], "Gamma");
4908    }
4909
4910    #[test]
4911    fn test_variant_cache_serialization_is_deterministic() {
4912        let reference = serde_json::to_vec(&make_populated_cache()).unwrap();
4913        for _ in 0..5 {
4914            let again = serde_json::to_vec(&make_populated_cache()).unwrap();
4915            assert!(
4916                reference == again,
4917                "VariantCache serialization must not depend on HashMap iteration order"
4918            );
4919        }
4920    }
4921
4922    #[test]
4923    fn test_variant_cache_serializes_map_keys_sorted() {
4924        use indexmap::IndexMap;
4925        use serde::de::IgnoredAny;
4926
4927        #[derive(serde::Deserialize)]
4928        struct ProbeWorkflow {
4929            ub_definitions: IndexMap<String, IgnoredAny>,
4930        }
4931        #[derive(serde::Deserialize)]
4932        struct ProbeStructure {
4933            element_counts: IndexMap<String, usize>,
4934        }
4935        #[derive(serde::Deserialize)]
4936        struct Probe {
4937            transaction_defs: IndexMap<String, IgnoredAny>,
4938            combined_defs: IndexMap<String, IgnoredAny>,
4939            code_lookups: IndexMap<String, IndexMap<String, IgnoredAny>>,
4940            segment_structure: ProbeStructure,
4941            pid_segment_numbers: IndexMap<String, IgnoredAny>,
4942            pid_requirements: IndexMap<String, IgnoredAny>,
4943            pid_ahb_workflows: IndexMap<String, ProbeWorkflow>,
4944            tx_groups: IndexMap<String, String>,
4945        }
4946        fn assert_sorted<'a>(what: &str, keys: impl Iterator<Item = &'a String>) {
4947            let keys: Vec<&String> = keys.collect();
4948            let mut sorted = keys.clone();
4949            sorted.sort();
4950            assert_eq!(keys, sorted, "{what} keys must serialize in sorted order");
4951        }
4952
4953        let json = serde_json::to_string(&make_populated_cache()).unwrap();
4954        let probe: Probe = serde_json::from_str(&json).unwrap();
4955        assert_sorted("transaction_defs", probe.transaction_defs.keys());
4956        assert_sorted("combined_defs", probe.combined_defs.keys());
4957        assert_sorted("code_lookups", probe.code_lookups.keys());
4958        let lookup = probe.code_lookups.values().next().unwrap();
4959        assert!(lookup.len() > 10, "fixture lookup should have many entries");
4960        assert_sorted("code_lookup entries", lookup.keys());
4961        assert_sorted(
4962            "segment_structure",
4963            probe.segment_structure.element_counts.keys(),
4964        );
4965        assert_sorted("pid_segment_numbers", probe.pid_segment_numbers.keys());
4966        assert_sorted("pid_requirements", probe.pid_requirements.keys());
4967        assert_sorted("pid_ahb_workflows", probe.pid_ahb_workflows.keys());
4968        let wf = probe.pid_ahb_workflows.values().next().unwrap();
4969        assert_sorted("ub_definitions", wf.ub_definitions.keys());
4970        assert_sorted("tx_groups", probe.tx_groups.keys());
4971    }
4972
4973    #[test]
4974    fn test_data_bundle_serializes_variants_sorted() {
4975        use indexmap::IndexMap;
4976        use serde::de::IgnoredAny;
4977
4978        #[derive(serde::Deserialize)]
4979        struct Probe {
4980            variants: IndexMap<String, IgnoredAny>,
4981        }
4982        let variants: BTreeMap<String, VariantCache> = (0..20)
4983            .map(|i| (format!("VARIANT_{i:02}"), make_test_cache()))
4984            .collect();
4985        let bundle = DataBundle {
4986            format_version: "FV2504".to_string(),
4987            bundle_version: DataBundle::CURRENT_VERSION,
4988            variants,
4989            bo4e_catalog: Default::default(),
4990        };
4991        let mut bytes = Vec::new();
4992        bundle.write_to(&mut bytes).unwrap();
4993        let probe: Probe = serde_json::from_slice(&bytes).unwrap();
4994        let keys: Vec<&String> = probe.variants.keys().collect();
4995        let mut sorted = keys.clone();
4996        sorted.sort();
4997        assert_eq!(keys, sorted);
4998    }
4999}
5000
5001#[cfg(test)]
5002mod tests {
5003    use super::*;
5004    use crate::definition::{MappingDefinition, MappingMeta, StructuredFieldMapping};
5005    use indexmap::IndexMap;
5006
5007    fn make_def(fields: IndexMap<String, FieldMapping>) -> MappingDefinition {
5008        MappingDefinition {
5009            meta: MappingMeta {
5010                entity: "Test".to_string(),
5011                bo4e_type: "Test".to_string(),
5012                source_group: "SG4".to_string(),
5013                source_path: None,
5014                discriminator: None,
5015                repeat_on_tag: None,
5016                parent_field: None,
5017                target_list: None,
5018                order: None,
5019            },
5020            fields,
5021            complex_handlers: None,
5022        }
5023    }
5024
5025    #[test]
5026    fn test_map_interchange_single_transaction_backward_compat() {
5027        use mig_assembly::assembler::*;
5028
5029        // Single SG4 with SG5 — the common case for current PID 55001 fixtures
5030        let tree = AssembledTree {
5031            segments: vec![
5032                AssembledSegment {
5033                    tag: "UNH".to_string(),
5034                    elements: vec![vec!["001".to_string()]],
5035                    mig_number: None,
5036                    segment_number: None,
5037                },
5038                AssembledSegment {
5039                    tag: "BGM".to_string(),
5040                    elements: vec![vec!["E01".to_string()], vec!["DOC001".to_string()]],
5041                    mig_number: None,
5042                    segment_number: None,
5043                },
5044            ],
5045            groups: vec![
5046                AssembledGroup {
5047                    group_id: "SG2".to_string(),
5048                    repetitions: vec![AssembledGroupInstance {
5049                        segments: vec![AssembledSegment {
5050                            tag: "NAD".to_string(),
5051                            elements: vec![vec!["MS".to_string()], vec!["9900123".to_string()]],
5052                            mig_number: None,
5053                            segment_number: None,
5054                        }],
5055                        child_groups: vec![],
5056                        entry_mig_number: None,
5057                        variant_mig_numbers: vec![],
5058                        skipped_segments: vec![],
5059                        skipped_positions: Vec::new(),
5060                    }],
5061                },
5062                AssembledGroup {
5063                    group_id: "SG4".to_string(),
5064                    repetitions: vec![AssembledGroupInstance {
5065                        segments: vec![AssembledSegment {
5066                            tag: "IDE".to_string(),
5067                            elements: vec![vec!["24".to_string()], vec!["TX001".to_string()]],
5068                            mig_number: None,
5069                            segment_number: None,
5070                        }],
5071                        child_groups: vec![AssembledGroup {
5072                            group_id: "SG5".to_string(),
5073                            repetitions: vec![AssembledGroupInstance {
5074                                segments: vec![AssembledSegment {
5075                                    tag: "LOC".to_string(),
5076                                    elements: vec![
5077                                        vec!["Z16".to_string()],
5078                                        vec!["DE000111222333".to_string()],
5079                                    ],
5080                                    mig_number: None,
5081                                    segment_number: None,
5082                                }],
5083                                child_groups: vec![],
5084                                entry_mig_number: None,
5085                                variant_mig_numbers: vec![],
5086                                skipped_segments: vec![],
5087                                skipped_positions: Vec::new(),
5088                            }],
5089                        }],
5090                        entry_mig_number: None,
5091                        variant_mig_numbers: vec![],
5092                        skipped_segments: vec![],
5093                        skipped_positions: Vec::new(),
5094                    }],
5095                },
5096            ],
5097            post_group_start: 2,
5098            inter_group_segments: std::collections::BTreeMap::new(),
5099        };
5100
5101        // Empty message engine (no message-level defs for this test)
5102        let msg_engine = MappingEngine::from_definitions(vec![]);
5103
5104        // Transaction defs
5105        let mut tx_fields: IndexMap<String, FieldMapping> = IndexMap::new();
5106        tx_fields.insert(
5107            "ide.1".to_string(),
5108            FieldMapping::Simple("vorgangId".to_string()),
5109        );
5110        let mut malo_fields: IndexMap<String, FieldMapping> = IndexMap::new();
5111        malo_fields.insert(
5112            "loc.1".to_string(),
5113            FieldMapping::Simple("marktlokationsId".to_string()),
5114        );
5115
5116        let tx_engine = MappingEngine::from_definitions(vec![
5117            MappingDefinition {
5118                meta: MappingMeta {
5119                    entity: "Prozessdaten".to_string(),
5120                    bo4e_type: "Prozessdaten".to_string(),
5121                    source_group: "SG4".to_string(),
5122                    source_path: None,
5123                    discriminator: None,
5124                    repeat_on_tag: None,
5125                    parent_field: None,
5126                    target_list: None,
5127                    order: None,
5128                },
5129                fields: tx_fields,
5130                complex_handlers: None,
5131            },
5132            MappingDefinition {
5133                meta: MappingMeta {
5134                    entity: "Marktlokation".to_string(),
5135                    bo4e_type: "Marktlokation".to_string(),
5136                    source_group: "SG4.SG5".to_string(),
5137                    source_path: None,
5138                    discriminator: None,
5139                    repeat_on_tag: None,
5140                    parent_field: None,
5141                    target_list: None,
5142                    order: None,
5143                },
5144                fields: malo_fields,
5145                complex_handlers: None,
5146            },
5147        ]);
5148
5149        let result = MappingEngine::map_interchange(&msg_engine, &tx_engine, &tree, "SG4", true);
5150
5151        assert_eq!(result.transaktionen.len(), 1);
5152        assert_eq!(
5153            result.transaktionen[0].transaktionsdaten["vorgangId"]
5154                .as_str()
5155                .unwrap(),
5156            "TX001"
5157        );
5158        // Marktlokation (SG4.SG5) stays top-level — SG4 IS the transaction root,
5159        // so Marktlokation is a peer of Prozessdaten, not a child of it.
5160        assert_eq!(
5161            result.transaktionen[0].stammdaten["marktlokation"]["marktlokationsId"]
5162                .as_str()
5163                .unwrap(),
5164            "DE000111222333"
5165        );
5166    }
5167
5168    #[test]
5169    fn test_map_reverse_pads_intermediate_empty_elements() {
5170        // NAD+Z09+++Muster:Max — positions 0 and 3 populated, 1 and 2 should become [""]
5171        let mut fields = IndexMap::new();
5172        fields.insert(
5173            "nad.0".to_string(),
5174            FieldMapping::Structured(StructuredFieldMapping {
5175                target: String::new(),
5176                transform: None,
5177                when: None,
5178                default: Some("Z09".to_string()),
5179                enum_map: None,
5180                when_filled: None,
5181                also_target: None,
5182                also_enum_map: None,
5183            }),
5184        );
5185        fields.insert(
5186            "nad.3.0".to_string(),
5187            FieldMapping::Simple("name".to_string()),
5188        );
5189        fields.insert(
5190            "nad.3.1".to_string(),
5191            FieldMapping::Simple("vorname".to_string()),
5192        );
5193
5194        let def = make_def(fields);
5195        let engine = MappingEngine::from_definitions(vec![]);
5196
5197        let bo4e = serde_json::json!({
5198            "name": "Muster",
5199            "vorname": "Max"
5200        });
5201
5202        let instance = engine.map_reverse(&bo4e, &def);
5203        assert_eq!(instance.segments.len(), 1);
5204
5205        let nad = &instance.segments[0];
5206        assert_eq!(nad.tag, "NAD");
5207        assert_eq!(nad.elements.len(), 4);
5208        assert_eq!(nad.elements[0], vec!["Z09"]);
5209        // Intermediate positions 1 and 2 should be padded to [""]
5210        assert_eq!(nad.elements[1], vec![""]);
5211        assert_eq!(nad.elements[2], vec![""]);
5212        assert_eq!(nad.elements[3][0], "Muster");
5213        assert_eq!(nad.elements[3][1], "Max");
5214    }
5215
5216    #[test]
5217    fn test_map_reverse_no_padding_when_contiguous() {
5218        // DTM+92:20250531:303 — all three components in element 0, no gaps
5219        let mut fields = IndexMap::new();
5220        fields.insert(
5221            "dtm.0.0".to_string(),
5222            FieldMapping::Structured(StructuredFieldMapping {
5223                target: String::new(),
5224                transform: None,
5225                when: None,
5226                default: Some("92".to_string()),
5227                enum_map: None,
5228                when_filled: None,
5229                also_target: None,
5230                also_enum_map: None,
5231            }),
5232        );
5233        fields.insert(
5234            "dtm.0.1".to_string(),
5235            FieldMapping::Simple("value".to_string()),
5236        );
5237        fields.insert(
5238            "dtm.0.2".to_string(),
5239            FieldMapping::Structured(StructuredFieldMapping {
5240                target: String::new(),
5241                transform: None,
5242                when: None,
5243                default: Some("303".to_string()),
5244                enum_map: None,
5245                when_filled: None,
5246                also_target: None,
5247                also_enum_map: None,
5248            }),
5249        );
5250
5251        let def = make_def(fields);
5252        let engine = MappingEngine::from_definitions(vec![]);
5253
5254        let bo4e = serde_json::json!({ "value": "20250531" });
5255
5256        let instance = engine.map_reverse(&bo4e, &def);
5257        let dtm = &instance.segments[0];
5258        // Single element with 3 components — no intermediate padding needed
5259        assert_eq!(dtm.elements.len(), 1);
5260        assert_eq!(dtm.elements[0], vec!["92", "20250531", "303"]);
5261    }
5262
5263    #[test]
5264    fn test_map_message_level_extracts_sg2_only() {
5265        use mig_assembly::assembler::*;
5266
5267        // Build a tree with SG2 (message-level) and SG4 (transaction-level)
5268        let tree = AssembledTree {
5269            segments: vec![
5270                AssembledSegment {
5271                    tag: "UNH".to_string(),
5272                    elements: vec![vec!["001".to_string()]],
5273                    mig_number: None,
5274                    segment_number: None,
5275                },
5276                AssembledSegment {
5277                    tag: "BGM".to_string(),
5278                    elements: vec![vec!["E01".to_string()]],
5279                    mig_number: None,
5280                    segment_number: None,
5281                },
5282            ],
5283            groups: vec![
5284                AssembledGroup {
5285                    group_id: "SG2".to_string(),
5286                    repetitions: vec![AssembledGroupInstance {
5287                        segments: vec![AssembledSegment {
5288                            tag: "NAD".to_string(),
5289                            elements: vec![vec!["MS".to_string()], vec!["9900123".to_string()]],
5290                            mig_number: None,
5291                            segment_number: None,
5292                        }],
5293                        child_groups: vec![],
5294                        entry_mig_number: None,
5295                        variant_mig_numbers: vec![],
5296                        skipped_segments: vec![],
5297                        skipped_positions: Vec::new(),
5298                    }],
5299                },
5300                AssembledGroup {
5301                    group_id: "SG4".to_string(),
5302                    repetitions: vec![AssembledGroupInstance {
5303                        segments: vec![AssembledSegment {
5304                            tag: "IDE".to_string(),
5305                            elements: vec![vec!["24".to_string()], vec!["TX001".to_string()]],
5306                            mig_number: None,
5307                            segment_number: None,
5308                        }],
5309                        child_groups: vec![],
5310                        entry_mig_number: None,
5311                        variant_mig_numbers: vec![],
5312                        skipped_segments: vec![],
5313                        skipped_positions: Vec::new(),
5314                    }],
5315                },
5316            ],
5317            post_group_start: 2,
5318            inter_group_segments: std::collections::BTreeMap::new(),
5319        };
5320
5321        // Message-level definition maps SG2
5322        let mut msg_fields: IndexMap<String, FieldMapping> = IndexMap::new();
5323        msg_fields.insert(
5324            "nad.0".to_string(),
5325            FieldMapping::Simple("marktrolle".to_string()),
5326        );
5327        msg_fields.insert(
5328            "nad.1".to_string(),
5329            FieldMapping::Simple("rollencodenummer".to_string()),
5330        );
5331        let msg_def = MappingDefinition {
5332            meta: MappingMeta {
5333                entity: "Marktteilnehmer".to_string(),
5334                bo4e_type: "Marktteilnehmer".to_string(),
5335                source_group: "SG2".to_string(),
5336                source_path: None,
5337                discriminator: None,
5338                repeat_on_tag: None,
5339                parent_field: None,
5340                target_list: None,
5341                order: None,
5342            },
5343            fields: msg_fields,
5344            complex_handlers: None,
5345        };
5346
5347        let engine = MappingEngine::from_definitions(vec![msg_def.clone()]);
5348        let result = engine.map_all_forward(&tree);
5349
5350        // Should contain Marktteilnehmer from SG2
5351        assert!(result.get("marktteilnehmer").is_some());
5352        let mt = &result["marktteilnehmer"];
5353        assert_eq!(mt["marktrolle"].as_str().unwrap(), "MS");
5354        assert_eq!(mt["rollencodenummer"].as_str().unwrap(), "9900123");
5355    }
5356
5357    #[test]
5358    fn test_map_transaction_scoped_to_sg4_instance() {
5359        use mig_assembly::assembler::*;
5360
5361        // Build a tree with SG4 containing SG5 (LOC+Z16)
5362        let tree = AssembledTree {
5363            segments: vec![
5364                AssembledSegment {
5365                    tag: "UNH".to_string(),
5366                    elements: vec![vec!["001".to_string()]],
5367                    mig_number: None,
5368                    segment_number: None,
5369                },
5370                AssembledSegment {
5371                    tag: "BGM".to_string(),
5372                    elements: vec![vec!["E01".to_string()]],
5373                    mig_number: None,
5374                    segment_number: None,
5375                },
5376            ],
5377            groups: vec![AssembledGroup {
5378                group_id: "SG4".to_string(),
5379                repetitions: vec![AssembledGroupInstance {
5380                    segments: vec![AssembledSegment {
5381                        tag: "IDE".to_string(),
5382                        elements: vec![vec!["24".to_string()], vec!["TX001".to_string()]],
5383                        mig_number: None,
5384                        segment_number: None,
5385                    }],
5386                    child_groups: vec![AssembledGroup {
5387                        group_id: "SG5".to_string(),
5388                        repetitions: vec![AssembledGroupInstance {
5389                            segments: vec![AssembledSegment {
5390                                tag: "LOC".to_string(),
5391                                elements: vec![
5392                                    vec!["Z16".to_string()],
5393                                    vec!["DE000111222333".to_string()],
5394                                ],
5395                                mig_number: None,
5396                                segment_number: None,
5397                            }],
5398                            child_groups: vec![],
5399                            entry_mig_number: None,
5400                            variant_mig_numbers: vec![],
5401                            skipped_segments: vec![],
5402                            skipped_positions: Vec::new(),
5403                        }],
5404                    }],
5405                    entry_mig_number: None,
5406                    variant_mig_numbers: vec![],
5407                    skipped_segments: vec![],
5408                    skipped_positions: Vec::new(),
5409                }],
5410            }],
5411            post_group_start: 2,
5412            inter_group_segments: std::collections::BTreeMap::new(),
5413        };
5414
5415        // Transaction-level definitions: prozessdaten (root of SG4) + marktlokation (SG5)
5416        let mut proz_fields: IndexMap<String, FieldMapping> = IndexMap::new();
5417        proz_fields.insert(
5418            "ide.1".to_string(),
5419            FieldMapping::Simple("vorgangId".to_string()),
5420        );
5421        let proz_def = MappingDefinition {
5422            meta: MappingMeta {
5423                entity: "Prozessdaten".to_string(),
5424                bo4e_type: "Prozessdaten".to_string(),
5425                source_group: "".to_string(), // Root-level within transaction sub-tree
5426                source_path: None,
5427                discriminator: None,
5428                repeat_on_tag: None,
5429                parent_field: None,
5430                target_list: None,
5431                order: None,
5432            },
5433            fields: proz_fields,
5434            complex_handlers: None,
5435        };
5436
5437        let mut malo_fields: IndexMap<String, FieldMapping> = IndexMap::new();
5438        malo_fields.insert(
5439            "loc.1".to_string(),
5440            FieldMapping::Simple("marktlokationsId".to_string()),
5441        );
5442        let malo_def = MappingDefinition {
5443            meta: MappingMeta {
5444                entity: "Marktlokation".to_string(),
5445                bo4e_type: "Marktlokation".to_string(),
5446                source_group: "SG5".to_string(), // Relative to SG4, not "SG4.SG5"
5447                source_path: None,
5448                discriminator: None,
5449                repeat_on_tag: None,
5450                parent_field: None,
5451                target_list: None,
5452                order: None,
5453            },
5454            fields: malo_fields,
5455            complex_handlers: None,
5456        };
5457
5458        let tx_engine = MappingEngine::from_definitions(vec![proz_def, malo_def]);
5459
5460        // Scope to the SG4 instance and map
5461        let sg4 = &tree.groups[0]; // SG4 group
5462        let sg4_instance = &sg4.repetitions[0];
5463        let sub_tree = sg4_instance.as_assembled_tree();
5464
5465        let result = tx_engine.map_all_forward(&sub_tree);
5466
5467        // Should contain Prozessdaten from SG4 root segments
5468        assert_eq!(
5469            result["prozessdaten"]["vorgangId"].as_str().unwrap(),
5470            "TX001"
5471        );
5472
5473        // Should contain Marktlokation from SG5 within SG4
5474        assert_eq!(
5475            result["marktlokation"]["marktlokationsId"]
5476                .as_str()
5477                .unwrap(),
5478            "DE000111222333"
5479        );
5480    }
5481
5482    #[test]
5483    fn test_map_interchange_produces_full_hierarchy() {
5484        use mig_assembly::assembler::*;
5485
5486        // Build a tree with SG2 (message-level) and SG4 with two repetitions (two transactions)
5487        let tree = AssembledTree {
5488            segments: vec![
5489                AssembledSegment {
5490                    tag: "UNH".to_string(),
5491                    elements: vec![vec!["001".to_string()]],
5492                    mig_number: None,
5493                    segment_number: None,
5494                },
5495                AssembledSegment {
5496                    tag: "BGM".to_string(),
5497                    elements: vec![vec!["E01".to_string()]],
5498                    mig_number: None,
5499                    segment_number: None,
5500                },
5501            ],
5502            groups: vec![
5503                AssembledGroup {
5504                    group_id: "SG2".to_string(),
5505                    repetitions: vec![AssembledGroupInstance {
5506                        segments: vec![AssembledSegment {
5507                            tag: "NAD".to_string(),
5508                            elements: vec![vec!["MS".to_string()], vec!["9900123".to_string()]],
5509                            mig_number: None,
5510                            segment_number: None,
5511                        }],
5512                        child_groups: vec![],
5513                        entry_mig_number: None,
5514                        variant_mig_numbers: vec![],
5515                        skipped_segments: vec![],
5516                        skipped_positions: Vec::new(),
5517                    }],
5518                },
5519                AssembledGroup {
5520                    group_id: "SG4".to_string(),
5521                    repetitions: vec![
5522                        AssembledGroupInstance {
5523                            segments: vec![AssembledSegment {
5524                                tag: "IDE".to_string(),
5525                                elements: vec![vec!["24".to_string()], vec!["TX001".to_string()]],
5526                                mig_number: None,
5527                                segment_number: None,
5528                            }],
5529                            child_groups: vec![],
5530                            entry_mig_number: None,
5531                            variant_mig_numbers: vec![],
5532                            skipped_segments: vec![],
5533                            skipped_positions: Vec::new(),
5534                        },
5535                        AssembledGroupInstance {
5536                            segments: vec![AssembledSegment {
5537                                tag: "IDE".to_string(),
5538                                elements: vec![vec!["24".to_string()], vec!["TX002".to_string()]],
5539                                mig_number: None,
5540                                segment_number: None,
5541                            }],
5542                            child_groups: vec![],
5543                            entry_mig_number: None,
5544                            variant_mig_numbers: vec![],
5545                            skipped_segments: vec![],
5546                            skipped_positions: Vec::new(),
5547                        },
5548                    ],
5549                },
5550            ],
5551            post_group_start: 2,
5552            inter_group_segments: std::collections::BTreeMap::new(),
5553        };
5554
5555        // Message-level definitions
5556        let mut msg_fields: IndexMap<String, FieldMapping> = IndexMap::new();
5557        msg_fields.insert(
5558            "nad.0".to_string(),
5559            FieldMapping::Simple("marktrolle".to_string()),
5560        );
5561        let msg_defs = vec![MappingDefinition {
5562            meta: MappingMeta {
5563                entity: "Marktteilnehmer".to_string(),
5564                bo4e_type: "Marktteilnehmer".to_string(),
5565                source_group: "SG2".to_string(),
5566                source_path: None,
5567                discriminator: None,
5568                repeat_on_tag: None,
5569                parent_field: None,
5570                target_list: None,
5571                order: None,
5572            },
5573            fields: msg_fields,
5574            complex_handlers: None,
5575        }];
5576
5577        // Transaction-level definitions (source_group includes SG4 prefix)
5578        let mut tx_fields: IndexMap<String, FieldMapping> = IndexMap::new();
5579        tx_fields.insert(
5580            "ide.1".to_string(),
5581            FieldMapping::Simple("vorgangId".to_string()),
5582        );
5583        let tx_defs = vec![MappingDefinition {
5584            meta: MappingMeta {
5585                entity: "Prozessdaten".to_string(),
5586                bo4e_type: "Prozessdaten".to_string(),
5587                source_group: "SG4".to_string(),
5588                source_path: None,
5589                discriminator: None,
5590                repeat_on_tag: None,
5591                parent_field: None,
5592                target_list: None,
5593                order: None,
5594            },
5595            fields: tx_fields,
5596            complex_handlers: None,
5597        }];
5598
5599        let msg_engine = MappingEngine::from_definitions(msg_defs);
5600        let tx_engine = MappingEngine::from_definitions(tx_defs);
5601
5602        let result = MappingEngine::map_interchange(&msg_engine, &tx_engine, &tree, "SG4", true);
5603
5604        // Message-level stammdaten
5605        assert!(result.stammdaten["marktteilnehmer"].is_object());
5606        assert_eq!(
5607            result.stammdaten["marktteilnehmer"]["marktrolle"]
5608                .as_str()
5609                .unwrap(),
5610            "MS"
5611        );
5612
5613        // Two transactions
5614        assert_eq!(result.transaktionen.len(), 2);
5615        assert_eq!(
5616            result.transaktionen[0].transaktionsdaten["vorgangId"]
5617                .as_str()
5618                .unwrap(),
5619            "TX001"
5620        );
5621        assert_eq!(
5622            result.transaktionen[1].transaktionsdaten["vorgangId"]
5623                .as_str()
5624                .unwrap(),
5625            "TX002"
5626        );
5627    }
5628
5629    #[test]
5630    fn test_map_reverse_with_segment_structure_pads_trailing() {
5631        // STS+7++E01 — position 0 and 2 populated, MIG says 5 elements
5632        let mut fields = IndexMap::new();
5633        fields.insert(
5634            "sts.0".to_string(),
5635            FieldMapping::Structured(StructuredFieldMapping {
5636                target: String::new(),
5637                transform: None,
5638                when: None,
5639                default: Some("7".to_string()),
5640                enum_map: None,
5641                when_filled: None,
5642                also_target: None,
5643                also_enum_map: None,
5644            }),
5645        );
5646        fields.insert(
5647            "sts.2".to_string(),
5648            FieldMapping::Simple("grund".to_string()),
5649        );
5650
5651        let def = make_def(fields);
5652
5653        // Build a SegmentStructure manually via BTreeMap
5654        let mut counts = std::collections::BTreeMap::new();
5655        counts.insert("STS".to_string(), 5usize);
5656        let ss = SegmentStructure {
5657            element_counts: counts,
5658        };
5659
5660        let engine = MappingEngine::from_definitions(vec![]).with_segment_structure(ss);
5661
5662        let bo4e = serde_json::json!({ "grund": "E01" });
5663
5664        let instance = engine.map_reverse(&bo4e, &def);
5665        let sts = &instance.segments[0];
5666        // Should have 5 elements: pos 0 = ["7"], pos 1 = [""] (intermediate pad),
5667        // pos 2 = ["E01"], pos 3 = [""] (trailing pad), pos 4 = [""] (trailing pad)
5668        assert_eq!(sts.elements.len(), 5);
5669        assert_eq!(sts.elements[0], vec!["7"]);
5670        assert_eq!(sts.elements[1], vec![""]);
5671        assert_eq!(sts.elements[2], vec!["E01"]);
5672        assert_eq!(sts.elements[3], vec![""]);
5673        assert_eq!(sts.elements[4], vec![""]);
5674    }
5675
5676    #[test]
5677    fn test_resolve_child_relative_with_source_path() {
5678        let mut map: std::collections::HashMap<String, Vec<usize>> =
5679            std::collections::HashMap::new();
5680        map.insert("sg4.sg8_ze1".to_string(), vec![6]);
5681        map.insert("sg4.sg8_z98".to_string(), vec![0]);
5682
5683        // Child without explicit index → resolved from source_path
5684        assert_eq!(
5685            resolve_child_relative("SG8.SG10", Some("sg4.sg8_ze1.sg10"), &map, 0),
5686            "SG8:6.SG10"
5687        );
5688
5689        // Child with explicit index → kept as-is
5690        assert_eq!(
5691            resolve_child_relative("SG8:3.SG10", Some("sg4.sg8_ze1.sg10"), &map, 0),
5692            "SG8:3.SG10"
5693        );
5694
5695        // Source path not in map → kept as-is
5696        assert_eq!(
5697            resolve_child_relative("SG8.SG10", Some("sg4.sg8_unknown.sg10"), &map, 0),
5698            "SG8.SG10"
5699        );
5700
5701        // No source_path → kept as-is
5702        assert_eq!(
5703            resolve_child_relative("SG8.SG10", None, &map, 0),
5704            "SG8.SG10"
5705        );
5706
5707        // SG9 also works
5708        assert_eq!(
5709            resolve_child_relative("SG8.SG9", Some("sg4.sg8_z98.sg9"), &map, 0),
5710            "SG8:0.SG9"
5711        );
5712
5713        // Multi-rep parent: item_idx selects the correct parent rep
5714        map.insert("sg4.sg8_zf3".to_string(), vec![3, 4]);
5715        assert_eq!(
5716            resolve_child_relative("SG8.SG10", Some("sg4.sg8_zf3.sg10"), &map, 0),
5717            "SG8:3.SG10"
5718        );
5719        assert_eq!(
5720            resolve_child_relative("SG8.SG10", Some("sg4.sg8_zf3.sg10"), &map, 1),
5721            "SG8:4.SG10"
5722        );
5723    }
5724
5725    #[test]
5726    fn test_place_in_groups_returns_rep_index() {
5727        let mut groups: Vec<AssembledGroup> = Vec::new();
5728
5729        // Append (no index) → returns position 0
5730        let instance = AssembledGroupInstance {
5731            segments: vec![],
5732            child_groups: vec![],
5733            entry_mig_number: None,
5734            variant_mig_numbers: vec![],
5735            skipped_segments: vec![],
5736            skipped_positions: Vec::new(),
5737        };
5738        assert_eq!(place_in_groups(&mut groups, "SG8", instance), 0);
5739
5740        // Append again → returns position 1
5741        let instance = AssembledGroupInstance {
5742            segments: vec![],
5743            child_groups: vec![],
5744            entry_mig_number: None,
5745            variant_mig_numbers: vec![],
5746            skipped_segments: vec![],
5747            skipped_positions: Vec::new(),
5748        };
5749        assert_eq!(place_in_groups(&mut groups, "SG8", instance), 1);
5750
5751        // Explicit index → returns that index
5752        let instance = AssembledGroupInstance {
5753            segments: vec![],
5754            child_groups: vec![],
5755            entry_mig_number: None,
5756            variant_mig_numbers: vec![],
5757            skipped_segments: vec![],
5758            skipped_positions: Vec::new(),
5759        };
5760        assert_eq!(place_in_groups(&mut groups, "SG8:5", instance), 5);
5761    }
5762
5763    #[test]
5764    fn test_resolve_by_source_path() {
5765        use mig_assembly::assembler::*;
5766
5767        // Build a tree: SG4[0] → SG8 with two reps (Z98 and ZD7) → each has SG10
5768        let tree = AssembledTree {
5769            segments: vec![],
5770            groups: vec![AssembledGroup {
5771                group_id: "SG4".to_string(),
5772                repetitions: vec![AssembledGroupInstance {
5773                    segments: vec![],
5774                    child_groups: vec![AssembledGroup {
5775                        group_id: "SG8".to_string(),
5776                        repetitions: vec![
5777                            AssembledGroupInstance {
5778                                segments: vec![AssembledSegment {
5779                                    tag: "SEQ".to_string(),
5780                                    elements: vec![vec!["Z98".to_string()]],
5781                                    mig_number: None,
5782                                    segment_number: None,
5783                                }],
5784                                child_groups: vec![AssembledGroup {
5785                                    group_id: "SG10".to_string(),
5786                                    repetitions: vec![AssembledGroupInstance {
5787                                        segments: vec![AssembledSegment {
5788                                            tag: "CCI".to_string(),
5789                                            elements: vec![vec![], vec![], vec!["ZB3".to_string()]],
5790                                            mig_number: None,
5791                                            segment_number: None,
5792                                        }],
5793                                        child_groups: vec![],
5794                                        entry_mig_number: None,
5795                                        variant_mig_numbers: vec![],
5796                                        skipped_segments: vec![],
5797                                        skipped_positions: Vec::new(),
5798                                    }],
5799                                }],
5800                                entry_mig_number: None,
5801                                variant_mig_numbers: vec![],
5802                                skipped_segments: vec![],
5803                                skipped_positions: Vec::new(),
5804                            },
5805                            AssembledGroupInstance {
5806                                segments: vec![AssembledSegment {
5807                                    tag: "SEQ".to_string(),
5808                                    elements: vec![vec!["ZD7".to_string()]],
5809                                    mig_number: None,
5810                                    segment_number: None,
5811                                }],
5812                                child_groups: vec![AssembledGroup {
5813                                    group_id: "SG10".to_string(),
5814                                    repetitions: vec![AssembledGroupInstance {
5815                                        segments: vec![AssembledSegment {
5816                                            tag: "CCI".to_string(),
5817                                            elements: vec![vec![], vec![], vec!["ZE6".to_string()]],
5818                                            mig_number: None,
5819                                            segment_number: None,
5820                                        }],
5821                                        child_groups: vec![],
5822                                        entry_mig_number: None,
5823                                        variant_mig_numbers: vec![],
5824                                        skipped_segments: vec![],
5825                                        skipped_positions: Vec::new(),
5826                                    }],
5827                                }],
5828                                entry_mig_number: None,
5829                                variant_mig_numbers: vec![],
5830                                skipped_segments: vec![],
5831                                skipped_positions: Vec::new(),
5832                            },
5833                        ],
5834                    }],
5835                    entry_mig_number: None,
5836                    variant_mig_numbers: vec![],
5837                    skipped_segments: vec![],
5838                    skipped_positions: Vec::new(),
5839                }],
5840            }],
5841            post_group_start: 0,
5842            inter_group_segments: std::collections::BTreeMap::new(),
5843        };
5844
5845        // Resolve SG10 under Z98
5846        let inst = MappingEngine::resolve_by_source_path(&tree, "sg4.sg8_z98.sg10");
5847        assert!(inst.is_some());
5848        assert_eq!(inst.unwrap().segments[0].elements[2][0], "ZB3");
5849
5850        // Resolve SG10 under ZD7
5851        let inst = MappingEngine::resolve_by_source_path(&tree, "sg4.sg8_zd7.sg10");
5852        assert!(inst.is_some());
5853        assert_eq!(inst.unwrap().segments[0].elements[2][0], "ZE6");
5854
5855        // Unknown qualifier → None
5856        let inst = MappingEngine::resolve_by_source_path(&tree, "sg4.sg8_zzz.sg10");
5857        assert!(inst.is_none());
5858
5859        // Without qualifier → first rep (Z98)
5860        let inst = MappingEngine::resolve_by_source_path(&tree, "sg4.sg8.sg10");
5861        assert!(inst.is_some());
5862        assert_eq!(inst.unwrap().segments[0].elements[2][0], "ZB3");
5863    }
5864
5865    #[test]
5866    fn test_parse_source_path_part() {
5867        assert_eq!(parse_source_path_part("sg4"), ("sg4", None));
5868        assert_eq!(parse_source_path_part("sg8_z98"), ("sg8", Some("z98")));
5869        assert_eq!(parse_source_path_part("sg10"), ("sg10", None));
5870        assert_eq!(parse_source_path_part("sg12_z04"), ("sg12", Some("z04")));
5871    }
5872
5873    #[test]
5874    fn test_has_source_path_qualifiers() {
5875        assert!(has_source_path_qualifiers("sg4.sg8_z98.sg10"));
5876        assert!(has_source_path_qualifiers("sg4.sg8_ze1.sg9"));
5877        assert!(!has_source_path_qualifiers("sg4.sg6"));
5878        assert!(!has_source_path_qualifiers("sg4.sg8.sg10"));
5879    }
5880
5881    #[test]
5882    fn test_extract_all_from_instance_collects_all_qualifier_matches() {
5883        use mig_assembly::assembler::*;
5884
5885        // Instance with 3 RFF+Z34 segments
5886        let instance = AssembledGroupInstance {
5887            segments: vec![
5888                AssembledSegment {
5889                    tag: "SEQ".to_string(),
5890                    elements: vec![vec!["ZD6".to_string()]],
5891                    mig_number: None,
5892                    segment_number: None,
5893                },
5894                AssembledSegment {
5895                    tag: "RFF".to_string(),
5896                    elements: vec![vec!["Z34".to_string(), "REF_A".to_string()]],
5897                    mig_number: None,
5898                    segment_number: None,
5899                },
5900                AssembledSegment {
5901                    tag: "RFF".to_string(),
5902                    elements: vec![vec!["Z34".to_string(), "REF_B".to_string()]],
5903                    mig_number: None,
5904                    segment_number: None,
5905                },
5906                AssembledSegment {
5907                    tag: "RFF".to_string(),
5908                    elements: vec![vec!["Z34".to_string(), "REF_C".to_string()]],
5909                    mig_number: None,
5910                    segment_number: None,
5911                },
5912                AssembledSegment {
5913                    tag: "RFF".to_string(),
5914                    elements: vec![vec!["Z35".to_string(), "OTHER".to_string()]],
5915                    mig_number: None,
5916                    segment_number: None,
5917                },
5918            ],
5919            child_groups: vec![],
5920            entry_mig_number: None,
5921            variant_mig_numbers: vec![],
5922            skipped_segments: vec![],
5923            skipped_positions: Vec::new(),
5924        };
5925
5926        // Wildcard collect: rff[Z34,*] should collect all 3 RFF+Z34 values
5927        let all = MappingEngine::extract_all_from_instance(&instance, "rff[Z34,*].0.1");
5928        assert_eq!(all, vec!["REF_A", "REF_B", "REF_C"]);
5929
5930        // Non-wildcard still returns single value via extract_from_instance
5931        let single = MappingEngine::extract_from_instance(&instance, "rff[Z34].0.1");
5932        assert_eq!(single, Some("REF_A".to_string()));
5933
5934        let second = MappingEngine::extract_from_instance(&instance, "rff[Z34,1].0.1");
5935        assert_eq!(second, Some("REF_B".to_string()));
5936    }
5937}