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