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