Skip to main content

mig_assembly/
assembler.rs

1//! Recursive descent assembler — MIG-guided segment consumption.
2//!
3//! The assembler walks the MIG tree structure and consumes matching
4//! segments from the input. It produces a generic tree representation
5//! that can be converted to typed PID structs.
6
7use crate::cursor::SegmentCursor;
8use crate::diagnostic::{StructureDiagnostic, StructureDiagnosticKind};
9use crate::matcher;
10use crate::tokenize::OwnedSegment;
11use crate::AssemblyError;
12use mig_types::schema::mig::{MigSchema, MigSegment, MigSegmentGroup};
13use serde::{Deserialize, Serialize};
14
15/// A generic assembled tree node (before PID-specific typing).
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct AssembledTree {
18    pub segments: Vec<AssembledSegment>,
19    pub groups: Vec<AssembledGroup>,
20    /// Index in `segments` where post-group segments start (e.g., UNT, UNZ).
21    /// Segments before this index appear before groups in EDIFACT order.
22    #[serde(default)]
23    pub post_group_start: usize,
24    /// Root segments consumed between groups during assembly (e.g., UNS
25    /// section separator in MSCONS). Key = index into `groups` vec; value =
26    /// segments that appear immediately before that group in the EDIFACT
27    /// stream. Empty for messages without inter-group root segments.
28    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
29    pub inter_group_segments: std::collections::BTreeMap<usize, Vec<AssembledSegment>>,
30}
31
32/// An assembled segment with its data elements.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct AssembledSegment {
35    pub tag: String,
36    /// `elements[i][j]` = component `j` of element `i`
37    pub elements: Vec<Vec<String>>,
38    /// MIG `Number` attribute identifying this segment variant.
39    /// Two segments with the same tag (e.g., DTM) but different roles
40    /// (DTM+92 vs DTM+93) have distinct MIG numbers.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub mig_number: Option<String>,
43}
44
45/// An assembled segment group (may repeat).
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct AssembledGroup {
48    pub group_id: String,
49    pub repetitions: Vec<AssembledGroupInstance>,
50}
51
52/// One repetition of a segment group.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct AssembledGroupInstance {
55    pub segments: Vec<AssembledSegment>,
56    pub child_groups: Vec<AssembledGroup>,
57    /// MIG `Number` of the entry segment that identified this group instance's variant.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub entry_mig_number: Option<String>,
60    /// All MIG `Number`s defined for this group variant — includes segments that
61    /// may be absent in the EDIFACT but are defined in the MIG for this variant.
62    ///
63    /// Used by the validator to determine which AHB rules belong to this instance:
64    /// a rule with `mig_number` in this set applies here, even if the segment is
65    /// missing (which is then a missing-field error). Without this, rules for
66    /// absent-but-required segments would be incorrectly filtered out.
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub variant_mig_numbers: Vec<String>,
69    /// Segments that were present in the EDIFACT input but not defined in
70    /// the PID-filtered MIG for this group. Only populated when the assembler
71    /// runs with [`AssemblerConfig::skip_unknown_segments`] enabled.
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub skipped_segments: Vec<AssembledSegment>,
74    /// Input positions of each skipped segment, parallel to `skipped_segments`.
75    /// Used by `assemble_with_diagnostics` to locate AHB-foreign content in
76    /// the original EDIFACT stream for STR008 diagnostics.
77    #[serde(default, skip_serializing_if = "Vec::is_empty")]
78    pub skipped_positions: Vec<usize>,
79}
80
81impl AssembledGroupInstance {
82    /// Create a virtual `AssembledTree` scoped to this group instance.
83    ///
84    /// The instance's own segments become the tree's root segments,
85    /// and its child groups become the tree's groups. This enables
86    /// running `MappingEngine::map_all_forward()` on a single
87    /// transaction group as if it were a complete message.
88    pub fn as_assembled_tree(&self) -> AssembledTree {
89        AssembledTree {
90            segments: self.segments.clone(),
91            groups: self.child_groups.clone(),
92            post_group_start: self.segments.len(),
93            inter_group_segments: std::collections::BTreeMap::new(),
94        }
95    }
96}
97
98/// Configuration for the assembler.
99#[derive(Debug, Clone, Default)]
100pub struct AssemblerConfig {
101    /// When `true`, the assembler skips segments inside a group instance that
102    /// don't match any remaining MIG slot, nested-group entry, or the group's
103    /// entry tag (next repetition). Skipped segments are preserved on
104    /// [`AssembledGroupInstance::skipped_segments`] for roundtrip re-emission.
105    ///
106    /// Default: `false` (strict AHB — unknown segments stall the cursor).
107    pub skip_unknown_segments: bool,
108
109    /// Qualifier-aware assembly: maps MIG `Number` to `(element_index, component_index, expected_value)`.
110    ///
111    /// When a bounded slot has a `number` with an entry in this map,
112    /// `try_consume_segment` checks the input segment's value at the
113    /// specified position. If it doesn't match, the slot is skipped (segment
114    /// is for a different qualifier variant).
115    ///
116    /// Build from the PID schema JSON, or construct manually:
117    /// `{ "00023" => (0, 0, "92".to_string()), "00024" => (0, 0, "93".to_string()) }`.
118    ///
119    /// Default: empty (positional assembly, no qualifier checking).
120    pub qualifier_map: std::collections::HashMap<String, (usize, usize, String)>,
121
122    /// Reject a slot when the input segment has a value outside the slot's
123    /// allowed codes at any code-bearing position (not just the primary
124    /// qualifier). Disambiguates PID-filtered slots that share a primary
125    /// qualifier but differ on a secondary code (e.g. PID 55035 SG8/PIA
126    /// variants all use 4347='5' but differ at C212/7143).
127    ///
128    /// Default: `false`. Only safe to enable on a PID-filtered MIG whose
129    /// per-slot codes reflect the AHB-narrowed allowed sets — on the raw
130    /// MIG, each slot's codes cover only one variant and strict matching
131    /// would leave most segments unconsumed.
132    pub strict_code_matching: bool,
133}
134
135/// MIG-guided assembler.
136///
137/// Takes a MIG schema and uses it as a grammar to guide consumption
138/// of parsed EDIFACT segments. Produces a generic `AssembledTree`.
139pub struct Assembler<'a> {
140    mig: &'a MigSchema,
141    config: AssemblerConfig,
142}
143
144impl<'a> Assembler<'a> {
145    pub fn new(mig: &'a MigSchema) -> Self {
146        Self {
147            mig,
148            config: AssemblerConfig::default(),
149        }
150    }
151
152    pub fn with_config(mig: &'a MigSchema, config: AssemblerConfig) -> Self {
153        Self { mig, config }
154    }
155
156    /// Tags reachable at the top level from a group's point of view — used
157    /// as the initial enclosing scope for the skip-unknown retry logic.
158    ///
159    /// Excludes top-level segment slots that have already been matched
160    /// (they won't be consumed again) and top-level group entries that
161    /// come before the current group (those groups have already run).
162    /// What's left is: unmatched top-level segments (e.g., UNT/UNZ still
163    /// to come) and top-level group entries of siblings the assembler
164    /// hasn't reached yet. A group's retry loop should break on those
165    /// tags (structural for an upcoming step) but may skip anything else
166    /// that slipped past the earlier passes.
167    ///
168    /// Empty when skip mode is off (the skip path is never taken).
169    fn top_level_enclosing_for_group(
170        &self,
171        current_group_idx: usize,
172        matched_seg_indices: &[usize],
173    ) -> std::collections::HashSet<String> {
174        if !self.config.skip_unknown_segments {
175            return std::collections::HashSet::new();
176        }
177        let mut tags: std::collections::HashSet<String> = self
178            .mig
179            .segments
180            .iter()
181            .enumerate()
182            .filter_map(|(i, s)| {
183                if matched_seg_indices.contains(&i) {
184                    None
185                } else {
186                    Some(s.id.clone())
187                }
188            })
189            .collect();
190        for (idx, group) in self.mig.segment_groups.iter().enumerate() {
191            if idx < current_group_idx {
192                continue;
193            }
194            if let Some(entry) = group.segments.first() {
195                tags.insert(entry.id.clone());
196            }
197        }
198        tags
199    }
200
201    /// Assemble segments into a generic tree following MIG structure.
202    pub fn assemble_generic(
203        &self,
204        segments: &[OwnedSegment],
205    ) -> Result<AssembledTree, AssemblyError> {
206        let mut cursor = SegmentCursor::new(segments.len());
207        let mut tree = AssembledTree {
208            segments: Vec::new(),
209            groups: Vec::new(),
210            post_group_start: 0,
211            inter_group_segments: std::collections::BTreeMap::new(),
212        };
213
214        // Track which MIG segment indices were matched in the first pass
215        let mut matched_seg_indices = Vec::new();
216
217        // Process top-level segments (first pass — before groups)
218        for (i, mig_seg) in self.mig.segments.iter().enumerate() {
219            if cursor.is_exhausted() {
220                break;
221            }
222            if let Some(assembled) = self.try_consume_segment(segments, &mut cursor, mig_seg)? {
223                tree.segments.push(assembled);
224                matched_seg_indices.push(i);
225            }
226        }
227
228        // Process segment groups, interleaving root segment consumption.
229        // Some message types (e.g., MSCONS) have root segments like UNS
230        // between groups (SG2 and SG5). Before trying each group, consume
231        // any unmatched root segments at the current cursor position.
232        //
233        // When consecutive same-ID groups have variant_code set (e.g., 3 SG8
234        // entries for ZD7, Z98, ZF3), the assembler tries ALL variants at each
235        // cursor position to handle interleaved reps.
236        let mut group_idx = 0;
237        while group_idx < self.mig.segment_groups.len() {
238            if cursor.is_exhausted() {
239                break;
240            }
241
242            let mig_group = &self.mig.segment_groups[group_idx];
243
244            // Top-level skip-unknown: in skip mode, advance the cursor past
245            // any input segment whose tag is neither an unmatched root MIG
246            // segment nor any group entry. Without this, an AHB-foreign
247            // top-level segment (e.g. IMD in QUOTES PID 15005) stalls the
248            // assembler before the first group entry is reached.
249            //
250            // Skipped segments are stashed in `inter_group_segments[N]` so
251            // disassembly re-emits them at the same logical position.
252            if self.config.skip_unknown_segments {
253                let tree_group_idx = tree.groups.len();
254                while !cursor.is_exhausted() {
255                    let seg = &segments[cursor.position()];
256                    let tag = &seg.id;
257                    let is_unmatched_root_seg = self
258                        .mig
259                        .segments
260                        .iter()
261                        .enumerate()
262                        .any(|(i, ms)| !matched_seg_indices.contains(&i) && ms.id == *tag);
263                    let is_any_group_entry = self
264                        .mig
265                        .segment_groups
266                        .iter()
267                        .any(|g| g.segments.first().is_some_and(|s| s.id == *tag));
268                    if is_unmatched_root_seg || is_any_group_entry {
269                        break;
270                    }
271                    tree.inter_group_segments
272                        .entry(tree_group_idx)
273                        .or_default()
274                        .push(owned_to_assembled(seg));
275                    cursor.advance();
276                }
277                if cursor.is_exhausted() {
278                    break;
279                }
280            }
281
282            // Try consuming unmatched root segments before this group
283            let tree_group_idx = tree.groups.len();
284            for (i, mig_seg) in self.mig.segments.iter().enumerate() {
285                if cursor.is_exhausted() {
286                    break;
287                }
288                if matched_seg_indices.contains(&i) {
289                    continue;
290                }
291                if let Some(assembled) = self.try_consume_segment(segments, &mut cursor, mig_seg)? {
292                    tree.inter_group_segments
293                        .entry(tree_group_idx)
294                        .or_default()
295                        .push(assembled);
296                    matched_seg_indices.push(i);
297                }
298            }
299
300            // Scope visible to this group's skip-retry logic: top-level
301            // segments still to be consumed, plus entries of sibling
302            // top-level groups that haven't run yet. Built per-group so
303            // already-matched DTM/BGM slots don't incorrectly protect an
304            // AHB-foreign DTM+92 inside SG4 from being skipped.
305            let top_enclosing =
306                self.top_level_enclosing_for_group(group_idx, &matched_seg_indices);
307
308            // Check if this starts a variant set (consecutive same-ID groups with variant_code)
309            if mig_group.variant_code.is_some() {
310                let variant_count = self.mig.segment_groups[group_idx..]
311                    .iter()
312                    .take_while(|g| g.id == mig_group.id && g.variant_code.is_some())
313                    .count();
314                let variant_end = group_idx + variant_count;
315
316                let variant_groups = &self.mig.segment_groups[group_idx..variant_end];
317                if let Some(combined) = self.try_consume_variant_groups(
318                    segments,
319                    &mut cursor,
320                    variant_groups,
321                    &top_enclosing,
322                )? {
323                    tree.groups.push(combined);
324                }
325                group_idx = variant_end;
326            } else {
327                if let Some(assembled) = self.try_consume_group(
328                    segments,
329                    &mut cursor,
330                    mig_group,
331                    &top_enclosing,
332                )? {
333                    tree.groups.push(assembled);
334                }
335                group_idx += 1;
336            }
337        }
338
339        // Mark where post-group segments start
340        tree.post_group_start = tree.segments.len();
341
342        // Second pass: try unmatched top-level segments (e.g., UNT, UNZ after groups)
343        for (i, mig_seg) in self.mig.segments.iter().enumerate() {
344            if cursor.is_exhausted() {
345                break;
346            }
347            if matched_seg_indices.contains(&i) {
348                continue;
349            }
350            if let Some(assembled) = self.try_consume_segment(segments, &mut cursor, mig_seg)? {
351                tree.segments.push(assembled);
352            }
353        }
354
355        Ok(tree)
356    }
357
358    fn try_consume_segment(
359        &self,
360        segments: &[OwnedSegment],
361        cursor: &mut SegmentCursor,
362        mig_seg: &MigSegment,
363    ) -> Result<Option<AssembledSegment>, AssemblyError> {
364        if cursor.is_exhausted() {
365            return Ok(None);
366        }
367        let seg = &segments[cursor.position()];
368        if matcher::matches_segment_tag(&seg.id, &mig_seg.id) {
369            // Qualifier check: if the MIG slot has a qualifier_map entry,
370            // verify the input segment's qualifier matches before consuming.
371            if let Some(ref num) = mig_seg.number {
372                if let Some((el_idx, comp_idx, expected)) = self.config.qualifier_map.get(num) {
373                    let actual = seg
374                        .elements
375                        .get(*el_idx)
376                        .and_then(|e| e.get(*comp_idx))
377                        .map(|s| s.as_str())
378                        .unwrap_or("");
379                    if actual != expected {
380                        return Ok(None); // Wrong qualifier — skip this slot
381                    }
382                }
383            }
384            // Note: full-code-profile matching (for disambiguating merged
385            // sibling slots sharing a primary qualifier, e.g. PID 55035 PIA
386            // variants all use 4347='5' but differ at C212/7143) is handled
387            // by the caller in `try_consume_group`'s entry-run when strict
388            // mode is on and `run_len > 1`. Doing it here would reject
389            // solo-slot segments whose codes fall outside the AHB-narrowed
390            // allowed set — those belong to the validator as COD002.
391            let mut assembled = owned_to_assembled(seg);
392            assembled.mig_number = mig_seg.number.clone();
393            cursor.advance();
394            Ok(Some(assembled))
395        } else {
396            Ok(None) // Segment not present (optional)
397        }
398    }
399
400    /// Consume the entry run of a group with best-match slot selection.
401    ///
402    /// Used when `strict_code_matching` is on and `run_len > 1` — the group
403    /// has multiple sibling entry slots with the same tag (merged PID-specific
404    /// variants). For each pending segment, picks the unused slot whose full
405    /// code profile matches best. Ties broken by MIG order. When no slot's
406    /// profile matches, falls back to the first unused tag+qualifier-matching
407    /// slot so the segment is still consumed (the validator emits COD002 if
408    /// the code is truly invalid).
409    fn consume_entry_run_best_match(
410        &self,
411        segments: &[OwnedSegment],
412        cursor: &mut SegmentCursor,
413        entry_slots: &[MigSegment],
414        instance: &mut AssembledGroupInstance,
415    ) -> Result<(), AssemblyError> {
416        let mut used = vec![false; entry_slots.len()];
417        for _ in 0..entry_slots.len() {
418            if cursor.is_exhausted() {
419                break;
420            }
421            let seg = &segments[cursor.position()];
422            let mut strict_match: Option<usize> = None;
423            let mut tag_match: Option<usize> = None;
424            for (i, slot) in entry_slots.iter().enumerate() {
425                if used[i] {
426                    continue;
427                }
428                if !matcher::matches_segment_tag(&seg.id, &slot.id) {
429                    continue;
430                }
431                if !self.segment_passes_qualifier_map(seg, slot) {
432                    continue;
433                }
434                if tag_match.is_none() {
435                    tag_match = Some(i);
436                }
437                if strict_match.is_none() && segment_matches_mig_codes(seg, slot) {
438                    strict_match = Some(i);
439                }
440            }
441            let Some(i) = strict_match.or(tag_match) else {
442                break;
443            };
444            used[i] = true;
445            let slot = &entry_slots[i];
446            let mut assembled = owned_to_assembled(seg);
447            assembled.mig_number = slot.number.clone();
448            instance.segments.push(assembled);
449            cursor.advance();
450        }
451        Ok(())
452    }
453
454    fn segment_passes_qualifier_map(&self, seg: &OwnedSegment, mig_seg: &MigSegment) -> bool {
455        let Some(ref num) = mig_seg.number else {
456            return true;
457        };
458        let Some((el_idx, comp_idx, expected)) = self.config.qualifier_map.get(num) else {
459            return true;
460        };
461        let actual = seg
462            .elements
463            .get(*el_idx)
464            .and_then(|e| e.get(*comp_idx))
465            .map(|s| s.as_str())
466            .unwrap_or("");
467        actual == expected
468    }
469
470    fn try_consume_group(
471        &self,
472        segments: &[OwnedSegment],
473        cursor: &mut SegmentCursor,
474        mig_group: &MigSegmentGroup,
475        enclosing: &std::collections::HashSet<String>,
476    ) -> Result<Option<AssembledGroup>, AssemblyError> {
477        let mut repetitions = Vec::new();
478        let entry_segment = mig_group.segments.first().ok_or_else(|| {
479            AssemblyError::ParseError(format!("Group {} has no segments", mig_group.id))
480        })?;
481
482        // Scope visible to skip decisions inside this group: our own local
483        // scope (entry + slots + direct nested entries) unioned with the
484        // caller's enclosing scope. Only built when skip mode is on —
485        // otherwise the skip path is dead code and the set stays unused.
486        let nested_enclosing: std::collections::HashSet<String> =
487            if self.config.skip_unknown_segments {
488                let mut set = enclosing.clone();
489                set.extend(group_local_scope(mig_group));
490                set
491            } else {
492                std::collections::HashSet::new()
493            };
494
495        // Loop for repeating groups
496        while !cursor.is_exhausted() {
497            let iter_start = cursor.position();
498            let seg = &segments[cursor.position()];
499            if !matcher::matches_segment_tag(&seg.id, &entry_segment.id) {
500                break; // Current segment doesn't match group entry — stop repeating
501            }
502
503            // Check variant qualifier if set — tag matches but wrong variant
504            if !mig_group.variant_codes.is_empty() {
505                let (ei, ci) = mig_group.variant_qualifier_position.unwrap_or((0, 0));
506                let actual_qual = seg
507                    .elements
508                    .get(ei)
509                    .and_then(|e| e.get(ci))
510                    .map(|s| s.as_str())
511                    .unwrap_or("");
512                if !mig_group
513                    .variant_codes
514                    .iter()
515                    .any(|c| actual_qual.eq_ignore_ascii_case(c))
516                {
517                    break;
518                }
519            } else if let Some(ref expected_code) = mig_group.variant_code {
520                let (ei, ci) = mig_group.variant_qualifier_position.unwrap_or((0, 0));
521                let actual_qual = seg
522                    .elements
523                    .get(ei)
524                    .and_then(|e| e.get(ci))
525                    .map(|s| s.as_str())
526                    .unwrap_or("");
527                if !actual_qual.eq_ignore_ascii_case(expected_code) {
528                    break;
529                }
530            }
531
532            let mut instance = AssembledGroupInstance {
533                segments: Vec::new(),
534                child_groups: Vec::new(),
535                entry_mig_number: entry_segment.number.clone(),
536                variant_mig_numbers: collect_mig_numbers(mig_group),
537                skipped_segments: Vec::new(),
538                skipped_positions: Vec::new(),
539            };
540
541            // Consume segments within this group instance.
542            // Process MIG slots in tag runs: for consecutive slots with the
543            // same tag, consume ALL matching input segments — not just the
544            // defined count. This handles real-world fixtures with more
545            // repetitions than the merged MIG predicts (e.g., 6 RFFs when
546            // the schema defines max 4).
547            //
548            // The entry segment (first tag run) is consumed bounded — one per
549            // defined slot — because the outer while loop uses the entry tag
550            // to delineate group repetitions.
551            let mut slot_idx = 0;
552            let mut is_entry_run = true;
553            while slot_idx < mig_group.segments.len() {
554                if cursor.is_exhausted() {
555                    break;
556                }
557                let current_tag = &mig_group.segments[slot_idx].id;
558                let run_len = mig_group.segments[slot_idx..]
559                    .iter()
560                    .take_while(|s| s.id == *current_tag)
561                    .count();
562
563                if is_entry_run {
564                    // Entry tag: consume at most run_len (preserves group boundaries)
565                    let entry_slots = &mig_group.segments[slot_idx..slot_idx + run_len];
566                    if self.config.strict_code_matching && run_len > 1 {
567                        // Best-match: among tag-matching sibling slots, prefer
568                        // the one whose full code profile matches the segment
569                        // (disambiguates PID 55035 PIA 00108/Z12 vs 00197/SRW).
570                        // Falls back to MIG order when no profile matches —
571                        // preserves assembly for codes outside any AHB-narrowed set.
572                        self.consume_entry_run_best_match(
573                            segments,
574                            cursor,
575                            entry_slots,
576                            &mut instance,
577                        )?;
578                    } else {
579                        for slot in entry_slots {
580                            if cursor.is_exhausted() {
581                                break;
582                            }
583                            if let Some(assembled) =
584                                self.try_consume_segment(segments, cursor, slot)?
585                            {
586                                instance.segments.push(assembled);
587                            }
588                        }
589                    }
590                    is_entry_run = false;
591                } else if matcher::matches_segment_tag(current_tag, &entry_segment.id) {
592                    // Non-entry slot with SAME tag as entry (e.g., CCI appears as
593                    // both entry and non-entry in merged SG30).
594                    //
595                    // Only consume if we haven't yet consumed any NON-entry-tag
596                    // segments (i.e., we're still in a consecutive entry-tag run).
597                    // Once we've consumed a different tag (like CAV), seeing the
598                    // entry tag again means a new rep boundary.
599                    //
600                    // z35: entry CCI → CAV CAV → sees CCI → has_other=true → break ✓
601                    // z39: entry CCI → (no CAV) → sees CCI → has_other=false → consume ✓
602                    //      then CCI CCI → CAV → sees CCI → has_other=true → break
603                    //      BUT: z39 needs CCI-CAV-CCI-CAV structure
604                    //
605                    // Better heuristic: check if ALL remaining slots from here are
606                    // entry-tag + non-entry pairs. If the current slot is entry-tag
607                    // and the NEXT input segment after it would be a non-entry tag,
608                    // consume — it's a continuation. Otherwise break.
609                    if cursor.is_exhausted() {
610                        break;
611                    }
612                    let seg = &segments[cursor.position()];
613                    if !matcher::matches_segment_tag(&seg.id, current_tag) {
614                        break;
615                    }
616                    // Check: is there a non-entry segment AFTER this entry-tag?
617                    // If so, this CCI+CAV pair is part of the current rep.
618                    let has_following_non_entry = if cursor.position() + 1 < segments.len() {
619                        let next = &segments[cursor.position() + 1];
620                        !matcher::matches_segment_tag(&next.id, &entry_segment.id)
621                            && mig_group.segments.iter().any(|s| {
622                                matcher::matches_segment_tag(&next.id, &s.id)
623                                    && !matcher::matches_segment_tag(&s.id, &entry_segment.id)
624                            })
625                    } else {
626                        false
627                    };
628                    if has_following_non_entry {
629                        // CCI followed by CAV → consume as continuation pair
630                        instance.segments.push(owned_to_assembled(seg));
631                        cursor.advance();
632                    } else {
633                        // CCI followed by CCI or unknown → let outer loop decide
634                        break;
635                    }
636                } else {
637                    // Non-entry tag: consume bounded slots first (with mig_number),
638                    // then greedily consume extras (without mig_number).
639                    // The bounded slots get mig_number from the MIG definition so
640                    // the validator can distinguish same-tag segments (e.g., DTM+92
641                    // vs DTM+93 both in SG4).
642                    let slots = &mig_group.segments[slot_idx..slot_idx + run_len];
643                    if self.config.strict_code_matching && run_len > 1 {
644                        self.consume_entry_run_best_match(segments, cursor, slots, &mut instance)?;
645                    } else {
646                        for slot in slots {
647                            if cursor.is_exhausted() {
648                                break;
649                            }
650                            if let Some(assembled) =
651                                self.try_consume_segment(segments, cursor, slot)?
652                            {
653                                instance.segments.push(assembled);
654                            }
655                        }
656                    }
657                    // Greedily consume any remaining same-tag segments beyond the MIG count
658                    while !cursor.is_exhausted() {
659                        let seg = &segments[cursor.position()];
660                        if matcher::matches_segment_tag(&seg.id, current_tag) {
661                            instance.segments.push(owned_to_assembled(seg));
662                            cursor.advance();
663                        } else {
664                            break;
665                        }
666                    }
667                }
668
669                slot_idx += run_len;
670
671                // Point A: Skip unknown segments between MIG slot runs.
672                // When skip mode is ON and we just finished a slot run but the
673                // current segment doesn't match any remaining MIG slot, nested
674                // group entry, or the entry tag, skip it — unless the tag
675                // appears elsewhere in the full MIG (it belongs to an
676                // enclosing group, not this one).
677                if self.config.skip_unknown_segments {
678                    while !cursor.is_exhausted() {
679                        let seg = &segments[cursor.position()];
680                        // Stop if it matches the entry tag (next group repetition)
681                        if matcher::matches_segment_tag(&seg.id, &entry_segment.id) {
682                            break;
683                        }
684                        // Stop if it matches any remaining MIG slot
685                        if mig_group.segments[slot_idx..]
686                            .iter()
687                            .any(|s| matcher::matches_segment_tag(&seg.id, &s.id))
688                        {
689                            break;
690                        }
691                        // Stop if it matches any nested group entry
692                        if mig_group.nested_groups.iter().any(|ng| {
693                            ng.segments
694                                .first()
695                                .is_some_and(|es| matcher::matches_segment_tag(&seg.id, &es.id))
696                        }) {
697                            break;
698                        }
699                        // Stop if the tag is reachable from the enclosing
700                        // scope — it's structural for an outer group (e.g.,
701                        // an SG8 SEQ seen from inside SG10) and should
702                        // surface to the outer loop rather than be
703                        // swallowed here.
704                        if enclosing.contains(&seg.id) {
705                            break;
706                        }
707                        // Unknown segment — skip it
708                        instance.skipped_positions.push(cursor.position());
709                        instance.skipped_segments.push(owned_to_assembled(seg));
710                        cursor.advance();
711                    }
712                }
713            }
714
715            // Consume nested groups (variant-aware for same-ID groups).
716            //
717            // When `skip_unknown_segments` is on, the loop retries after
718            // stalling on an AHB-foreign segment that matches none of the
719            // nested-group entries — the orphan is recorded in
720            // `instance.skipped_segments` and subsequent legitimate reps are
721            // still assembled (PID 55035 cascade fix). Retries merge new
722            // child groups into the existing entry for the same id so a
723            // variant set assembled across two passes stays a single child.
724            loop {
725                let pass_start = cursor.position();
726                let mut nested_idx = 0;
727                while nested_idx < mig_group.nested_groups.len() {
728                    if cursor.is_exhausted() {
729                        break;
730                    }
731                    let nested = &mig_group.nested_groups[nested_idx];
732
733                    if nested.variant_code.is_some() {
734                        // Variant set: collect consecutive same-ID groups with variant_code
735                        let variant_count = mig_group.nested_groups[nested_idx..]
736                            .iter()
737                            .take_while(|g| g.id == nested.id && g.variant_code.is_some())
738                            .count();
739                        let variant_end = nested_idx + variant_count;
740                        let variant_groups = &mig_group.nested_groups[nested_idx..variant_end];
741                        if let Some(combined) = self.try_consume_variant_groups(
742                            segments,
743                            cursor,
744                            variant_groups,
745                            &nested_enclosing,
746                        )? {
747                            push_or_merge_child(&mut instance.child_groups, combined);
748                        }
749                        nested_idx = variant_end;
750                    } else {
751                        if let Some(assembled) =
752                            self.try_consume_group(segments, cursor, nested, &nested_enclosing)?
753                        {
754                            push_or_merge_child(&mut instance.child_groups, assembled);
755                        }
756                        nested_idx += 1;
757                    }
758                }
759
760                if !self.config.skip_unknown_segments || cursor.is_exhausted() {
761                    break;
762                }
763                let seg = &segments[cursor.position()];
764                // A segment is a true orphan from this group's perspective
765                // if its tag is not reachable from the enclosing scope
766                // (siblings above, upcoming top-level segments). Tags in
767                // this group's own local scope (e.g., RFF as an entry of
768                // a sibling nested SG6) get skipped here: we've already
769                // tried every nested group in the pass above and none
770                // consumed the segment, so a sibling qualifier-mismatch
771                // at this position will reject every subsequent pass
772                // too. Skipping moves the cursor past the mismatched
773                // content so legitimate reps that follow can still
774                // assemble. Progress is guaranteed: either a pass
775                // consumed something (cursor advanced) or we skip one
776                // segment per iteration until we hit an enclosing-scoped
777                // tag or exhaust the input.
778                if enclosing.contains(&seg.id) {
779                    break;
780                }
781                // Defensive: if the nested pass made no progress AND
782                // nothing to skip (impossible given the check above but
783                // kept so this loop always terminates), break.
784                if cursor.position() == pass_start && !self.config.skip_unknown_segments {
785                    break;
786                }
787
788                instance.skipped_positions.push(cursor.position());
789                instance.skipped_segments.push(owned_to_assembled(seg));
790                cursor.advance();
791            }
792
793            // Guard against infinite loops: if no progress was made this iteration
794            // (entry tag matched but the entry segment was rejected by e.g. a
795            // qualifier_map mismatch), stop. Pushing an empty rep per iteration
796            // would allocate unbounded memory (see collect_mig_numbers call in
797            // the instance constructor).
798            if cursor.position() == iter_start {
799                break;
800            }
801            repetitions.push(instance);
802        }
803
804        if repetitions.is_empty() {
805            Ok(None)
806        } else {
807            Ok(Some(AssembledGroup {
808                group_id: mig_group.id.clone(),
809                repetitions,
810            }))
811        }
812    }
813
814    /// Consume interleaved repetitions of variant groups.
815    ///
816    /// At each cursor position, tries all variant definitions to find which one
817    /// matches the entry segment's qualifier. Collects all reps into one
818    /// `AssembledGroup` with the shared group_id.
819    fn try_consume_variant_groups(
820        &self,
821        segments: &[OwnedSegment],
822        cursor: &mut SegmentCursor,
823        variants: &[MigSegmentGroup],
824        enclosing: &std::collections::HashSet<String>,
825    ) -> Result<Option<AssembledGroup>, AssemblyError> {
826        let group_id = variants[0].id.clone();
827        let entry_tag = variants[0]
828            .segments
829            .first()
830            .map(|s| s.id.as_str())
831            .unwrap_or("");
832        let mut all_reps = Vec::new();
833
834        while !cursor.is_exhausted() {
835            let seg = &segments[cursor.position()];
836            if !matcher::matches_segment_tag(&seg.id, entry_tag) {
837                break;
838            }
839
840            // Find which variant matches this segment's qualifier.
841            // Each variant may have its qualifier at a different element position
842            // (e.g., CCI+Z19 has qualifier at [0][0], but CCI+++Z15 at [2][0]).
843            let matched = variants.iter().find(|v| {
844                let (ei, ci) = v.variant_qualifier_position.unwrap_or((0, 0));
845                let actual_qual = seg
846                    .elements
847                    .get(ei)
848                    .and_then(|e| e.get(ci))
849                    .map(|s| s.as_str())
850                    .unwrap_or("");
851                if !v.variant_codes.is_empty() {
852                    v.variant_codes
853                        .iter()
854                        .any(|c| actual_qual.eq_ignore_ascii_case(c))
855                } else if let Some(ref expected_code) = v.variant_code {
856                    actual_qual.eq_ignore_ascii_case(expected_code)
857                } else {
858                    false
859                }
860            });
861
862            if let Some(variant) = matched {
863                if let Some(group) =
864                    self.try_consume_group(segments, cursor, variant, enclosing)?
865                {
866                    all_reps.extend(group.repetitions);
867                } else {
868                    break;
869                }
870            } else {
871                // No variant matches — try consuming with the first variant as
872                // fallback to avoid getting stuck. This handles edge cases where
873                // the qualifier doesn't exactly match any variant code.
874                if let Some(group) =
875                    self.try_consume_group(segments, cursor, &variants[0], enclosing)?
876                {
877                    all_reps.extend(group.repetitions);
878                } else {
879                    break;
880                }
881            }
882        }
883
884        if all_reps.is_empty() {
885            Ok(None)
886        } else {
887            Ok(Some(AssembledGroup {
888                group_id,
889                repetitions: all_reps,
890            }))
891        }
892    }
893
894    /// Assemble segments with diagnostic collection.
895    ///
896    /// Returns the assembled tree plus diagnostics for segments not consumed
897    /// by the MIG-guided assembly. Existing `assemble_generic()` is unchanged.
898    pub fn assemble_with_diagnostics(
899        &self,
900        segments: &[OwnedSegment],
901    ) -> (AssembledTree, Vec<StructureDiagnostic>) {
902        let mut diagnostics = Vec::new();
903
904        let tree = match self.assemble_generic(segments) {
905            Ok(tree) => tree,
906            Err(e) => {
907                diagnostics.push(StructureDiagnostic {
908                    kind: StructureDiagnosticKind::UnexpectedSegment,
909                    segment_id: String::new(),
910                    position: 0,
911                    message: format!("Assembly failed: {e}"),
912                });
913                return (
914                    AssembledTree {
915                        segments: Vec::new(),
916                        groups: Vec::new(),
917                        post_group_start: 0,
918                        inter_group_segments: std::collections::BTreeMap::new(),
919                    },
920                    diagnostics,
921                );
922            }
923        };
924
925        // Count consumed segments in the assembled tree (skipped segments
926        // are included in this count because they were advanced past and
927        // stored — they surface as their own diagnostics below).
928        let consumed = count_tree_segments(&tree);
929
930        // Segments beyond consumed count are unconsumed tail (cascade case
931        // when skip mode is off, or structure genuinely ran out of MIG).
932        for (i, seg) in segments.iter().enumerate().skip(consumed) {
933            diagnostics.push(StructureDiagnostic {
934                kind: StructureDiagnosticKind::UnexpectedSegment,
935                segment_id: seg.id.clone(),
936                position: i,
937                message: format!(
938                    "Segment '{}' at position {} was not consumed by MIG-guided assembly",
939                    seg.id, i
940                ),
941            });
942        }
943
944        // Walk the tree and emit a diagnostic for each AHB-foreign segment
945        // the assembler advanced past (only populated when skip mode is on).
946        // Emit in input-position order so the report reads top-to-bottom.
947        let mut skipped: Vec<(usize, String)> = Vec::new();
948        collect_skipped(&tree, &mut skipped);
949        skipped.sort_by_key(|(pos, _)| *pos);
950        for (pos, tag) in skipped {
951            diagnostics.push(StructureDiagnostic {
952                kind: StructureDiagnosticKind::SkippedUnknownSegment,
953                segment_id: tag.clone(),
954                position: pos,
955                message: format!(
956                    "Segment '{tag}' at position {pos} is not defined in the PID-filtered MIG; the assembler advanced past it",
957                ),
958            });
959        }
960
961        (tree, diagnostics)
962    }
963}
964
965fn collect_skipped(tree: &AssembledTree, out: &mut Vec<(usize, String)>) {
966    for group in &tree.groups {
967        collect_skipped_from_group(group, out);
968    }
969}
970
971fn collect_skipped_from_group(group: &AssembledGroup, out: &mut Vec<(usize, String)>) {
972    for rep in &group.repetitions {
973        for (i, seg) in rep.skipped_segments.iter().enumerate() {
974            let pos = rep.skipped_positions.get(i).copied().unwrap_or(0);
975            out.push((pos, seg.tag.clone()));
976        }
977        for child in &rep.child_groups {
978            collect_skipped_from_group(child, out);
979        }
980    }
981}
982
983fn count_tree_segments(tree: &AssembledTree) -> usize {
984    let mut count = tree.segments.len();
985    for group in &tree.groups {
986        count += count_group_segments(group);
987    }
988    // Count inter-group segments (e.g., UNS+D between groups)
989    for segs in tree.inter_group_segments.values() {
990        count += segs.len();
991    }
992    count
993}
994
995fn count_group_segments(group: &AssembledGroup) -> usize {
996    let mut count = 0;
997    for rep in &group.repetitions {
998        count += rep.segments.len();
999        count += rep.skipped_segments.len();
1000        for child in &rep.child_groups {
1001            count += count_group_segments(child);
1002        }
1003    }
1004    count
1005}
1006
1007/// Collect tags reachable from the given group's local scope — its entry
1008/// segment, its own slot tags, and each direct nested group's entry tag.
1009/// Used by the skip-unknown retry logic to decide whether a stalled
1010/// segment belongs to the current group's responsibility (break, let
1011/// something structural consume it) or is a true orphan (skip).
1012fn group_local_scope(mig_group: &MigSegmentGroup) -> std::collections::HashSet<String> {
1013    let mut tags = std::collections::HashSet::new();
1014    for seg in &mig_group.segments {
1015        tags.insert(seg.id.clone());
1016    }
1017    for nested in &mig_group.nested_groups {
1018        if let Some(entry) = nested.segments.first() {
1019            tags.insert(entry.id.clone());
1020        }
1021    }
1022    tags
1023}
1024
1025/// Push a newly-assembled child group into an instance's `child_groups`,
1026/// merging its repetitions into any existing same-id entry. Used by the
1027/// skip-unknown retry loop in `try_consume_group` so a variant set whose
1028/// reps are split by an orphan segment still surfaces as one child group.
1029fn push_or_merge_child(child_groups: &mut Vec<AssembledGroup>, new: AssembledGroup) {
1030    if let Some(existing) = child_groups.iter_mut().find(|g| g.group_id == new.group_id) {
1031        existing.repetitions.extend(new.repetitions);
1032    } else {
1033        child_groups.push(new);
1034    }
1035}
1036
1037/// Collect all MIG `Number`s from a segment group definition, recursively.
1038///
1039/// This includes numbers from direct segments and from nested groups.
1040/// Used to populate `AssembledGroupInstance::variant_mig_numbers`.
1041fn collect_mig_numbers(group: &MigSegmentGroup) -> Vec<String> {
1042    let mut numbers = Vec::new();
1043    for seg in &group.segments {
1044        if let Some(ref num) = seg.number {
1045            numbers.push(num.clone());
1046        }
1047    }
1048    for nested in &group.nested_groups {
1049        numbers.extend(collect_mig_numbers(nested));
1050    }
1051    numbers
1052}
1053
1054pub fn owned_to_assembled(seg: &OwnedSegment) -> AssembledSegment {
1055    AssembledSegment {
1056        tag: seg.id.clone(),
1057        elements: seg.elements.clone(),
1058        mig_number: None,
1059    }
1060}
1061
1062/// Check every code-bearing position declared on a MIG segment against the
1063/// corresponding value on the input segment.
1064///
1065/// Used by `try_consume_segment` to disambiguate slots that share the same
1066/// primary qualifier but differ on a secondary code. Returns `true` when the
1067/// input segment's values at each declared position are either empty
1068/// (optional) or in the slot's allowed set.
1069fn segment_matches_mig_codes(seg: &OwnedSegment, mig_seg: &MigSegment) -> bool {
1070    let actual_at = |el: usize, c: usize| -> &str {
1071        seg.elements
1072            .get(el)
1073            .and_then(|e| e.get(c))
1074            .map(|s| s.as_str())
1075            .unwrap_or("")
1076    };
1077    for de in &mig_seg.data_elements {
1078        if !de.codes.is_empty() {
1079            let actual = actual_at(de.position, 0);
1080            if !actual.is_empty() && !de.codes.iter().any(|c| c.value == actual) {
1081                return false;
1082            }
1083        }
1084    }
1085    for comp in &mig_seg.composites {
1086        for de in &comp.data_elements {
1087            if !de.codes.is_empty() {
1088                let actual = actual_at(comp.position, de.position);
1089                if !actual.is_empty() && !de.codes.iter().any(|c| c.value == actual) {
1090                    return false;
1091                }
1092            }
1093        }
1094    }
1095    true
1096}
1097
1098#[cfg(test)]
1099mod tests {
1100    use super::*;
1101    use crate::test_support::{make_mig_group, make_mig_group_with_variant, make_mig_segment};
1102
1103    fn make_owned_seg(id: &str, elements: Vec<Vec<&str>>) -> OwnedSegment {
1104        OwnedSegment {
1105            id: id.to_string(),
1106            elements: elements
1107                .into_iter()
1108                .map(|e| e.into_iter().map(|c| c.to_string()).collect())
1109                .collect(),
1110            segment_number: 0,
1111        }
1112    }
1113
1114    fn make_mig_schema(segments: Vec<&str>, groups: Vec<MigSegmentGroup>) -> MigSchema {
1115        MigSchema {
1116            message_type: "UTILMD".to_string(),
1117            variant: Some("Strom".to_string()),
1118            version: "S2.1".to_string(),
1119            publication_date: "2025-03-20".to_string(),
1120            author: "BDEW".to_string(),
1121            format_version: "FV2504".to_string(),
1122            source_file: "test".to_string(),
1123            segments: segments.into_iter().map(make_mig_segment).collect(),
1124            segment_groups: groups,
1125        }
1126    }
1127
1128    #[test]
1129    fn test_assembler_top_level_segments_only() {
1130        let mig = make_mig_schema(vec!["UNH", "BGM", "DTM", "UNT"], vec![]);
1131
1132        let segments = vec![
1133            make_owned_seg("UNH", vec![vec!["001", "UTILMD:D:11A:UN:S2.1"]]),
1134            make_owned_seg("BGM", vec![vec!["E01", "DOC001"]]),
1135            make_owned_seg("DTM", vec![vec!["137", "20250101", "102"]]),
1136            make_owned_seg("UNT", vec![vec!["4", "001"]]),
1137        ];
1138
1139        let assembler = Assembler::new(&mig);
1140        let result = assembler.assemble_generic(&segments).unwrap();
1141
1142        assert_eq!(result.segments.len(), 4);
1143        assert_eq!(result.segments[0].tag, "UNH");
1144        assert_eq!(result.segments[1].tag, "BGM");
1145        assert_eq!(result.segments[2].tag, "DTM");
1146        assert_eq!(result.segments[3].tag, "UNT");
1147        assert!(result.groups.is_empty());
1148    }
1149
1150    #[test]
1151    fn test_assembler_with_segment_group() {
1152        let mig = make_mig_schema(
1153            vec!["UNH", "BGM"],
1154            vec![
1155                make_mig_group("SG2", vec!["NAD"], vec![]),
1156                make_mig_group("SG4", vec!["IDE", "STS"], vec![]),
1157            ],
1158        );
1159
1160        let segments = vec![
1161            make_owned_seg("UNH", vec![vec!["001"]]),
1162            make_owned_seg("BGM", vec![vec!["E01"]]),
1163            make_owned_seg("NAD", vec![vec!["MS", "9900123"]]),
1164            make_owned_seg("NAD", vec![vec!["MR", "9900456"]]),
1165            make_owned_seg("IDE", vec![vec!["24", "TX001"]]),
1166            make_owned_seg("STS", vec![vec!["7"], vec!["Z33"]]),
1167        ];
1168
1169        let assembler = Assembler::new(&mig);
1170        let result = assembler.assemble_generic(&segments).unwrap();
1171
1172        // Top-level: UNH, BGM
1173        assert_eq!(result.segments.len(), 2);
1174        // SG2: 2 repetitions (two NAD segments)
1175        assert_eq!(result.groups.len(), 2);
1176        assert_eq!(result.groups[0].group_id, "SG2");
1177        assert_eq!(result.groups[0].repetitions.len(), 2);
1178        assert_eq!(result.groups[0].repetitions[0].segments[0].tag, "NAD");
1179        assert_eq!(result.groups[0].repetitions[1].segments[0].tag, "NAD");
1180        // SG4: 1 repetition (IDE + STS)
1181        assert_eq!(result.groups[1].group_id, "SG4");
1182        assert_eq!(result.groups[1].repetitions.len(), 1);
1183        assert_eq!(result.groups[1].repetitions[0].segments.len(), 2);
1184    }
1185
1186    #[test]
1187    fn test_assembler_nested_groups() {
1188        let sg3 = make_mig_group("SG3", vec!["CTA", "COM"], vec![]);
1189        let mig = make_mig_schema(
1190            vec!["UNH", "BGM"],
1191            vec![make_mig_group("SG2", vec!["NAD"], vec![sg3])],
1192        );
1193
1194        let segments = vec![
1195            make_owned_seg("UNH", vec![vec!["001"]]),
1196            make_owned_seg("BGM", vec![vec!["E01"]]),
1197            make_owned_seg("NAD", vec![vec!["MS", "9900123"]]),
1198            make_owned_seg("CTA", vec![vec!["IC", "Kontakt"]]),
1199            make_owned_seg("COM", vec![vec!["040@example.com", "EM"]]),
1200        ];
1201
1202        let assembler = Assembler::new(&mig);
1203        let result = assembler.assemble_generic(&segments).unwrap();
1204
1205        // SG2 has 1 repetition
1206        let sg2 = &result.groups[0];
1207        assert_eq!(sg2.group_id, "SG2");
1208        assert_eq!(sg2.repetitions.len(), 1);
1209
1210        let sg2_inst = &sg2.repetitions[0];
1211        assert_eq!(sg2_inst.segments[0].tag, "NAD");
1212
1213        // SG3 nested inside SG2
1214        assert_eq!(sg2_inst.child_groups.len(), 1);
1215        let sg3 = &sg2_inst.child_groups[0];
1216        assert_eq!(sg3.group_id, "SG3");
1217        assert_eq!(sg3.repetitions[0].segments.len(), 2);
1218        assert_eq!(sg3.repetitions[0].segments[0].tag, "CTA");
1219        assert_eq!(sg3.repetitions[0].segments[1].tag, "COM");
1220    }
1221
1222    #[test]
1223    fn test_assembler_optional_segments_skipped() {
1224        // MIG expects UNH, BGM, DTM, UNT but input has no DTM
1225        let mig = make_mig_schema(vec!["UNH", "BGM", "DTM", "UNT"], vec![]);
1226
1227        let segments = vec![
1228            make_owned_seg("UNH", vec![vec!["001"]]),
1229            make_owned_seg("BGM", vec![vec!["E01"]]),
1230            make_owned_seg("UNT", vec![vec!["2", "001"]]),
1231        ];
1232
1233        let assembler = Assembler::new(&mig);
1234        let result = assembler.assemble_generic(&segments).unwrap();
1235
1236        // DTM is skipped (optional), UNT consumed
1237        assert_eq!(result.segments.len(), 3);
1238        assert_eq!(result.segments[0].tag, "UNH");
1239        assert_eq!(result.segments[1].tag, "BGM");
1240        assert_eq!(result.segments[2].tag, "UNT");
1241    }
1242
1243    #[test]
1244    fn test_assembler_empty_segments() {
1245        let mig = make_mig_schema(vec!["UNH"], vec![]);
1246        let assembler = Assembler::new(&mig);
1247        let result = assembler.assemble_generic(&[]).unwrap();
1248        assert!(result.segments.is_empty());
1249        assert!(result.groups.is_empty());
1250    }
1251
1252    #[test]
1253    fn test_assembler_preserves_element_data() {
1254        let mig = make_mig_schema(vec!["DTM"], vec![]);
1255
1256        let segments = vec![make_owned_seg(
1257            "DTM",
1258            vec![vec!["137", "202501010000+01", "303"]],
1259        )];
1260
1261        let assembler = Assembler::new(&mig);
1262        let result = assembler.assemble_generic(&segments).unwrap();
1263
1264        let dtm = &result.segments[0];
1265        assert_eq!(dtm.elements[0][0], "137");
1266        assert_eq!(dtm.elements[0][1], "202501010000+01");
1267        assert_eq!(dtm.elements[0][2], "303");
1268    }
1269
1270    #[test]
1271    fn test_group_instance_as_assembled_tree() {
1272        // Build an SG4 instance with root segments (IDE, STS) and child groups (SG5)
1273        let sg5 = AssembledGroup {
1274            group_id: "SG5".to_string(),
1275            repetitions: vec![AssembledGroupInstance {
1276                segments: vec![AssembledSegment {
1277                    tag: "LOC".to_string(),
1278                    elements: vec![vec!["Z16".to_string(), "DE000111222333".to_string()]],
1279                    mig_number: None,
1280                }],
1281                child_groups: vec![],
1282                entry_mig_number: None,
1283                variant_mig_numbers: vec![],
1284                skipped_segments: vec![],
1285                skipped_positions: Vec::new(),
1286            }],
1287        };
1288
1289        let sg4_instance = AssembledGroupInstance {
1290            segments: vec![
1291                AssembledSegment {
1292                    tag: "IDE".to_string(),
1293                    elements: vec![vec!["24".to_string(), "TX001".to_string()]],
1294                    mig_number: None,
1295                },
1296                AssembledSegment {
1297                    tag: "STS".to_string(),
1298                    elements: vec![vec!["7".to_string()]],
1299                    mig_number: None,
1300                },
1301            ],
1302            child_groups: vec![sg5],
1303            entry_mig_number: None,
1304            variant_mig_numbers: vec![],
1305            skipped_segments: vec![],
1306            skipped_positions: Vec::new(),
1307        };
1308
1309        let sub_tree = sg4_instance.as_assembled_tree();
1310
1311        // Root segments of sub-tree are the SG4 instance's segments
1312        assert_eq!(sub_tree.segments.len(), 2);
1313        assert_eq!(sub_tree.segments[0].tag, "IDE");
1314        assert_eq!(sub_tree.segments[1].tag, "STS");
1315
1316        // Groups of sub-tree are the SG4 instance's child groups
1317        assert_eq!(sub_tree.groups.len(), 1);
1318        assert_eq!(sub_tree.groups[0].group_id, "SG5");
1319
1320        // post_group_start marks where root segments end
1321        assert_eq!(sub_tree.post_group_start, 2);
1322    }
1323
1324    #[test]
1325    fn test_assembler_from_parsed_edifact() {
1326        // End-to-end: parse raw EDIFACT, then assemble
1327        let input = b"UNA:+.? 'UNB+UNOC:3+SENDER+RECEIVER+210101:1200+REF001'UNH+MSG001+UTILMD:D:11A:UN:S2.1'BGM+E01+DOC001+9'DTM+137:20250101:102'UNT+3+MSG001'UNZ+1+REF001'";
1328        let segments = crate::tokenize::parse_to_segments(input).unwrap();
1329
1330        let mig = make_mig_schema(vec!["UNB", "UNH", "BGM", "DTM", "UNT", "UNZ"], vec![]);
1331
1332        let assembler = Assembler::new(&mig);
1333        let result = assembler.assemble_generic(&segments).unwrap();
1334
1335        assert!(result.segments.iter().any(|s| s.tag == "UNH"));
1336        assert!(result.segments.iter().any(|s| s.tag == "BGM"));
1337        assert!(result.segments.iter().any(|s| s.tag == "DTM"));
1338    }
1339
1340    #[test]
1341    fn test_assemble_with_diagnostics_clean_input() {
1342        let mig = make_mig_schema(vec!["UNH", "BGM", "UNT"], vec![]);
1343        let segments = vec![
1344            make_owned_seg("UNH", vec![vec!["001"]]),
1345            make_owned_seg("BGM", vec![vec!["E01"]]),
1346            make_owned_seg("UNT", vec![vec!["2", "001"]]),
1347        ];
1348        let assembler = Assembler::new(&mig);
1349        let (tree, diagnostics) = assembler.assemble_with_diagnostics(&segments);
1350        assert_eq!(tree.segments.len(), 3);
1351        assert!(
1352            diagnostics.is_empty(),
1353            "Clean input should have no diagnostics"
1354        );
1355    }
1356
1357    #[test]
1358    fn test_assemble_with_diagnostics_unconsumed_segments() {
1359        let mig = make_mig_schema(vec!["UNH", "BGM"], vec![]);
1360        let segments = vec![
1361            make_owned_seg("UNH", vec![vec!["001"]]),
1362            make_owned_seg("BGM", vec![vec!["E01"]]),
1363            make_owned_seg("FTX", vec![vec!["AAA", "extra text"]]),
1364        ];
1365        let assembler = Assembler::new(&mig);
1366        let (tree, diagnostics) = assembler.assemble_with_diagnostics(&segments);
1367        assert_eq!(tree.segments.len(), 2);
1368        assert_eq!(diagnostics.len(), 1);
1369        assert_eq!(
1370            diagnostics[0].kind,
1371            StructureDiagnosticKind::UnexpectedSegment
1372        );
1373        assert_eq!(diagnostics[0].segment_id, "FTX");
1374        assert_eq!(diagnostics[0].position, 2);
1375    }
1376
1377    #[test]
1378    fn test_assemble_with_diagnostics_multiple_unconsumed() {
1379        let mig = make_mig_schema(vec!["UNH"], vec![]);
1380        let segments = vec![
1381            make_owned_seg("UNH", vec![vec!["001"]]),
1382            make_owned_seg("FOO", vec![]),
1383            make_owned_seg("BAR", vec![]),
1384            make_owned_seg("BAZ", vec![]),
1385        ];
1386        let assembler = Assembler::new(&mig);
1387        let (tree, diagnostics) = assembler.assemble_with_diagnostics(&segments);
1388        assert_eq!(tree.segments.len(), 1);
1389        assert_eq!(diagnostics.len(), 3);
1390        assert_eq!(diagnostics[0].segment_id, "FOO");
1391        assert_eq!(diagnostics[1].segment_id, "BAR");
1392        assert_eq!(diagnostics[2].segment_id, "BAZ");
1393    }
1394
1395    // ── Non-entry segment mig_number assignment tests ──
1396
1397    #[test]
1398    fn test_non_entry_segments_get_mig_number_from_bounded_slots() {
1399        // MIG group SG4 has entry IDE + two numbered DTMs + STS.
1400        // The assembler should assign mig_number from the MIG slots to
1401        // each non-entry segment via the bounded consumption path.
1402        use crate::test_support::make_mig_segment_numbered;
1403
1404        let sg4 = MigSegmentGroup {
1405            segments: vec![
1406                make_mig_segment_numbered("IDE", "00020"),
1407                make_mig_segment_numbered("DTM", "00023"),
1408                make_mig_segment_numbered("DTM", "00024"),
1409                make_mig_segment_numbered("STS", "00035"),
1410            ],
1411            ..make_mig_group("SG4", vec![], vec![])
1412        };
1413        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1414
1415        let segments = vec![
1416            make_owned_seg("UNH", vec![vec!["001"]]),
1417            make_owned_seg("IDE", vec![vec!["24", "TX001"]]),
1418            make_owned_seg("DTM", vec![vec!["92", "202505312200+00", "303"]]),
1419            make_owned_seg("DTM", vec![vec!["93", "202512312300+00", "303"]]),
1420            make_owned_seg("STS", vec![vec!["7"], vec![], vec!["E01"]]),
1421        ];
1422
1423        let assembler = Assembler::new(&mig);
1424        let tree = assembler.assemble_generic(&segments).unwrap();
1425
1426        let sg4_instance = &tree.groups[0].repetitions[0];
1427
1428        // IDE (entry) gets mig_number from try_consume_segment
1429        assert_eq!(sg4_instance.segments[0].tag, "IDE");
1430        assert_eq!(sg4_instance.segments[0].mig_number.as_deref(), Some("00020"));
1431
1432        // DTM+92 gets mig_number "00023" from first DTM slot
1433        assert_eq!(sg4_instance.segments[1].tag, "DTM");
1434        assert_eq!(sg4_instance.segments[1].mig_number.as_deref(), Some("00023"));
1435
1436        // DTM+93 gets mig_number "00024" from second DTM slot
1437        assert_eq!(sg4_instance.segments[2].tag, "DTM");
1438        assert_eq!(sg4_instance.segments[2].mig_number.as_deref(), Some("00024"));
1439
1440        // STS gets mig_number "00035"
1441        assert_eq!(sg4_instance.segments[3].tag, "STS");
1442        assert_eq!(sg4_instance.segments[3].mig_number.as_deref(), Some("00035"));
1443
1444        // variant_mig_numbers should contain all four
1445        assert!(sg4_instance.variant_mig_numbers.contains(&"00020".to_string()));
1446        assert!(sg4_instance.variant_mig_numbers.contains(&"00023".to_string()));
1447        assert!(sg4_instance.variant_mig_numbers.contains(&"00024".to_string()));
1448        assert!(sg4_instance.variant_mig_numbers.contains(&"00035".to_string()));
1449    }
1450
1451    #[test]
1452    fn test_greedy_extra_segments_get_no_mig_number() {
1453        // MIG defines 1 DTM slot, but input has 2 DTMs.
1454        // First DTM gets mig_number from bounded path, second gets None (greedy extra).
1455        use crate::test_support::make_mig_segment_numbered;
1456
1457        let sg4 = MigSegmentGroup {
1458            segments: vec![
1459                make_mig_segment_numbered("IDE", "00020"),
1460                make_mig_segment_numbered("DTM", "00023"),
1461            ],
1462            ..make_mig_group("SG4", vec![], vec![])
1463        };
1464        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1465
1466        let segments = vec![
1467            make_owned_seg("UNH", vec![vec!["001"]]),
1468            make_owned_seg("IDE", vec![vec!["24"]]),
1469            make_owned_seg("DTM", vec![vec!["92", "20250531"]]),
1470            make_owned_seg("DTM", vec![vec!["93", "20251231"]]), // extra beyond MIG
1471        ];
1472
1473        let assembler = Assembler::new(&mig);
1474        let tree = assembler.assemble_generic(&segments).unwrap();
1475
1476        let sg4_instance = &tree.groups[0].repetitions[0];
1477        assert_eq!(sg4_instance.segments.len(), 3); // IDE + 2 DTMs
1478
1479        // First DTM: bounded slot → mig_number set
1480        assert_eq!(sg4_instance.segments[1].mig_number.as_deref(), Some("00023"));
1481
1482        // Second DTM: greedy extra → mig_number None
1483        assert_eq!(sg4_instance.segments[2].mig_number, None);
1484    }
1485
1486    // ── Qualifier-aware assembly tests ──
1487
1488    #[test]
1489    fn test_qualifier_map_prevents_wrong_slot_consumption() {
1490        // MIG defines DTM(00023) + DTM(00024). Input has only DTM+93.
1491        // Without qualifier map: DTM+93 consumed by slot 00023 (wrong).
1492        // With qualifier map: slot 00023 expects "92", skips DTM+93.
1493        //   Slot 00024 expects "93", consumes DTM+93 correctly.
1494        use crate::test_support::make_mig_segment_numbered;
1495        use std::collections::HashMap;
1496
1497        let sg4 = MigSegmentGroup {
1498            segments: vec![
1499                make_mig_segment_numbered("IDE", "00020"),
1500                make_mig_segment_numbered("DTM", "00023"),
1501                make_mig_segment_numbered("DTM", "00024"),
1502            ],
1503            ..make_mig_group("SG4", vec![], vec![])
1504        };
1505        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1506
1507        let segments = vec![
1508            make_owned_seg("UNH", vec![vec!["001"]]),
1509            make_owned_seg("IDE", vec![vec!["24"]]),
1510            make_owned_seg("DTM", vec![vec!["93", "202512312300+00", "303"]]),
1511        ];
1512
1513        let mut qualifier_map = HashMap::new();
1514        qualifier_map.insert("00023".to_string(), (0, 0, "92".to_string()));
1515        qualifier_map.insert("00024".to_string(), (0, 0, "93".to_string()));
1516
1517        let config = AssemblerConfig {
1518            skip_unknown_segments: false,
1519            qualifier_map,
1520            ..Default::default()
1521        };
1522        let assembler = Assembler::with_config(&mig, config);
1523        let tree = assembler.assemble_generic(&segments).unwrap();
1524
1525        let sg4_instance = &tree.groups[0].repetitions[0];
1526
1527        // DTM+93 should be consumed by slot 00024, NOT slot 00023
1528        assert_eq!(sg4_instance.segments.len(), 2); // IDE + DTM+93
1529        let dtm = &sg4_instance.segments[1];
1530        assert_eq!(dtm.tag, "DTM");
1531        assert_eq!(
1532            dtm.mig_number.as_deref(),
1533            Some("00024"),
1534            "DTM+93 should get mig_number 00024 (not 00023)"
1535        );
1536    }
1537
1538    #[test]
1539    fn test_group_entry_qualifier_mismatch_does_not_infinite_loop() {
1540        // Regression: when a group's entry segment has a qualifier_map entry
1541        // but the input segment's qualifier does not match, the outer
1542        // `while !cursor.is_exhausted()` loop in try_consume_group used to
1543        // spin forever — entry tag matched, so the loop kept going, but
1544        // try_consume_segment rejected the segment on qualifier mismatch, so
1545        // the cursor never advanced. Each iteration allocated a fresh
1546        // variant_mig_numbers Vec via collect_mig_numbers, driving unbounded
1547        // memory growth (observed: 3.5 GB → OOM in ~3 s on staging for
1548        // FV2604/UTILMD_Gas/PID 44004 with LOC+172).
1549        use crate::test_support::make_mig_segment_numbered;
1550        use std::collections::HashMap;
1551
1552        let sg5 = MigSegmentGroup {
1553            segments: vec![make_mig_segment_numbered("LOC", "00050")],
1554            ..make_mig_group("SG5", vec![], vec![])
1555        };
1556        let mig = make_mig_schema(vec!["UNH"], vec![sg5]);
1557
1558        let segments = vec![
1559            make_owned_seg("UNH", vec![vec!["001"]]),
1560            // LOC entry tag matches, but qualifier "172" ≠ expected "Z16"
1561            make_owned_seg("LOC", vec![vec!["172"], vec!["92003964705"]]),
1562        ];
1563
1564        let mut qualifier_map = HashMap::new();
1565        qualifier_map.insert("00050".to_string(), (0, 0, "Z16".to_string()));
1566
1567        let config = AssemblerConfig {
1568            skip_unknown_segments: false,
1569            qualifier_map,
1570            ..Default::default()
1571        };
1572        let assembler = Assembler::with_config(&mig, config);
1573
1574        // Before the fix this would loop forever. Bound the assertion with a
1575        // generous wall-clock guard so a regression is a clear test failure
1576        // rather than a hanging CI job.
1577        let start = std::time::Instant::now();
1578        let tree = assembler.assemble_generic(&segments).unwrap();
1579        assert!(
1580            start.elapsed() < std::time::Duration::from_secs(5),
1581            "assembly took {:?} — suspected infinite-loop regression",
1582            start.elapsed()
1583        );
1584
1585        // LOC+172 didn't match SG5's qualifier, so SG5 should be empty.
1586        // The LOC segment remains unconsumed (caller will surface it as a
1587        // structure diagnostic).
1588        assert!(tree.groups.is_empty());
1589    }
1590
1591    // ── Skip-unknown-segments tests ──
1592
1593    #[test]
1594    fn test_skip_unknown_segment_between_slots() {
1595        // MIG group expects [SEQ, CCI], input has [SEQ, RFF, CCI].
1596        // With skip ON, RFF is skipped and CCI is consumed.
1597        // With skip OFF (default), CCI is lost because RFF stalls the cursor.
1598        let sg8 = make_mig_group("SG8", vec!["SEQ", "CCI"], vec![]);
1599        let mig = make_mig_schema(vec!["UNH"], vec![sg8.clone()]);
1600
1601        let segments = vec![
1602            make_owned_seg("UNH", vec![vec!["001"]]),
1603            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1604            make_owned_seg("RFF", vec![vec!["Z38", "CROSSREF"]]),
1605            make_owned_seg("CCI", vec![vec!["Z30"]]),
1606        ];
1607
1608        // Skip OFF: CCI not consumed (RFF stalls cursor after SEQ)
1609        let off = Assembler::new(&mig);
1610        let tree_off = off.assemble_generic(&segments).unwrap();
1611        let sg8_off = &tree_off.groups[0];
1612        assert_eq!(sg8_off.repetitions[0].segments.len(), 1); // Only SEQ
1613        assert_eq!(sg8_off.repetitions[0].segments[0].tag, "SEQ");
1614
1615        // Skip ON: RFF skipped, CCI consumed
1616        let on = Assembler::with_config(
1617            &mig,
1618            AssemblerConfig {
1619                skip_unknown_segments: true,
1620                ..Default::default()
1621            },
1622        );
1623        let tree_on = on.assemble_generic(&segments).unwrap();
1624        let sg8_on = &tree_on.groups[0];
1625        assert_eq!(sg8_on.repetitions[0].segments.len(), 2); // SEQ + CCI
1626        assert_eq!(sg8_on.repetitions[0].segments[0].tag, "SEQ");
1627        assert_eq!(sg8_on.repetitions[0].segments[1].tag, "CCI");
1628    }
1629
1630    #[test]
1631    fn test_skip_preserves_on_instance() {
1632        // Skipped segments are stored in instance.skipped_segments
1633        let sg8 = make_mig_group("SG8", vec!["SEQ", "CCI"], vec![]);
1634        let mig = make_mig_schema(vec!["UNH"], vec![sg8]);
1635
1636        let segments = vec![
1637            make_owned_seg("UNH", vec![vec!["001"]]),
1638            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1639            make_owned_seg("RFF", vec![vec!["Z38", "REF1"]]),
1640            make_owned_seg("DTM", vec![vec!["92", "20250101"]]),
1641            make_owned_seg("CCI", vec![vec!["Z30"]]),
1642        ];
1643
1644        let assembler = Assembler::with_config(
1645            &mig,
1646            AssemblerConfig {
1647                skip_unknown_segments: true,
1648                ..Default::default()
1649            },
1650        );
1651        let tree = assembler.assemble_generic(&segments).unwrap();
1652        let instance = &tree.groups[0].repetitions[0];
1653
1654        assert_eq!(instance.segments.len(), 2); // SEQ + CCI
1655        assert_eq!(instance.skipped_segments.len(), 2); // RFF + DTM
1656        assert_eq!(instance.skipped_segments[0].tag, "RFF");
1657        assert_eq!(instance.skipped_segments[1].tag, "DTM");
1658    }
1659
1660    #[test]
1661    fn test_skip_mode_off_default() {
1662        // Assembler::new() doesn't skip (backwards compat)
1663        let mig = make_mig_schema(vec![], vec![]);
1664        let assembler = Assembler::new(&mig);
1665        assert!(!assembler.config.skip_unknown_segments);
1666    }
1667
1668    #[test]
1669    fn test_skip_does_not_consume_nested_group_entry() {
1670        // Skip must NOT consume segments that are nested group entries.
1671        // SG4 expects [IDE, STS], nested SG5 expects [LOC].
1672        // Input: IDE, FOO, STS, LOC. FOO should be skipped, LOC goes to SG5.
1673        let sg5 = make_mig_group("SG5", vec!["LOC"], vec![]);
1674        let sg4 = make_mig_group("SG4", vec!["IDE", "STS"], vec![sg5]);
1675        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1676
1677        let segments = vec![
1678            make_owned_seg("UNH", vec![vec!["001"]]),
1679            make_owned_seg("IDE", vec![vec!["24"]]),
1680            make_owned_seg("FOO", vec![vec!["unknown"]]),
1681            make_owned_seg("STS", vec![vec!["7"]]),
1682            make_owned_seg("LOC", vec![vec!["Z16"]]),
1683        ];
1684
1685        let assembler = Assembler::with_config(
1686            &mig,
1687            AssemblerConfig {
1688                skip_unknown_segments: true,
1689                ..Default::default()
1690            },
1691        );
1692        let tree = assembler.assemble_generic(&segments).unwrap();
1693        let sg4 = &tree.groups[0];
1694        let inst = &sg4.repetitions[0];
1695
1696        // IDE + STS consumed, FOO skipped
1697        assert_eq!(inst.segments.len(), 2);
1698        assert_eq!(inst.segments[0].tag, "IDE");
1699        assert_eq!(inst.segments[1].tag, "STS");
1700        assert_eq!(inst.skipped_segments.len(), 1);
1701        assert_eq!(inst.skipped_segments[0].tag, "FOO");
1702
1703        // LOC went to nested SG5
1704        assert_eq!(inst.child_groups.len(), 1);
1705        assert_eq!(inst.child_groups[0].group_id, "SG5");
1706        assert_eq!(inst.child_groups[0].repetitions[0].segments[0].tag, "LOC");
1707    }
1708
1709    #[test]
1710    fn test_skip_unknown_between_nested_group_reps() {
1711        // PID 55035 regression: an AHB-foreign segment sitting between two
1712        // reps of a nested variant-aware group currently stalls the cursor
1713        // and cascades: every subsequent valid rep is lost. With skip mode on,
1714        // the orphan should be recorded and the following reps consumed.
1715        //
1716        // Shape:
1717        //   SG4 [IDE, STS]
1718        //     SG8 (variant ZD7) [SEQ]
1719        //       SG10 [CCI]
1720        //     SG8 (variant Z98) [SEQ]
1721        //     SG12 [NAD]
1722        //
1723        // Input:
1724        //   IDE, STS, SEQ+ZD7, CCI+Z30, FOO+<orphan>, SEQ+Z98, NAD+MS
1725        //
1726        // Expected with skip ON: SG8 has 2 reps (ZD7 + Z98), SG12 has 1 rep,
1727        // and the orphan FOO sits in SG4's skipped_segments.
1728        let sg10 = make_mig_group("SG10", vec!["CCI"], vec![]);
1729        let sg8_zd7 =
1730            make_mig_group_with_variant("SG8", vec!["SEQ"], vec![sg10.clone()], "ZD7");
1731        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ"], vec![], "Z98");
1732        let sg12 = make_mig_group("SG12", vec!["NAD"], vec![]);
1733        let sg4 = make_mig_group(
1734            "SG4",
1735            vec!["IDE", "STS"],
1736            vec![sg8_zd7, sg8_z98, sg12],
1737        );
1738        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1739
1740        let segments = vec![
1741            make_owned_seg("UNH", vec![vec!["001"]]),
1742            make_owned_seg("IDE", vec![vec!["24", "TX001"]]),
1743            make_owned_seg("STS", vec![vec!["7"], vec!["Z33"]]),
1744            make_owned_seg("SEQ", vec![vec!["ZD7"]]),
1745            make_owned_seg("CCI", vec![vec!["Z30"]]),
1746            make_owned_seg("FOO", vec![vec!["orphan"]]),
1747            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1748            make_owned_seg("NAD", vec![vec!["MS", "9900123"]]),
1749        ];
1750
1751        let assembler = Assembler::with_config(
1752            &mig,
1753            AssemblerConfig {
1754                skip_unknown_segments: true,
1755                ..Default::default()
1756            },
1757        );
1758        let tree = assembler.assemble_generic(&segments).unwrap();
1759
1760        assert_eq!(tree.groups.len(), 1);
1761        let sg4_inst = &tree.groups[0].repetitions[0];
1762        // SG4 nested child groups: one SG8 (combined ZD7+Z98) and one SG12
1763        assert_eq!(sg4_inst.child_groups.len(), 2, "expected SG8 + SG12");
1764        let sg8_tree = &sg4_inst.child_groups[0];
1765        assert_eq!(sg8_tree.group_id, "SG8");
1766        assert_eq!(
1767            sg8_tree.repetitions.len(),
1768            2,
1769            "SG8 should have both ZD7 and Z98 reps after orphan skip"
1770        );
1771        assert_eq!(sg8_tree.repetitions[0].segments[0].elements[0][0], "ZD7");
1772        assert_eq!(sg8_tree.repetitions[1].segments[0].elements[0][0], "Z98");
1773
1774        let sg12_tree = &sg4_inst.child_groups[1];
1775        assert_eq!(sg12_tree.group_id, "SG12");
1776        assert_eq!(sg12_tree.repetitions.len(), 1);
1777
1778        // Orphan FOO is recorded on whichever instance was active when it
1779        // was encountered. It may live on SG4, on the first SG8 rep, or on
1780        // that rep's child SG10 — the important thing is that it's captured
1781        // exactly once and the subsequent valid reps were still consumed.
1782        fn count_foo(inst: &AssembledGroupInstance) -> usize {
1783            let mut n = inst
1784                .skipped_segments
1785                .iter()
1786                .filter(|s| s.tag == "FOO")
1787                .count();
1788            for child in &inst.child_groups {
1789                for rep in &child.repetitions {
1790                    n += count_foo(rep);
1791                }
1792            }
1793            n
1794        }
1795        assert_eq!(count_foo(sg4_inst), 1, "FOO should be recorded exactly once");
1796    }
1797
1798    #[test]
1799    fn test_skip_off_preserves_cascade_behavior() {
1800        // Same structure as above, but with skip OFF the orphan must still
1801        // stall the cursor (callers relying on strict assembly shouldn't
1802        // suddenly see orphans silently swallowed).
1803        let sg10 = make_mig_group("SG10", vec!["CCI"], vec![]);
1804        let sg8_zd7 =
1805            make_mig_group_with_variant("SG8", vec!["SEQ"], vec![sg10], "ZD7");
1806        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ"], vec![], "Z98");
1807        let sg12 = make_mig_group("SG12", vec!["NAD"], vec![]);
1808        let sg4 = make_mig_group(
1809            "SG4",
1810            vec!["IDE", "STS"],
1811            vec![sg8_zd7, sg8_z98, sg12],
1812        );
1813        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1814
1815        let segments = vec![
1816            make_owned_seg("UNH", vec![vec!["001"]]),
1817            make_owned_seg("IDE", vec![vec!["24", "TX001"]]),
1818            make_owned_seg("STS", vec![vec!["7"], vec!["Z33"]]),
1819            make_owned_seg("SEQ", vec![vec!["ZD7"]]),
1820            make_owned_seg("CCI", vec![vec!["Z30"]]),
1821            make_owned_seg("FOO", vec![vec!["orphan"]]),
1822            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1823            make_owned_seg("NAD", vec![vec!["MS", "9900123"]]),
1824        ];
1825
1826        let assembler = Assembler::new(&mig);
1827        let tree = assembler.assemble_generic(&segments).unwrap();
1828
1829        let sg4_inst = &tree.groups[0].repetitions[0];
1830        let sg8_tree = sg4_inst
1831            .child_groups
1832            .iter()
1833            .find(|g| g.group_id == "SG8")
1834            .expect("SG8 should still be present");
1835        // Only the first SG8 rep gets consumed; Z98 and NAD stall behind FOO.
1836        assert_eq!(sg8_tree.repetitions.len(), 1);
1837        assert!(
1838            sg4_inst
1839                .skipped_segments
1840                .iter()
1841                .all(|s| s.tag != "FOO"),
1842            "FOO must not be skipped when skip mode is off"
1843        );
1844    }
1845
1846    #[test]
1847    fn test_roundtrip_with_skip() {
1848        // Full roundtrip: assemble with skip → disassemble → byte-identical
1849        // including skipped segments in the output.
1850        use crate::disassembler::Disassembler;
1851        use crate::renderer::render_edifact;
1852
1853        let sg8 = make_mig_group("SG8", vec!["SEQ", "CCI"], vec![]);
1854        let mig = make_mig_schema(vec!["UNH", "UNT"], vec![sg8]);
1855
1856        let segments = vec![
1857            make_owned_seg("UNH", vec![vec!["001"]]),
1858            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1859            make_owned_seg("RFF", vec![vec!["Z38", "REF1"]]),
1860            make_owned_seg("CCI", vec![vec!["Z30"]]),
1861            make_owned_seg("UNT", vec![vec!["4", "001"]]),
1862        ];
1863
1864        let assembler = Assembler::with_config(
1865            &mig,
1866            AssemblerConfig {
1867                skip_unknown_segments: true,
1868                ..Default::default()
1869            },
1870        );
1871        let tree = assembler.assemble_generic(&segments).unwrap();
1872
1873        let disassembler = Disassembler::new(&mig);
1874        let dis = disassembler.disassemble(&tree);
1875        let delimiters = edifact_primitives::EdifactDelimiters::default();
1876        let rendered = render_edifact(&dis, &delimiters);
1877
1878        // All 5 segments should appear in output (including skipped RFF).
1879        // Disassembler emits MIG-guided segments first (SEQ, CCI),
1880        // then skipped segments (RFF) — so order within the group differs
1881        // from the original input, but all content is preserved.
1882        assert_eq!(dis.len(), 5);
1883        assert_eq!(dis[0].tag, "UNH");
1884        assert_eq!(dis[1].tag, "SEQ");
1885        assert_eq!(dis[2].tag, "CCI");
1886        assert_eq!(dis[3].tag, "RFF"); // skipped → emitted after MIG segments
1887        assert_eq!(dis[4].tag, "UNT");
1888
1889        // Rendered output contains all segments
1890        assert!(rendered.contains("UNH+001"));
1891        assert!(rendered.contains("SEQ+Z98"));
1892        assert!(rendered.contains("RFF+Z38:REF1"));
1893        assert!(rendered.contains("CCI+Z30"));
1894        assert!(rendered.contains("UNT+4:001"));
1895    }
1896
1897    // ── Variant-aware assembly tests ──
1898
1899    #[test]
1900    fn test_variant_groups_interleaved_reps() {
1901        // Two SG8 variant definitions: one for SEQ+ZD7, one for SEQ+Z98.
1902        // Input has interleaved reps: ZD7, Z98, ZD7, Z98.
1903        // All should be collected into one SG8 group with 4 reps.
1904        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ", "CCI"], vec![], "ZD7");
1905        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ", "RFF"], vec![], "Z98");
1906
1907        let mig = make_mig_schema(vec!["UNH"], vec![sg8_zd7, sg8_z98]);
1908
1909        let segments = vec![
1910            make_owned_seg("UNH", vec![vec!["001"]]),
1911            make_owned_seg("SEQ", vec![vec!["ZD7"]]),
1912            make_owned_seg("CCI", vec![vec!["Z30"]]),
1913            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1914            make_owned_seg("RFF", vec![vec!["Z38", "REF1"]]),
1915            make_owned_seg("SEQ", vec![vec!["ZD7"]]),
1916            make_owned_seg("CCI", vec![vec!["Z31"]]),
1917            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1918            make_owned_seg("RFF", vec![vec!["Z38", "REF2"]]),
1919        ];
1920
1921        let assembler = Assembler::new(&mig);
1922        let result = assembler.assemble_generic(&segments).unwrap();
1923
1924        assert_eq!(result.segments.len(), 1); // UNH
1925        assert_eq!(result.groups.len(), 1); // One combined SG8
1926        let sg8 = &result.groups[0];
1927        assert_eq!(sg8.group_id, "SG8");
1928        assert_eq!(sg8.repetitions.len(), 4);
1929
1930        // ZD7 reps have SEQ+CCI, Z98 reps have SEQ+RFF
1931        assert_eq!(sg8.repetitions[0].segments[0].elements[0][0], "ZD7");
1932        assert_eq!(sg8.repetitions[0].segments[1].tag, "CCI");
1933        assert_eq!(sg8.repetitions[1].segments[0].elements[0][0], "Z98");
1934        assert_eq!(sg8.repetitions[1].segments[1].tag, "RFF");
1935        assert_eq!(sg8.repetitions[2].segments[0].elements[0][0], "ZD7");
1936        assert_eq!(sg8.repetitions[3].segments[0].elements[0][0], "Z98");
1937    }
1938
1939    #[test]
1940    fn test_variant_groups_single_variant_type() {
1941        // Only Z98 reps, no ZD7 — still works with variant matching
1942        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ", "CCI"], vec![], "ZD7");
1943        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ", "RFF"], vec![], "Z98");
1944
1945        let mig = make_mig_schema(vec!["UNH"], vec![sg8_zd7, sg8_z98]);
1946
1947        let segments = vec![
1948            make_owned_seg("UNH", vec![vec!["001"]]),
1949            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1950            make_owned_seg("RFF", vec![vec!["Z38", "REF1"]]),
1951            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1952            make_owned_seg("RFF", vec![vec!["Z38", "REF2"]]),
1953        ];
1954
1955        let assembler = Assembler::new(&mig);
1956        let result = assembler.assemble_generic(&segments).unwrap();
1957
1958        assert_eq!(result.groups.len(), 1);
1959        assert_eq!(result.groups[0].repetitions.len(), 2);
1960        assert_eq!(
1961            result.groups[0].repetitions[0].segments[0].elements[0][0],
1962            "Z98"
1963        );
1964        assert_eq!(
1965            result.groups[0].repetitions[1].segments[0].elements[0][0],
1966            "Z98"
1967        );
1968    }
1969
1970    #[test]
1971    fn test_non_variant_groups_unchanged() {
1972        // Groups without variant_code behave exactly as before
1973        let sg2 = make_mig_group("SG2", vec!["NAD"], vec![]);
1974        let sg4 = make_mig_group("SG4", vec!["IDE", "STS"], vec![]);
1975
1976        let mig = make_mig_schema(vec!["UNH", "BGM"], vec![sg2, sg4]);
1977
1978        let segments = vec![
1979            make_owned_seg("UNH", vec![vec!["001"]]),
1980            make_owned_seg("BGM", vec![vec!["E01"]]),
1981            make_owned_seg("NAD", vec![vec!["MS", "9900123"]]),
1982            make_owned_seg("NAD", vec![vec!["MR", "9900456"]]),
1983            make_owned_seg("IDE", vec![vec!["24", "TX001"]]),
1984            make_owned_seg("STS", vec![vec!["7"], vec!["Z33"]]),
1985        ];
1986
1987        let assembler = Assembler::new(&mig);
1988        let result = assembler.assemble_generic(&segments).unwrap();
1989
1990        assert_eq!(result.segments.len(), 2);
1991        assert_eq!(result.groups.len(), 2);
1992        assert_eq!(result.groups[0].group_id, "SG2");
1993        assert_eq!(result.groups[0].repetitions.len(), 2);
1994        assert_eq!(result.groups[1].group_id, "SG4");
1995        assert_eq!(result.groups[1].repetitions.len(), 1);
1996    }
1997
1998    #[test]
1999    fn test_variant_groups_with_nested_children() {
2000        // Variant groups can have nested child groups
2001        let sg10 = make_mig_group("SG10", vec!["CCI", "CAV"], vec![]);
2002        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ"], vec![sg10.clone()], "ZD7");
2003        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ"], vec![sg10], "Z98");
2004
2005        let mig = make_mig_schema(vec!["UNH"], vec![sg8_zd7, sg8_z98]);
2006
2007        let segments = vec![
2008            make_owned_seg("UNH", vec![vec!["001"]]),
2009            make_owned_seg("SEQ", vec![vec!["ZD7"]]),
2010            make_owned_seg("CCI", vec![vec!["Z30"]]),
2011            make_owned_seg("CAV", vec![vec!["Z91", "Y"]]),
2012            make_owned_seg("SEQ", vec![vec!["Z98"]]),
2013            make_owned_seg("CCI", vec![vec!["Z31"]]),
2014            make_owned_seg("CAV", vec![vec!["Z91", "N"]]),
2015        ];
2016
2017        let assembler = Assembler::new(&mig);
2018        let result = assembler.assemble_generic(&segments).unwrap();
2019
2020        assert_eq!(result.groups.len(), 1);
2021        let sg8 = &result.groups[0];
2022        assert_eq!(sg8.repetitions.len(), 2);
2023
2024        // First rep (ZD7) has nested SG10
2025        assert_eq!(sg8.repetitions[0].child_groups.len(), 1);
2026        assert_eq!(sg8.repetitions[0].child_groups[0].group_id, "SG10");
2027        assert_eq!(
2028            sg8.repetitions[0].child_groups[0].repetitions[0].segments[0].elements[0][0],
2029            "Z30"
2030        );
2031
2032        // Second rep (Z98) has nested SG10
2033        assert_eq!(sg8.repetitions[1].child_groups.len(), 1);
2034        assert_eq!(
2035            sg8.repetitions[1].child_groups[0].repetitions[0].segments[0].elements[0][0],
2036            "Z31"
2037        );
2038    }
2039
2040    #[test]
2041    fn test_variant_qualifier_check_prevents_wrong_variant_consumption() {
2042        // try_consume_group with variant_code set should NOT consume a segment
2043        // whose qualifier doesn't match, even if the tag matches.
2044        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ", "CCI"], vec![], "ZD7");
2045
2046        let mig = make_mig_schema(vec!["UNH"], vec![sg8_zd7]);
2047
2048        let segments = vec![
2049            make_owned_seg("UNH", vec![vec!["001"]]),
2050            make_owned_seg("SEQ", vec![vec!["Z98"]]), // Wrong qualifier
2051            make_owned_seg("CCI", vec![vec!["Z30"]]),
2052        ];
2053
2054        let assembler = Assembler::new(&mig);
2055        let result = assembler.assemble_generic(&segments).unwrap();
2056
2057        // SG8 should have no reps because Z98 != ZD7
2058        assert!(result.groups.is_empty());
2059    }
2060
2061    #[test]
2062    fn test_mixed_variant_and_non_variant_groups() {
2063        // SG2 (no variant), then variant SG8s, then SG12 (no variant)
2064        let sg2 = make_mig_group("SG2", vec!["NAD"], vec![]);
2065        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ", "CCI"], vec![], "ZD7");
2066        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ", "RFF"], vec![], "Z98");
2067        let sg12 = make_mig_group("SG12", vec!["NAD"], vec![]);
2068
2069        let mig = make_mig_schema(vec!["UNH"], vec![sg2, sg8_zd7, sg8_z98, sg12]);
2070
2071        let segments = vec![
2072            make_owned_seg("UNH", vec![vec!["001"]]),
2073            make_owned_seg("NAD", vec![vec!["MS", "9900123"]]),
2074            make_owned_seg("SEQ", vec![vec!["ZD7"]]),
2075            make_owned_seg("CCI", vec![vec!["Z30"]]),
2076            make_owned_seg("SEQ", vec![vec!["Z98"]]),
2077            make_owned_seg("RFF", vec![vec!["Z38", "REF1"]]),
2078            make_owned_seg("NAD", vec![vec!["Z65", "ID001"]]),
2079        ];
2080
2081        let assembler = Assembler::new(&mig);
2082        let result = assembler.assemble_generic(&segments).unwrap();
2083
2084        assert_eq!(result.groups.len(), 3); // SG2, SG8 (combined), SG12
2085        assert_eq!(result.groups[0].group_id, "SG2");
2086        assert_eq!(result.groups[0].repetitions.len(), 1);
2087        assert_eq!(result.groups[1].group_id, "SG8");
2088        assert_eq!(result.groups[1].repetitions.len(), 2);
2089        assert_eq!(result.groups[2].group_id, "SG12");
2090        assert_eq!(result.groups[2].repetitions.len(), 1);
2091    }
2092
2093    #[test]
2094    fn test_assembler_disambiguates_shared_qualifier_by_full_code_profile() {
2095        // PID 55035 PIA variants: several mig slots share the primary qualifier
2096        // 4347='5' but differ at C212/7143 (one allows Z12, another SRW). With
2097        // only per-mig qualifier_map, the assembler consumes at the first matching
2098        // slot regardless of the composite code — the downstream validator is
2099        // then forced to second-guess the variant choice. Disambiguate at
2100        // assembly time by checking all code-bearing positions declared on the
2101        // MIG segment.
2102        use mig_types::schema::common::CodeDefinition;
2103        use mig_types::schema::mig::{MigComposite, MigDataElement};
2104        use std::collections::HashMap;
2105
2106        fn code(value: &str) -> CodeDefinition {
2107            CodeDefinition {
2108                value: value.to_string(),
2109                name: value.to_string(),
2110                description: None,
2111            }
2112        }
2113
2114        fn pia_slot(number: &str, composite_code: &str) -> MigSegment {
2115            MigSegment {
2116                id: "PIA".to_string(),
2117                name: "PIA".to_string(),
2118                description: None,
2119                counter: None,
2120                level: 1,
2121                number: Some(number.to_string()),
2122                max_rep_std: 1,
2123                max_rep_spec: 1,
2124                status_std: Some("M".to_string()),
2125                status_spec: Some("M".to_string()),
2126                example: None,
2127                data_elements: vec![MigDataElement {
2128                    id: "4347".to_string(),
2129                    name: "Produkt-ID-Funktion".to_string(),
2130                    description: None,
2131                    status_std: Some("M".to_string()),
2132                    status_spec: Some("M".to_string()),
2133                    format_std: None,
2134                    format_spec: None,
2135                    codes: vec![code("5")],
2136                    position: 0,
2137                }],
2138                composites: vec![MigComposite {
2139                    id: "C212".to_string(),
2140                    name: "Item Identifier".to_string(),
2141                    description: None,
2142                    status_std: Some("M".to_string()),
2143                    status_spec: Some("M".to_string()),
2144                    data_elements: vec![MigDataElement {
2145                        id: "7143".to_string(),
2146                        name: "Artikel/Dienstleistung-ID".to_string(),
2147                        description: None,
2148                        status_std: Some("M".to_string()),
2149                        status_spec: Some("M".to_string()),
2150                        format_std: None,
2151                        format_spec: None,
2152                        codes: vec![code(composite_code)],
2153                        position: 0,
2154                    }],
2155                    position: 1,
2156                }],
2157            }
2158        }
2159
2160        let sg4 = MigSegmentGroup {
2161            segments: vec![
2162                crate::test_support::make_mig_segment_numbered("IDE", "00020"),
2163                pia_slot("00108", "Z12"),
2164                pia_slot("00197", "SRW"),
2165            ],
2166            ..make_mig_group("SG4", vec![], vec![])
2167        };
2168        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
2169
2170        let segments = vec![
2171            make_owned_seg("UNH", vec![vec!["001"]]),
2172            make_owned_seg("IDE", vec![vec!["24"]]),
2173            // PIA+5+:::SRW — composite element 1, component 0 = "SRW"
2174            make_owned_seg("PIA", vec![vec!["5"], vec!["SRW"]]),
2175        ];
2176
2177        // Both PIA mig slots share the qualifier (0,0)='5'. Without full-profile
2178        // matching, the first slot (00108) wins and the SRW composite is
2179        // mis-assigned to the Z12 variant.
2180        let mut qualifier_map = HashMap::new();
2181        qualifier_map.insert("00108".to_string(), (0, 0, "5".to_string()));
2182        qualifier_map.insert("00197".to_string(), (0, 0, "5".to_string()));
2183
2184        let config = AssemblerConfig {
2185            skip_unknown_segments: false,
2186            qualifier_map,
2187            strict_code_matching: true,
2188        };
2189        let assembler = Assembler::with_config(&mig, config);
2190        let tree = assembler.assemble_generic(&segments).unwrap();
2191
2192        let sg4_instance = &tree.groups[0].repetitions[0];
2193        let pia = sg4_instance
2194            .segments
2195            .iter()
2196            .find(|s| s.tag == "PIA")
2197            .expect("PIA consumed into SG4");
2198        assert_eq!(
2199            pia.mig_number.as_deref(),
2200            Some("00197"),
2201            "PIA+5+:::SRW must be assigned the SRW variant (mig=00197), not the Z12 variant"
2202        );
2203    }
2204}