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