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