Skip to main content

automapper_validation/validator/
validate.rs

1//! Main EdifactValidator implementation.
2
3use std::collections::{BTreeMap, HashMap, HashSet};
4
5use crate::expr::{ConditionExpr, ConditionParser};
6
7use crate::eval::{
8    ConditionEvaluator, ConditionExprEvaluator, ConditionResult, EvaluationContext,
9    ExternalConditionProvider, GroupScope,
10};
11use mig_types::navigator::GroupNavigator;
12use mig_types::segment::OwnedSegment;
13
14use super::tree::{AhbGroupNode, AhbNode, ValidatedTree};
15
16use super::issue::{Severity, ValidationIssue};
17use super::level::ValidationLevel;
18use super::report::ValidationReport;
19use crate::{IssueKind, UnresolvedConditions};
20
21// The AHB rulebook data types (`AhbFieldRule`, `AhbCodeRule`, `AhbWorkflow`) now
22// live in the `ahb-types` crate so both this validation crate and `mig-bo4e`
23// (which embeds `AhbWorkflow` in the distribution bundle) can share them as
24// serializable data — without a mapping↔validation dependency edge. Re-exported
25// here so existing `crate::validator::validate::Ahb*` paths keep resolving.
26pub use ahb_types::{AhbCodeRule, AhbFieldRule, AhbWorkflow};
27
28/// Validates EDIFACT messages against AHB business rules.
29///
30/// The validator is a pure validation engine: it receives pre-parsed
31/// segments, an AHB workflow, and an external condition provider.
32/// Parsing and message-type detection are the caller's responsibility.
33///
34/// The validator is generic over the `ConditionEvaluator` implementation,
35/// which is typically generated from AHB XML schemas.
36///
37/// # Example
38///
39/// ```ignore
40/// use automapper_validation::validator::EdifactValidator;
41/// use automapper_validation::eval::NoOpExternalProvider;
42///
43/// let evaluator = UtilmdConditionEvaluatorFV2510::new();
44/// let validator = EdifactValidator::new(evaluator);
45/// let external = NoOpExternalProvider;
46///
47/// let report = validator.validate(
48///     &segments,
49///     &ahb_workflow,
50///     &external,
51///     ValidationLevel::Full,
52/// );
53///
54/// if !report.is_valid() {
55///     for error in report.errors() {
56///         eprintln!("{error}");
57///     }
58/// }
59/// ```
60pub struct EdifactValidator<E: ConditionEvaluator> {
61    evaluator: E,
62}
63
64impl<E: ConditionEvaluator> EdifactValidator<E> {
65    /// Create a new validator with the given condition evaluator.
66    pub fn new(evaluator: E) -> Self {
67        Self { evaluator }
68    }
69
70    /// Validate pre-parsed EDIFACT segments against an AHB workflow.
71    ///
72    /// # Arguments
73    ///
74    /// * `segments` - Pre-parsed EDIFACT segments
75    /// * `workflow` - AHB workflow definition for the PID
76    /// * `external` - Provider for external conditions
77    /// * `level` - Validation strictness level
78    ///
79    /// # Returns
80    ///
81    /// A `ValidationReport` with all issues found.
82    pub fn validate(
83        &self,
84        segments: &[OwnedSegment],
85        workflow: &AhbWorkflow,
86        external: &dyn ExternalConditionProvider,
87        level: ValidationLevel,
88    ) -> ValidationReport {
89        let mut report = ValidationReport::new(self.evaluator.message_type(), level)
90            .with_format_version(self.evaluator.format_version())
91            .with_pruefidentifikator(&workflow.pruefidentifikator);
92
93        let ctx = EvaluationContext::new(&workflow.pruefidentifikator, external, segments);
94
95        if matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
96            self.validate_conditions(workflow, &ctx, &mut report);
97        }
98
99        report
100    }
101
102    /// Validate with a group navigator for group-scoped condition queries.
103    ///
104    /// Same as [`validate`] but passes a `GroupNavigator` to the
105    /// `EvaluationContext`, enabling conditions to query segments within
106    /// specific group instances (e.g., "in derselben SG8").
107    pub fn validate_with_navigator(
108        &self,
109        segments: &[OwnedSegment],
110        workflow: &AhbWorkflow,
111        external: &dyn ExternalConditionProvider,
112        level: ValidationLevel,
113        navigator: &dyn GroupNavigator,
114    ) -> ValidationReport {
115        let mut report = ValidationReport::new(self.evaluator.message_type(), level)
116            .with_format_version(self.evaluator.format_version())
117            .with_pruefidentifikator(&workflow.pruefidentifikator);
118
119        let ctx = EvaluationContext::with_navigator(
120            &workflow.pruefidentifikator,
121            external,
122            segments,
123            navigator,
124        );
125
126        if matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
127            self.validate_conditions(workflow, &ctx, &mut report);
128        }
129
130        report
131    }
132
133    /// Validate using a pre-built ValidatedTree where each node carries
134    /// its resolved EDIFACT value.
135    ///
136    /// This is the preferred validation path -- conditions see `ctx.resolved_value`
137    /// set from the tree node, so format conditions like [931] "ZZZ=+00" check
138    /// the correct segment instance (e.g., DTM+92, not DTM+137).
139    pub fn validate_tree(
140        &self,
141        validated_tree: &ValidatedTree,
142        segments: &[OwnedSegment],
143        external: &dyn ExternalConditionProvider,
144        level: ValidationLevel,
145        navigator: Option<&dyn GroupNavigator>,
146    ) -> ValidationReport {
147        let mut report = ValidationReport::new(self.evaluator.message_type(), level)
148            .with_format_version(self.evaluator.format_version())
149            .with_pruefidentifikator(validated_tree.pruefidentifikator);
150
151        if !matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
152            return report;
153        }
154
155        let ctx = match navigator {
156            Some(nav) => EvaluationContext::with_navigator(
157                validated_tree.pruefidentifikator,
158                external,
159                segments,
160                nav,
161            ),
162            None => EvaluationContext::new(validated_tree.pruefidentifikator, external, segments),
163        };
164
165        let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
166
167        // Collect all nodes depth-first from the tree (used for tag_migs
168        // precomputation and for the synthetic workflow at the bottom).
169        let mut all_nodes: Vec<&AhbNode> = Vec::new();
170        all_nodes.extend(validated_tree.root_fields.iter());
171        for group in &validated_tree.groups {
172            collect_nodes_depth_first(group, &mut all_nodes);
173        }
174
175        // For each segment tag, collect all distinct mig_numbers the workflow
176        // defines. Tags with a single mig variant (UNT/UNS/UNZ in MSCONS) let
177        // us fall back to a flat-segment check when assembly didn't claim the
178        // segment; tags with multiple variants (DTM with +92/+93 in UTILMD)
179        // must not, because a different variant's segment may satisfy
180        // `is_field_present` while the rule's specific variant is absent.
181        let mut tag_migs: HashMap<String, HashSet<&str>> = HashMap::new();
182        for node in &all_nodes {
183            if let Some(ref m) = node.rule.mig_number {
184                tag_migs
185                    .entry(extract_segment_id(&node.rule.segment_path))
186                    .or_default()
187                    .insert(m.as_str());
188            }
189        }
190        for rule in &validated_tree.unmatched_rules {
191            if let Some(ref m) = rule.mig_number {
192                tag_migs
193                    .entry(extract_segment_id(&rule.segment_path))
194                    .or_default()
195                    .insert(m.as_str());
196            }
197        }
198
199        // Evaluate root-level fields (not inside any group) with message-wide scope.
200        for node in &validated_tree.root_fields {
201            evaluate_node(
202                node,
203                &ctx,
204                &expr_eval,
205                &self.evaluator,
206                validated_tree.ub_definitions,
207                &tag_migs,
208                None,
209                &mut report,
210            );
211        }
212
213        // Walk top-level groups with per-instance scope. `validated_tree.groups`
214        // is a flat Vec<AhbGroupNode> where each entry corresponds to one
215        // group repetition; compute the instance index by counting preceding
216        // entries with the same group_id.
217        let mut instance_counter: HashMap<&str, usize> = HashMap::new();
218        for group in &validated_tree.groups {
219            let instance_index = *instance_counter
220                .entry(group.group_id)
221                .and_modify(|c| *c += 1)
222                .or_insert(0);
223
224            let group_path_storage = [group.group_id];
225            // Build a scoped context without consuming `ctx` (used again for
226            // the synthetic-workflow checks at the end of this method).
227            let scoped_ctx = ctx.with_scope(GroupScope {
228                group_path: &group_path_storage,
229                instance_index,
230            });
231
232            walk_group_instance(
233                group,
234                &scoped_ctx,
235                &expr_eval,
236                &self.evaluator,
237                validated_tree.ub_definitions,
238                &tag_migs,
239                instance_index,
240                &mut report,
241            );
242        }
243
244        // Evaluate rules whose mig_number wasn't matched to any tree node.
245        // These represent entirely-absent group variants (e.g., NAD+MS when
246        // only NAD+MR is present). Use the flat is_field_present/is_group_variant_absent
247        // logic which correctly distinguishes mandatory-but-missing variants from
248        // optional-and-absent ones.
249        for field in &validated_tree.unmatched_rules {
250            if should_skip_for_parent_group(field, &expr_eval, &ctx, validated_tree.ub_definitions)
251            {
252                continue;
253            }
254
255            let (condition_result, _) = expr_eval.evaluate_status_detailed_with_ub(
256                &field.ahb_status,
257                &ctx,
258                validated_tree.ub_definitions,
259            );
260
261            if matches!(condition_result, ConditionResult::True)
262                && is_mandatory_status(&field.ahb_status)
263                && !is_field_present(&ctx, field)
264                && !is_group_variant_absent(&ctx, field)
265            {
266                let mut issue = ValidationIssue::new(
267                    Severity::Error,
268                    IssueKind::MissingRequiredField {
269                        field_name: field.name.clone(),
270                    },
271                )
272                .with_field_path(&field.segment_path)
273                .with_rule(&field.ahb_status);
274                if let Some(first_code) = field.codes.first() {
275                    issue.expected_value = Some(first_code.value.clone());
276                }
277                issue = attach_field_position(issue, field);
278                report.add_issue(issue);
279            }
280        }
281
282        // Run cross-field code validation and package cardinality checks.
283        // These operate on flat field rules (no resolved_value needed),
284        // so we reconstruct a lightweight workflow from the tree's rules.
285        if matches!(level, ValidationLevel::Full) {
286            let mut all_fields: Vec<AhbFieldRule> = Vec::new();
287            for node in &all_nodes {
288                all_fields.push(node.rule.clone());
289            }
290            for rule in &validated_tree.unmatched_rules {
291                all_fields.push((*rule).clone());
292            }
293            let synthetic_workflow = AhbWorkflow {
294                pruefidentifikator: validated_tree.pruefidentifikator.to_string(),
295                description: String::new(),
296                communication_direction: None,
297                fields: all_fields,
298                ub_definitions: validated_tree.ub_definitions.clone(),
299            };
300            self.validate_codes_cross_field(&synthetic_workflow, &ctx, &mut report);
301            self.validate_package_cardinality(&synthetic_workflow, &ctx, &mut report);
302        }
303
304        report
305    }
306
307    /// Validate AHB conditions for each field in the workflow.
308    fn validate_conditions(
309        &self,
310        workflow: &AhbWorkflow,
311        ctx: &EvaluationContext,
312        report: &mut ValidationReport,
313    ) {
314        let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
315
316        for field in &workflow.fields {
317            // Skip if parent group condition is not met (False or Unknown).
318            if should_skip_for_parent_group(field, &expr_eval, ctx, &workflow.ub_definitions) {
319                continue;
320            }
321
322            // Evaluate the AHB status condition expression, collecting
323            // which specific condition IDs are unknown when the result is Unknown.
324            let (condition_result, unknown_ids) = expr_eval.evaluate_status_detailed_with_ub(
325                &field.ahb_status,
326                ctx,
327                &workflow.ub_definitions,
328            );
329
330            match condition_result {
331                ConditionResult::True => {
332                    // Condition is met - field is required/applicable
333                    if is_mandatory_status(&field.ahb_status)
334                        && !is_field_present(ctx, field)
335                        && !is_group_variant_absent(ctx, field)
336                    {
337                        let mut issue = ValidationIssue::new(
338                            Severity::Error,
339                            IssueKind::MissingRequiredField {
340                                field_name: field.name.clone(),
341                            },
342                        )
343                        .with_field_path(&field.segment_path)
344                        .with_rule(&field.ahb_status);
345                        // Store the first expected code for BO4E path disambiguation.
346                        // E.g., DTM+93's code "93" helps resolve SG4/DTM/C507/2005
347                        // to Prozessdaten.gueltigBis vs gueltigAb.
348                        if let Some(first_code) = field.codes.first() {
349                            issue.expected_value = Some(first_code.value.clone());
350                        }
351                        report.add_issue(issue);
352                    }
353                }
354                ConditionResult::False => {
355                    // Condition not met.
356                    // If the field has a mandatory prefix (X, Muss) AND the field is
357                    // present, the condition is a format/value constraint that the
358                    // value violates. E.g., "X [UB1]" where UB1 checks timezone
359                    // format — the field is required, but its value is wrong.
360                    if is_mandatory_status(&field.ahb_status) && is_field_present(ctx, field) {
361                        report.add_issue(
362                            ValidationIssue::new(
363                                Severity::Error,
364                                IssueKind::FieldConditionNotSatisfied {
365                                    field_name: field.name.clone(),
366                                },
367                            )
368                            .with_field_path(&field.segment_path)
369                            .with_rule(&field.ahb_status),
370                        );
371                    }
372                }
373                ConditionResult::Unknown => {
374                    // Partition unknown IDs into three categories:
375                    // 1. External: require an external provider (business context)
376                    // 2. Undetermined: implemented but returned Unknown (data not present)
377                    // 3. Missing: not implemented in evaluator at all
378                    let mut external_ids = Vec::new();
379                    let mut undetermined_ids = Vec::new();
380                    let mut missing_ids = Vec::new();
381                    for id in unknown_ids {
382                        if self.evaluator.is_external(id) {
383                            external_ids.push(id);
384                        } else if self.evaluator.is_known(id) {
385                            undetermined_ids.push(id);
386                        } else {
387                            missing_ids.push(id);
388                        }
389                    }
390
391                    report.add_issue(
392                        ValidationIssue::new(
393                            Severity::Info,
394                            IssueKind::ConditionUnknown {
395                                field_name: field.name.clone(),
396                                unresolved: UnresolvedConditions {
397                                    external: external_ids,
398                                    undetermined: undetermined_ids,
399                                    missing: missing_ids,
400                                },
401                            },
402                        )
403                        .with_field_path(&field.segment_path)
404                        .with_rule(&field.ahb_status),
405                    );
406                }
407            }
408        }
409
410        // Cross-field code validation: aggregate allowed codes across all field
411        // rules sharing the same segment path, then check each segment instance
412        // against the combined set. This avoids false positives from per-field
413        // validation (e.g., NAD/3035 with [MS] for sender and [MR] for receiver).
414        self.validate_codes_cross_field(workflow, ctx, report);
415
416        // Package cardinality post-processing: check that the count of codes
417        // present from each package group falls within [min..max] bounds.
418        self.validate_package_cardinality(workflow, ctx, report);
419    }
420
421    /// Validate package cardinality constraints across all field rules.
422    ///
423    /// Scans each code's `ahb_status` for `[NP_min..max]` package references,
424    /// groups codes by `(segment_path, package_id)`, counts how many of the
425    /// package's codes are actually present in the segment, and emits AHB006
426    /// if the count falls outside `[min..max]`.
427    fn validate_package_cardinality(
428        &self,
429        workflow: &AhbWorkflow,
430        ctx: &EvaluationContext,
431        report: &mut ValidationReport,
432    ) {
433        // Collect package groups: (segment_path, package_id) -> (min, max, Vec<code_value>)
434        // Also store (element_index, component_index) for looking up the value in the segment.
435        struct PackageGroup {
436            min: u32,
437            max: u32,
438            code_values: Vec<String>,
439            element_index: usize,
440            component_index: usize,
441        }
442
443        // Key: (segment_path, mig_number, package_id).
444        //
445        // `mig_number` scopes the package to a specific MIG segment variant.
446        // Example: in MSCONS SG10, STS+Z32 and STS+Z40 are separate variants
447        // that both declare `[4P0..1]` on component 9013, but they refer to
448        // disjoint packages (Z32's Statusanlaß codes vs Z40's). Without the
449        // mig_number key, both variants' codes merge into one "super-package"
450        // and any real message using both STS types fails cardinality.
451        let mut groups: HashMap<(String, Option<String>, u32), PackageGroup> = HashMap::new();
452
453        let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
454
455        for field in &workflow.fields {
456            let el_idx = field.element_index.unwrap_or(0);
457            let comp_idx = field.component_index.unwrap_or(0);
458
459            // Skip package codes whose parent group condition is False or Unknown.
460            if should_skip_for_parent_group(field, &expr_eval, ctx, &workflow.ub_definitions) {
461                continue;
462            }
463
464            for code in &field.codes {
465                // Parse the code's ahb_status for Package nodes
466                if let Ok(Some(expr)) = ConditionParser::parse(&code.ahb_status) {
467                    // Walk the AST to find Package nodes
468                    let mut packages = Vec::new();
469                    collect_packages(&expr, &mut packages);
470
471                    for (pkg_id, pkg_min, pkg_max) in packages {
472                        let key = (field.segment_path.clone(), field.mig_number.clone(), pkg_id);
473                        let group = groups.entry(key).or_insert_with(|| PackageGroup {
474                            min: pkg_min,
475                            max: pkg_max,
476                            code_values: Vec::new(),
477                            element_index: el_idx,
478                            component_index: comp_idx,
479                        });
480                        // Update min/max in case different codes specify different bounds
481                        // (should be the same, but take the intersection to be safe)
482                        group.min = group.min.max(pkg_min);
483                        group.max = group.max.min(pkg_max);
484                        group.code_values.push(code.value.clone());
485                    }
486                }
487            }
488        }
489
490        // For each package group, count present codes and check bounds.
491        //
492        // Package cardinality like `[4P0..1]` limits the number of codes that
493        // may appear *within a single group instance*, not message-wide. When
494        // a navigator is available and the field has a group path, iterate
495        // per-instance and check each independently; otherwise fall back to
496        // the message-wide count (used by flat-segment test fixtures).
497        for ((seg_path, mig_number, pkg_id), group) in &groups {
498            let segment_id = extract_segment_id(seg_path);
499
500            // Deduplicate code values: the synthetic workflow built from a
501            // validated tree duplicates rules once per matched node, which
502            // otherwise inflates the error message's code list.
503            let mut unique_codes: Vec<&str> =
504                group.code_values.iter().map(|s| s.as_str()).collect();
505            unique_codes.sort_unstable();
506            unique_codes.dedup();
507            let code_set: HashSet<&str> = unique_codes.iter().copied().collect();
508
509            let group_path_str = extract_group_path_key(seg_path);
510            let group_path: Vec<&str> = if group_path_str.is_empty() {
511                Vec::new()
512            } else {
513                group_path_str.split('/').collect()
514            };
515
516            let min = group.min as usize;
517            let max = group.max as usize;
518
519            let per_instance_counts: Option<Vec<usize>> =
520                match (ctx.navigator, group_path.is_empty()) {
521                    (Some(nav), false) => {
522                        let instance_count = nav.group_instance_count(&group_path);
523                        if instance_count == 0 {
524                            None
525                        } else {
526                            // Restrict the check to instances whose variant
527                            // declares the rule's mig_number. Without this, a
528                            // rule from one SG variant (e.g. PID 55218 SEQ
529                            // mig=00171 for sg8_z45_z84) is counted against
530                            // sibling variants (sg8_z01 with SEQ mig=00115)
531                            // and fires "0 codes present" on those. Rules
532                            // with no mig_number stay unscoped.
533                            Some(
534                                (0..instance_count)
535                                    .filter(|i| match mig_number.as_deref() {
536                                        Some(m) => nav.instance_has_mig_number(&group_path, *i, m),
537                                        None => true,
538                                    })
539                                    .map(|i| {
540                                        nav.find_segments_in_group(&segment_id, &group_path, i)
541                                            .iter()
542                                            .filter_map(|seg| {
543                                                seg.elements
544                                                    .get(group.element_index)
545                                                    .and_then(|e| e.get(group.component_index))
546                                                    .filter(|v| !v.is_empty())
547                                                    .cloned()
548                                            })
549                                            .filter(|v| code_set.contains(v.as_str()))
550                                            .count()
551                                    })
552                                    .collect(),
553                            )
554                        }
555                    }
556                    _ => None,
557                };
558
559            let counts: Vec<usize> = per_instance_counts.unwrap_or_else(|| {
560                let segments = ctx.find_segments(&segment_id);
561                let count = segments
562                    .iter()
563                    .filter_map(|seg| {
564                        seg.elements
565                            .get(group.element_index)
566                            .and_then(|e| e.get(group.component_index))
567                            .filter(|v| !v.is_empty())
568                            .map(|s| s.as_str())
569                    })
570                    .filter(|v| code_set.contains(v))
571                    .count();
572                vec![count]
573            });
574
575            // Emit one issue per out-of-bounds instance. Dedup by count so a
576            // single message-wide overflow doesn't produce N copies of the
577            // same error for repeated groups.
578            let mut reported_counts: HashSet<usize> = HashSet::new();
579            for present_count in counts {
580                if (present_count < min || present_count > max)
581                    && reported_counts.insert(present_count)
582                {
583                    report.add_issue(
584                        ValidationIssue::new(
585                            Severity::Error,
586                            IssueKind::PackageCardinality {
587                                package_id: pkg_id.to_string(),
588                                present: present_count,
589                                min,
590                                max,
591                                codes: unique_codes.iter().map(|s| s.to_string()).collect(),
592                            },
593                        )
594                        .with_field_path(seg_path)
595                        .with_expected(format!("{}..{}", group.min, group.max))
596                        .with_actual(present_count.to_string()),
597                    );
598                }
599            }
600        }
601    }
602
603    /// Validate qualifier codes by aggregating allowed values across field rules.
604    ///
605    /// When a group navigator is available, codes are grouped by segment path
606    /// (e.g., `SG2/NAD/3035` → `{MS, MR}`) and segments are looked up within
607    /// the specific group, giving precise per-group validation.
608    ///
609    /// Without a navigator, falls back to grouping by segment tag and unioning
610    /// all codes across groups to avoid cross-group false positives.
611    ///
612    /// Only validates simple qualifier paths (`[SG/]*/SEG/ELEMENT`) where the
613    /// code is in `element[0][0]`. Composite paths are skipped.
614    fn validate_codes_cross_field(
615        &self,
616        workflow: &AhbWorkflow,
617        ctx: &EvaluationContext,
618        report: &mut ValidationReport,
619    ) {
620        if ctx.navigator.is_some() {
621            self.validate_codes_group_scoped(workflow, ctx, report);
622        } else {
623            self.validate_codes_tag_scoped(workflow, ctx, report);
624        }
625    }
626
627    /// Group-scoped code validation: group codes by path, use navigator to
628    /// find segments within each group, check against that group's codes only.
629    fn validate_codes_group_scoped(
630        &self,
631        workflow: &AhbWorkflow,
632        ctx: &EvaluationContext,
633        report: &mut ValidationReport,
634    ) {
635        let by_loc = partition_codes_by_mig(workflow);
636        let known_qualifiers = global_qualifiers_by_tag(&by_loc);
637        let nav = ctx.navigator.unwrap();
638
639        for ((group_key, tag), migs) in &by_loc {
640            let field_path = if group_key.is_empty() {
641                format!("{tag}/qualifier")
642            } else {
643                format!("{group_key}/{tag}/qualifier")
644            };
645
646            let group_path: Vec<&str> = if group_key.is_empty() {
647                Vec::new()
648            } else {
649                group_key.split('/').collect()
650            };
651
652            let tag_qualifiers = known_qualifiers.get(tag);
653
654            if group_path.is_empty() {
655                Self::validate_segments_per_mig(
656                    &ctx.find_segments(tag),
657                    migs,
658                    tag_qualifiers,
659                    tag,
660                    &field_path,
661                    report,
662                );
663            } else {
664                let instance_count = nav.group_instance_count(&group_path);
665                for i in 0..instance_count {
666                    let owned = nav.find_segments_in_group(tag, &group_path, i);
667                    let refs: Vec<&OwnedSegment> = owned.iter().collect();
668                    Self::validate_segments_per_mig(
669                        &refs,
670                        migs,
671                        tag_qualifiers,
672                        tag,
673                        &field_path,
674                        report,
675                    );
676                }
677            }
678        }
679    }
680
681    /// Fallback: no navigator — validate segments flat by tag. Still partitions
682    /// codes by mig_number so segments are checked only against codes from their
683    /// matching mig.
684    fn validate_codes_tag_scoped(
685        &self,
686        workflow: &AhbWorkflow,
687        ctx: &EvaluationContext,
688        report: &mut ValidationReport,
689    ) {
690        let by_loc = partition_codes_by_mig(workflow);
691        let known_qualifiers = global_qualifiers_by_tag(&by_loc);
692        // Merge entries that share a tag (tag-scoped ignores group_key).
693        let mut by_tag: HashMap<String, HashMap<Option<String>, MigCodeBucket>> = HashMap::new();
694        for ((_group_key, tag), migs) in by_loc {
695            let merged = by_tag.entry(tag).or_default();
696            for (mig_key, bucket) in migs {
697                let entry = merged.entry(mig_key).or_default();
698                if entry.qualifier_position.is_none() {
699                    entry.qualifier_position = bucket.qualifier_position;
700                }
701                if entry.qualifier_position == bucket.qualifier_position {
702                    entry.qualifier_values.extend(&bucket.qualifier_values);
703                }
704                for (pos, codes) in bucket.codes {
705                    entry.codes.entry(pos).or_default().extend(codes);
706                }
707            }
708        }
709
710        for (tag, migs) in &by_tag {
711            let field_path = format!("{tag}/qualifier");
712            Self::validate_segments_per_mig(
713                &ctx.find_segments(tag),
714                migs,
715                known_qualifiers.get(tag),
716                tag,
717                &field_path,
718                report,
719            );
720        }
721    }
722
723    /// For each segment, match it to a mig_number via its primary qualifier and
724    /// validate only that mig's code constraints.
725    ///
726    /// `tag_qualifiers` is the set of all qualifier values this tag uses anywhere
727    /// in the workflow, keyed by position. When no mig at the current location
728    /// matches a segment, but the segment's qualifier IS valid somewhere else for
729    /// this tag, we skip it (the segment belongs to another location that's
730    /// handled separately). Only truly unknown qualifier values are flagged.
731    fn validate_segments_per_mig(
732        segments: &[&OwnedSegment],
733        migs: &HashMap<Option<String>, MigCodeBucket>,
734        tag_qualifiers: Option<&HashMap<(usize, usize), HashSet<String>>>,
735        tag: &str,
736        field_path: &str,
737        report: &mut ValidationReport,
738    ) {
739        for seg in segments {
740            match match_segment_to_mig(seg, migs) {
741                Some(bucket) => {
742                    for ((el, c), allowed) in &bucket.codes {
743                        if allowed.is_empty() {
744                            continue;
745                        }
746                        Self::check_segments_against_codes(
747                            vec![*seg],
748                            allowed,
749                            tag,
750                            *el,
751                            *c,
752                            field_path,
753                            report,
754                        );
755                    }
756                }
757                None => {
758                    let Some(qualifiers) = tag_qualifiers else {
759                        continue;
760                    };
761                    // Find a qualifier position where this segment has a value.
762                    // If the value matches any known qualifier for this tag (at any
763                    // location), the segment belongs elsewhere — skip. Otherwise
764                    // report an invalid qualifier against the union at this position.
765                    for ((el, c), allowed) in qualifiers {
766                        let Some(actual) = seg
767                            .elements
768                            .get(*el)
769                            .and_then(|e| e.get(*c))
770                            .filter(|v| !v.is_empty())
771                            .map(|s| s.as_str())
772                        else {
773                            continue;
774                        };
775                        if allowed.iter().any(|v| v == actual) {
776                            // Qualifier is valid at some location — skip here.
777                            break;
778                        }
779                        // Truly unknown qualifier value — report against this
780                        // location's bucket union if it has one.
781                        let bucket_values: HashSet<&str> = migs
782                            .values()
783                            .filter(|b| b.qualifier_position == Some((*el, *c)))
784                            .flat_map(|b| b.qualifier_values.iter().copied())
785                            .collect();
786                        if !bucket_values.is_empty() {
787                            Self::check_segments_against_codes(
788                                vec![*seg],
789                                &bucket_values,
790                                tag,
791                                *el,
792                                *c,
793                                field_path,
794                                report,
795                            );
796                            break;
797                        }
798                    }
799                }
800            }
801        }
802    }
803
804    /// Check a list of segments' qualifier at the given element/component index against allowed codes.
805    fn check_segments_against_codes(
806        segments: Vec<&OwnedSegment>,
807        allowed_codes: &HashSet<&str>,
808        _tag: &str,
809        el_idx: usize,
810        comp_idx: usize,
811        field_path: &str,
812        report: &mut ValidationReport,
813    ) {
814        for segment in segments {
815            if let Some(code_value) = segment
816                .elements
817                .get(el_idx)
818                .and_then(|e| e.get(comp_idx))
819                .filter(|v| !v.is_empty())
820            {
821                if !allowed_codes.contains(code_value.as_str()) {
822                    let mut sorted_codes: Vec<&str> = allowed_codes.iter().copied().collect();
823                    sorted_codes.sort_unstable();
824                    let kind = IssueKind::CodeNotAllowedForPid {
825                        value: code_value.to_string(),
826                        allowed: sorted_codes.iter().map(|s| (*s).to_string()).collect(),
827                    };
828                    report.add_issue(
829                        ValidationIssue::new(Severity::Error, kind)
830                            .with_field_path(field_path)
831                            .with_actual(code_value)
832                            .with_expected(sorted_codes.join(", ")),
833                    );
834                }
835            }
836        }
837    }
838}
839
840/// Check if the parent group's conditional status evaluates to False or Unknown.
841///
842/// Returns `true` if the field should be skipped (parent group condition not met).
843/// Only evaluates when `parent_group_ahb_status` contains condition brackets `[`.
844/// Simple statuses like "Kann" or "Muss" pass through (return `false`).
845fn should_skip_for_parent_group<E: ConditionEvaluator>(
846    field: &AhbFieldRule,
847    expr_eval: &ConditionExprEvaluator<E>,
848    ctx: &EvaluationContext,
849    ub_definitions: &BTreeMap<String, ConditionExpr>,
850) -> bool {
851    if let Some(ref group_status) = field.parent_group_ahb_status {
852        if group_status.contains('[') {
853            let result = expr_eval.evaluate_status_with_ub(group_status, ctx, ub_definitions);
854            return matches!(result, ConditionResult::False | ConditionResult::Unknown);
855        }
856    }
857    false
858}
859
860/// Check if a field's required segment instance is present.
861///
862/// For fields with qualifier codes (e.g., `SG2/NAD/3035` with `[MR]`),
863/// checks that a segment with one of those qualifier values exists.
864/// Otherwise just checks that any segment with the tag is present.
865///
866/// This prevents false negatives where `NAD+MS` is present but the
867/// validator incorrectly says NAD "exists" when `NAD+MR` is missing.
868fn is_field_present(ctx: &EvaluationContext, field: &AhbFieldRule) -> bool {
869    let segment_id = extract_segment_id(&field.segment_path);
870
871    // When a field has known codes, check for a segment containing one of those
872    // specific values at the right element/component position. This applies to both
873    // simple qualifier paths (NAD/3035) and composite paths (CCI/C240/7037) —
874    // without this, a CCI segment from a different group variant (e.g., CCI+Z61)
875    // would falsely satisfy the check for CCI with C240/7037 codes Z15/Z18.
876    if !field.codes.is_empty() {
877        if let (Some(el_idx), Some(comp_idx)) = (field.element_index, field.component_index) {
878            let required_codes: Vec<&str> = field.codes.iter().map(|c| c.value.as_str()).collect();
879            let matching = ctx.find_segments(&segment_id);
880            return matching.iter().any(|seg| {
881                seg.elements
882                    .get(el_idx)
883                    .and_then(|e| e.get(comp_idx))
884                    .is_some_and(|v| required_codes.contains(&v.as_str()))
885            });
886        }
887        // Fallback for fields with codes but no element/component indices:
888        // use simple qualifier check if it's a qualifier-style path.
889        if is_qualifier_field(&field.segment_path) {
890            let required_codes: Vec<&str> = field.codes.iter().map(|c| c.value.as_str()).collect();
891            let el_idx = field.element_index.unwrap_or(0);
892            let comp_idx = field.component_index.unwrap_or(0);
893            let matching = ctx.find_segments(&segment_id);
894            return matching.iter().any(|seg| {
895                seg.elements
896                    .get(el_idx)
897                    .and_then(|e| e.get(comp_idx))
898                    .is_some_and(|v| required_codes.contains(&v.as_str()))
899            });
900        }
901    }
902
903    ctx.has_segment(&segment_id)
904}
905
906/// Check if the group variant for a field is absent from the message.
907///
908/// Uses tree-based logic:
909/// 1. **Group entirely absent**: If the group path has 0 instances, the
910///    whole group is absent and its children aren't required.
911/// 2. **Optional group variant absent**: If the parent group is optional
912///    ("Kann") and the specific qualifier variant isn't present, the variant
913///    is absent. E.g., SG5 "Kann" with LOC+Z17 — if no SG5 instance has
914///    LOC+Z17, all fields under that variant are skipped.
915///
916/// Returns `false` (not absent) when:
917/// - The field has no group prefix (e.g., `NAD/3035`)
918/// - No navigator is available (can't determine group presence)
919/// - The parent group is mandatory and has instances
920fn is_group_variant_absent(ctx: &EvaluationContext, field: &AhbFieldRule) -> bool {
921    let group_path: Vec<&str> = field
922        .segment_path
923        .split('/')
924        .take_while(|p| p.starts_with("SG"))
925        .collect();
926
927    if group_path.is_empty() {
928        return false;
929    }
930
931    let nav = match ctx.navigator {
932        Some(nav) => nav,
933        None => return false,
934    };
935
936    let instance_count = nav.group_instance_count(&group_path);
937
938    // Case 1: group entirely absent — only skip if the group is NOT mandatory.
939    // When a mandatory group (parent_group_ahb_status contains "Muss" or "X")
940    // has 0 instances, the group's absence is itself the error, so we should
941    // NOT skip field validation — let the field-level check report the missing field.
942    if instance_count == 0 {
943        let is_group_mandatory = field
944            .parent_group_ahb_status
945            .as_deref()
946            .is_some_and(is_mandatory_status);
947        if !is_group_mandatory {
948            return true;
949        }
950        // Mandatory group with 0 instances → don't suppress field errors
951        return false;
952    }
953
954    // Case 2: group has instances, but the specific qualifier variant may be absent.
955    // Only applies when the parent group is optional ("Kann") — mandatory groups
956    // ("Muss", "X") require all their qualifier variants to be present.
957    if let Some(ref group_status) = field.parent_group_ahb_status {
958        if !is_mandatory_status(group_status) && !group_status.contains('[') {
959            // Parent group is unconditionally optional (e.g., "Kann").
960            // Check if the field's qualifier variant is present in any instance.
961            if !field.codes.is_empty() && is_qualifier_field(&field.segment_path) {
962                let segment_id = extract_segment_id(&field.segment_path);
963                let required_codes: Vec<&str> =
964                    field.codes.iter().map(|c| c.value.as_str()).collect();
965
966                let any_instance_has_qualifier = (0..instance_count).any(|i| {
967                    nav.find_segments_in_group(&segment_id, &group_path, i)
968                        .iter()
969                        .any(|seg| {
970                            seg.elements
971                                .first()
972                                .and_then(|e| e.first())
973                                .is_some_and(|v| required_codes.contains(&v.as_str()))
974                        })
975                });
976
977                if !any_instance_has_qualifier {
978                    return true; // optional group variant absent
979                }
980            }
981        }
982    }
983
984    // Case 3: non-entry segment absent from all group instances.
985    // E.g., SG10 has QTY (entry) + optional STS segments. If no STS appears
986    // in any SG10 instance but the entry segment (QTY) is present, fields
987    // under STS are not required.
988    //
989    // We check `has_any_segment_in_group` to confirm the group instance is
990    // genuinely populated (proving our target segment is a non-entry optional
991    // one) vs a navigator that simply can't resolve segments.
992    let segment_id = extract_segment_id(&field.segment_path);
993    let segment_absent_from_all = (0..instance_count).all(|i| {
994        nav.find_segments_in_group(&segment_id, &group_path, i)
995            .is_empty()
996    });
997    if segment_absent_from_all {
998        let group_has_other_segments =
999            (0..instance_count).any(|i| nav.has_any_segment_in_group(&group_path, i));
1000        if group_has_other_segments {
1001            return true;
1002        }
1003    }
1004
1005    false
1006}
1007
1008/// Recursively collect all AhbNodes from a group node in depth-first order.
1009fn collect_nodes_depth_first<'a, 'b>(group: &'b AhbGroupNode<'a>, out: &mut Vec<&'b AhbNode<'a>>) {
1010    out.extend(group.fields.iter());
1011    for child in &group.children {
1012        collect_nodes_depth_first(child, out);
1013    }
1014}
1015
1016/// Evaluate a single `AhbNode` with the given (possibly scoped) context,
1017/// emitting AHB001/AHB003/AHB005 issues as appropriate. When `instance_index`
1018/// is `Some`, errors are tagged with it via `with_instance_index`.
1019#[allow(clippy::too_many_arguments)]
1020fn evaluate_node<E: ConditionEvaluator>(
1021    node: &AhbNode,
1022    ctx: &EvaluationContext,
1023    expr_eval: &ConditionExprEvaluator<E>,
1024    evaluator: &E,
1025    ub_definitions: &BTreeMap<String, ConditionExpr>,
1026    tag_migs: &HashMap<String, HashSet<&str>>,
1027    instance_index: Option<usize>,
1028    report: &mut ValidationReport,
1029) {
1030    let field = node.rule;
1031
1032    // Create a field-scoped context with resolved value from the tree node.
1033    let node_ctx = ctx.with_resolved(node.value, node.segment_elements);
1034
1035    // Skip if parent group condition is not met (False or Unknown).
1036    if should_skip_for_parent_group(field, expr_eval, ctx, ub_definitions) {
1037        return;
1038    }
1039
1040    // Evaluate the field's AHB status condition using the node context
1041    // (WITH resolved_value set).
1042    let (condition_result, unknown_ids) =
1043        expr_eval.evaluate_status_detailed_with_ub(&field.ahb_status, &node_ctx, ub_definitions);
1044
1045    match condition_result {
1046        ConditionResult::True => {
1047            // Condition is met -- field is required/applicable.
1048            //
1049            // Normally rely on the tree's resolved value. The exception:
1050            // if assembly didn't claim the segment at all (value AND
1051            // segment_elements both absent) AND the tag has exactly one
1052            // mig variant in this PID, a flat-segment hit means the
1053            // segment IS present and assembly just failed to pick it up.
1054            // Don't double-report — structure diagnostics already warn.
1055            if is_mandatory_status(&field.ahb_status) && node.value.is_none() {
1056                let tag = extract_segment_id(&field.segment_path);
1057                let single_variant_present = node.segment_elements.is_none()
1058                    && tag_migs.get(&tag).is_some_and(|ms| ms.len() == 1)
1059                    && is_field_present(ctx, field);
1060                // Suppress missing-field errors when the containing segment
1061                // is optional (Kann/Soll) and absent from the instance.
1062                // "X" on a Kann segment's sub-element means "required IF
1063                // segment present"; the segment's absence is legal.
1064                let segment_optional_and_absent = node.segment_elements.is_none()
1065                    && field
1066                        .segment_ahb_status
1067                        .as_deref()
1068                        .is_some_and(is_optional_segment_status);
1069                if !single_variant_present && !segment_optional_and_absent {
1070                    let mut issue = ValidationIssue::new(
1071                        Severity::Error,
1072                        IssueKind::MissingRequiredField {
1073                            field_name: field.name.clone(),
1074                        },
1075                    )
1076                    .with_field_path(&field.segment_path)
1077                    .with_rule(&field.ahb_status);
1078                    if let Some(first_code) = field.codes.first() {
1079                        issue.expected_value = Some(first_code.value.clone());
1080                    }
1081                    if let Some(idx) = instance_index {
1082                        issue = issue.with_instance_index(idx);
1083                    }
1084                    if let Some(num) = node.matched_segment_number {
1085                        issue = issue.with_position(crate::SegmentPosition {
1086                            segment_number: num,
1087                            byte_offset: 0,
1088                            message_number: 1,
1089                        });
1090                    }
1091                    issue = attach_field_position(issue, field);
1092                    report.add_issue(issue);
1093                }
1094            }
1095        }
1096        ConditionResult::False => {
1097            // Condition not met. If mandatory AND value is present,
1098            // the value violates the condition constraint.
1099            if is_mandatory_status(&field.ahb_status) && node.value.is_some() {
1100                let mut issue = ValidationIssue::new(
1101                    Severity::Error,
1102                    IssueKind::FieldConditionNotSatisfied {
1103                        field_name: field.name.clone(),
1104                    },
1105                )
1106                .with_field_path(&field.segment_path)
1107                .with_rule(&field.ahb_status);
1108                if let Some(idx) = instance_index {
1109                    issue = issue.with_instance_index(idx);
1110                }
1111                if let Some(num) = node.matched_segment_number {
1112                    issue = issue.with_position(crate::SegmentPosition {
1113                        segment_number: num,
1114                        byte_offset: 0,
1115                        message_number: 1,
1116                    });
1117                }
1118                issue = attach_field_position(issue, field);
1119                report.add_issue(issue);
1120            }
1121        }
1122        ConditionResult::Unknown => {
1123            // Partition unknown IDs into external / undetermined / missing.
1124            let mut external_ids = Vec::new();
1125            let mut undetermined_ids = Vec::new();
1126            let mut missing_ids = Vec::new();
1127            for id in unknown_ids {
1128                if evaluator.is_external(id) {
1129                    external_ids.push(id);
1130                } else if evaluator.is_known(id) {
1131                    undetermined_ids.push(id);
1132                } else {
1133                    missing_ids.push(id);
1134                }
1135            }
1136
1137            let mut issue = ValidationIssue::new(
1138                Severity::Info,
1139                IssueKind::ConditionUnknown {
1140                    field_name: field.name.clone(),
1141                    unresolved: UnresolvedConditions {
1142                        external: external_ids,
1143                        undetermined: undetermined_ids,
1144                        missing: missing_ids,
1145                    },
1146                },
1147            )
1148            .with_field_path(&field.segment_path)
1149            .with_rule(&field.ahb_status);
1150            if let Some(idx) = instance_index {
1151                issue = issue.with_instance_index(idx);
1152            }
1153            if let Some(num) = node.matched_segment_number {
1154                issue = issue.with_position(crate::SegmentPosition {
1155                    segment_number: num,
1156                    byte_offset: 0,
1157                    message_number: 1,
1158                });
1159            }
1160            issue = attach_field_position(issue, field);
1161            report.add_issue(issue);
1162        }
1163    }
1164}
1165
1166/// Attach UCD `S011` position info derived from the AHB field rule's
1167/// `element_index` / `component_index`. Both are 0-based array indices
1168/// in the parsed segment; CONTRL DE0098 / DE0104 are 1-based and
1169/// DE0098 counts the segment identifier as position 1.
1170fn attach_field_position(issue: ValidationIssue, field: &AhbFieldRule) -> ValidationIssue {
1171    match field.element_index {
1172        Some(el) => {
1173            let element_pos = (el as u32) + 2;
1174            let component_pos = field.component_index.map(|c| (c as u32) + 1);
1175            issue.with_field_position(element_pos, component_pos)
1176        }
1177        None => issue,
1178    }
1179}
1180
1181/// Recursively walk a group instance (and its children) evaluating every
1182/// `AhbNode` with the same scoped context. Child groups inherit the parent's
1183/// scope for this initial implementation — deeper nested scoping (computing
1184/// a child's own instance_index among siblings within the parent) can be
1185/// added later if conditions actually differentiate on nested SGx.
1186// TODO(path-c-nested): when an AHB condition references "in dieser SG10"
1187// (nested within SG8), we'd need to compute SG10's own instance_index
1188// among its siblings under the current SG8 and push a deeper GroupScope
1189// path like ["SG4", "SG8", "SG10"]. No condition in the current UTILMD
1190// codegen drives on nested SGx scope directly, so this is YAGNI for now.
1191#[allow(clippy::too_many_arguments)]
1192fn walk_group_instance<E: ConditionEvaluator>(
1193    group: &AhbGroupNode,
1194    scoped_ctx: &EvaluationContext,
1195    expr_eval: &ConditionExprEvaluator<E>,
1196    evaluator: &E,
1197    ub_definitions: &BTreeMap<String, ConditionExpr>,
1198    tag_migs: &HashMap<String, HashSet<&str>>,
1199    instance_index: usize,
1200    report: &mut ValidationReport,
1201) {
1202    for node in &group.fields {
1203        evaluate_node(
1204            node,
1205            scoped_ctx,
1206            expr_eval,
1207            evaluator,
1208            ub_definitions,
1209            tag_migs,
1210            Some(instance_index),
1211            report,
1212        );
1213    }
1214    for child in &group.children {
1215        walk_group_instance(
1216            child,
1217            scoped_ctx,
1218            expr_eval,
1219            evaluator,
1220            ub_definitions,
1221            tag_migs,
1222            instance_index,
1223            report,
1224        );
1225    }
1226}
1227
1228/// Recursively collect all Package nodes from a condition expression tree.
1229fn collect_packages(expr: &ConditionExpr, out: &mut Vec<(u32, u32, u32)>) {
1230    match expr {
1231        ConditionExpr::Package { id, min, max } => {
1232            out.push((*id, *min, *max));
1233        }
1234        ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
1235            for e in exprs {
1236                collect_packages(e, out);
1237            }
1238        }
1239        ConditionExpr::Xor(left, right) => {
1240            collect_packages(left, out);
1241            collect_packages(right, out);
1242        }
1243        ConditionExpr::Not(inner) => {
1244            collect_packages(inner, out);
1245        }
1246        ConditionExpr::Ref(_) => {}
1247    }
1248}
1249
1250/// Check if an AHB status is mandatory (Muss or X prefix).
1251fn is_mandatory_status(status: &str) -> bool {
1252    let trimmed = status.trim();
1253    trimmed.starts_with("Muss") || trimmed.starts_with('X')
1254}
1255
1256/// Check if a segment-level AHB status indicates an optional segment.
1257///
1258/// "Kann" / "Kann [x]" — always optional: absence is legal.
1259/// "Soll" / "Soll [x]" — should-be: absence is tolerated (not an error).
1260/// "Muss" (with or without conditions) — required; absence must be reported.
1261///
1262/// Used to suppress AHB001 missing-field errors on sub-elements of a segment
1263/// that is both optional at segment-level and absent from the assembled
1264/// instance: "X" on such a sub-element means "required IF segment present".
1265fn is_optional_segment_status(status: &str) -> bool {
1266    let trimmed = status.trim();
1267    trimmed.starts_with("Kann") || trimmed.starts_with("Soll")
1268}
1269
1270/// Check if a field path points to a simple qualifier element (element[0] of the segment).
1271///
1272/// Returns `true` for paths like `[SG/]*/SEG/ELEMENT` where the data element is
1273/// directly under the segment (no composite wrapper). These fields have their code
1274/// in `element[0][0]` and can be validated.
1275///
1276/// Also accepts composite paths like `SEG/COMPOSITE/ELEMENT` (e.g., `STS/C556/9013`).
1277/// The `element_index` and `component_index` on the field rule carry the exact
1278/// position, so composites can be checked just like simple elements.
1279fn is_qualifier_field(path: &str) -> bool {
1280    let parts: Vec<&str> = path.split('/').filter(|p| !p.starts_with("SG")).collect();
1281    // [SEG, ELEMENT] for simple data elements; [SEG, COMPOSITE, ELEMENT] for composites.
1282    matches!(parts.len(), 2 | 3)
1283}
1284
1285/// Per-mig code constraints at one (group_key, tag) location.
1286///
1287/// A bucket represents one mig_number (or the `None` bucket when rules lack
1288/// mig numbers). Each bucket tracks its qualifier position and the set of
1289/// valid qualifier values there, so multiple single-code rules under the same
1290/// mig key (e.g. two distinct NAD qualifiers both under mig=None in a test
1291/// fixture) all contribute as valid qualifier matches.
1292#[derive(Default)]
1293struct MigCodeBucket<'a> {
1294    /// The position (element_index, component_index) treated as the discriminator
1295    /// for this bucket — the first single-code required field seen in the workflow.
1296    qualifier_position: Option<(usize, usize)>,
1297    /// Valid qualifier values at `qualifier_position`. Empty if no single-code
1298    /// required field exists for this bucket.
1299    qualifier_values: HashSet<&'a str>,
1300    /// Allowed codes for this bucket, keyed by `(element_index, component_index)`.
1301    /// Includes the qualifier position itself.
1302    codes: HashMap<(usize, usize), HashSet<&'a str>>,
1303}
1304
1305/// Partition workflow code rules by `(group_key, tag, mig_number)`. Without this
1306/// split, codes from multiple migs sharing a tag (e.g. STS+7 mig 00035 and
1307/// STS+E01 mig 00036 in PID 55018) get unioned and every segment is checked
1308/// against codes that only apply to one mig.
1309fn partition_codes_by_mig(
1310    workflow: &AhbWorkflow,
1311) -> HashMap<(String, String), HashMap<Option<String>, MigCodeBucket<'_>>> {
1312    let mut out: HashMap<(String, String), HashMap<Option<String>, MigCodeBucket>> = HashMap::new();
1313    for field in &workflow.fields {
1314        if field.codes.is_empty() || !is_qualifier_field(&field.segment_path) {
1315            continue;
1316        }
1317        let tag = extract_segment_id(&field.segment_path);
1318        let group_key = extract_group_path_key(&field.segment_path);
1319        let mig = field.mig_number.clone();
1320        let el = field.element_index.unwrap_or(0);
1321        let c = field.component_index.unwrap_or(0);
1322
1323        let bucket = out
1324            .entry((group_key, tag))
1325            .or_default()
1326            .entry(mig)
1327            .or_default();
1328
1329        // A code is "allowed for this PID" whenever its AHB status declares it
1330        // usable — whether unconditionally (`X`, `Muss`) or under a condition
1331        // (`X [35]`, `X [35] ∨ ([32] ∧ [77])`). Matching only the exact string
1332        // `"X"` drops every conditionally-allowed code, causing COD002 false
1333        // positives on fields like MSCONS QTY 6063 (220 unconditional, 67/Z18
1334        // conditional).
1335        let required: Vec<&str> = field
1336            .codes
1337            .iter()
1338            .filter(|code| code.ahb_status.starts_with('X') || code.ahb_status.starts_with("Muss"))
1339            .map(|code| code.value.as_str())
1340            .collect();
1341
1342        // The first code-bearing field locks in the qualifier position; any
1343        // further required codes at that position contribute additional valid
1344        // qualifier values. A bucket whose entry qualifier permits multiple
1345        // codes (e.g. PID 55035 SG8/RFF mig=00075 allows {Z31, Z39}) must be
1346        // matchable by any of them — without this, segments with Z39 fall
1347        // through to the union-of-other-migs path and produce a false COD002.
1348        if !required.is_empty() {
1349            if bucket.qualifier_position.is_none() {
1350                bucket.qualifier_position = Some((el, c));
1351            }
1352            if bucket.qualifier_position == Some((el, c)) {
1353                bucket.qualifier_values.extend(required.iter().copied());
1354            }
1355        }
1356
1357        for v in required {
1358            bucket.codes.entry((el, c)).or_default().insert(v);
1359        }
1360    }
1361    out
1362}
1363
1364/// For each tag, collect every qualifier value used anywhere in the workflow,
1365/// keyed by qualifier position. This lets code validation distinguish "segment
1366/// has an unknown qualifier value" (truly invalid — report) from "segment has
1367/// a valid qualifier used at a different location than the current bucket"
1368/// (belongs elsewhere — skip at this location).
1369fn global_qualifiers_by_tag(
1370    by_loc: &HashMap<(String, String), HashMap<Option<String>, MigCodeBucket<'_>>>,
1371) -> HashMap<String, HashMap<(usize, usize), HashSet<String>>> {
1372    let mut out: HashMap<String, HashMap<(usize, usize), HashSet<String>>> = HashMap::new();
1373    for ((_group, tag), migs) in by_loc {
1374        let tag_entry = out.entry(tag.clone()).or_default();
1375        for bucket in migs.values() {
1376            if let Some(pos) = bucket.qualifier_position {
1377                tag_entry
1378                    .entry(pos)
1379                    .or_default()
1380                    .extend(bucket.qualifier_values.iter().map(|s| s.to_string()));
1381            }
1382        }
1383    }
1384    out
1385}
1386
1387/// Find the bucket that best matches this segment.
1388///
1389/// A bucket is a candidate if its `qualifier_values` contain the segment's
1390/// value at `qualifier_position`. Among candidates, the one with the most
1391/// additional code positions matching the segment wins. This disambiguates
1392/// migs that share the same primary qualifier but differ on a secondary
1393/// code position (e.g. PID 55035 SG8/PIA variants all use 4347='5' but
1394/// differ on C212/7143={Z12} vs {SRW}).
1395///
1396/// Returns `None` if no bucket's qualifier matches the segment.
1397fn match_segment_to_mig<'a, 'b>(
1398    seg: &OwnedSegment,
1399    migs: &'a HashMap<Option<String>, MigCodeBucket<'b>>,
1400) -> Option<&'a MigCodeBucket<'b>> {
1401    let actual_at = |el: usize, c: usize| -> &str {
1402        seg.elements
1403            .get(el)
1404            .and_then(|e| e.get(c))
1405            .map(|s| s.as_str())
1406            .unwrap_or("")
1407    };
1408
1409    let mut best: Option<&MigCodeBucket> = None;
1410    let mut best_matches = 0usize;
1411
1412    for bucket in migs.values() {
1413        let Some((el, c)) = bucket.qualifier_position else {
1414            continue;
1415        };
1416        if !bucket.qualifier_values.contains(actual_at(el, c)) {
1417            continue;
1418        }
1419        let extra_matches = bucket
1420            .codes
1421            .iter()
1422            .filter(|(pos, _)| **pos != (el, c))
1423            .filter(|((e, k), allowed)| {
1424                let v = actual_at(*e, *k);
1425                !v.is_empty() && allowed.contains(v)
1426            })
1427            .count();
1428        if best.is_none() || extra_matches > best_matches {
1429            best = Some(bucket);
1430            best_matches = extra_matches;
1431        }
1432    }
1433    best
1434}
1435
1436/// Extract the group path prefix from a field path.
1437///
1438/// `"SG2/NAD/3035"` → `"SG2"`, `"SG4/SG12/NAD/3035"` → `"SG4/SG12"`,
1439/// `"NAD/3035"` → `""` (no group prefix).
1440fn extract_group_path_key(path: &str) -> String {
1441    let sg_parts: Vec<&str> = path
1442        .split('/')
1443        .take_while(|p| p.starts_with("SG"))
1444        .collect();
1445    sg_parts.join("/")
1446}
1447
1448/// Extract the segment ID from a field path like "SG2/NAD/C082/3039" -> "NAD".
1449fn extract_segment_id(path: &str) -> String {
1450    for part in path.split('/') {
1451        // Skip segment group identifiers and composite/element identifiers
1452        if part.starts_with("SG") || part.starts_with("C_") || part.starts_with("D_") {
1453            continue;
1454        }
1455        // Return first 3-letter uppercase segment identifier
1456        if part.len() >= 3
1457            && part
1458                .chars()
1459                .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
1460        {
1461            return part.to_string();
1462        }
1463    }
1464    // Fallback: return the last part
1465    path.split('/').next_back().unwrap_or(path).to_string()
1466}
1467
1468/// Validate that the UNT segment count matches the actual number of segments.
1469///
1470/// The UNT segment's first data element (D0074) declares the number of segments
1471/// in the message, counting from UNH to UNT inclusive. This function compares
1472/// that declared value against the actual segment count.
1473///
1474/// Pass per-message segments (from [`MessageChunk::message_segments()`] or
1475/// [`MessageChunk::all_segments()`]), not the full interchange. For multi-message
1476/// interchanges, call once per message.
1477///
1478/// Returns `Some(ValidationIssue)` if there's a mismatch, `None` if correct.
1479pub fn validate_unt_segment_count(segments: &[OwnedSegment]) -> Option<ValidationIssue> {
1480    // Reject multi-message input — counting would be incorrect.
1481    let unh_count = segments.iter().filter(|s| s.id == "UNH").count();
1482    if unh_count > 1 {
1483        return Some(ValidationIssue::new(
1484            Severity::Error,
1485            IssueKind::UntCountNotVerifiable { unh_count },
1486        ));
1487    }
1488
1489    // Find the UNT segment
1490    let unt = segments.iter().rfind(|s| s.id == "UNT")?;
1491    let declared: usize = unt.get_element(0).parse().ok()?;
1492
1493    // Count segments from UNH to UNT inclusive
1494    let actual = segments
1495        .iter()
1496        .filter(|s| s.id != "UNA" && s.id != "UNB" && s.id != "UNZ")
1497        .count();
1498
1499    if declared != actual {
1500        Some(
1501            ValidationIssue::new(
1502                Severity::Error,
1503                IssueKind::UntSegmentCountMismatch { declared, actual },
1504            )
1505            .with_field_path("UNT/0074")
1506            .with_expected(actual.to_string())
1507            .with_actual(declared.to_string()),
1508        )
1509    } else {
1510        None
1511    }
1512}
1513
1514#[cfg(test)]
1515mod tests {
1516    use super::super::codes::ErrorCodes;
1517    use super::super::issue::ValidationCategory;
1518    use super::*;
1519    use crate::display::{EdifactView, IssueNarrator, IssueView, TechnicalNarrator};
1520    use crate::eval::{ConditionResult as CR, NoOpExternalProvider};
1521    use std::collections::HashMap;
1522
1523    /// Narrate an issue the way the EDIFACT view would, for assertions that
1524    /// used to read `.message` before Task 5 removed it.
1525    fn narrate(issue: &ValidationIssue) -> String {
1526        TechnicalNarrator.describe(issue, EdifactView.location(issue).as_deref())
1527    }
1528
1529    /// Mock evaluator for testing the validator.
1530    struct MockEvaluator {
1531        results: HashMap<u32, CR>,
1532    }
1533
1534    impl MockEvaluator {
1535        fn new(results: Vec<(u32, CR)>) -> Self {
1536            Self {
1537                results: results.into_iter().collect(),
1538            }
1539        }
1540
1541        fn all_true(ids: &[u32]) -> Self {
1542            Self::new(ids.iter().map(|&id| (id, CR::True)).collect())
1543        }
1544    }
1545
1546    impl ConditionEvaluator for MockEvaluator {
1547        fn evaluate(&self, condition: u32, _ctx: &EvaluationContext) -> CR {
1548            self.results.get(&condition).copied().unwrap_or(CR::Unknown)
1549        }
1550        fn is_external(&self, _condition: u32) -> bool {
1551            false
1552        }
1553        fn message_type(&self) -> &str {
1554            "UTILMD"
1555        }
1556        fn format_version(&self) -> &str {
1557            "FV2510"
1558        }
1559    }
1560
1561    // === Helper function tests ===
1562
1563    #[test]
1564    fn test_is_mandatory_status() {
1565        assert!(is_mandatory_status("Muss"));
1566        assert!(is_mandatory_status("Muss [182] ∧ [152]"));
1567        assert!(is_mandatory_status("X"));
1568        assert!(is_mandatory_status("X [567]"));
1569        assert!(!is_mandatory_status("Soll [1]"));
1570        assert!(!is_mandatory_status("Kann [1]"));
1571        assert!(!is_mandatory_status(""));
1572    }
1573
1574    #[test]
1575    fn test_extract_segment_id_simple() {
1576        assert_eq!(extract_segment_id("NAD"), "NAD");
1577    }
1578
1579    #[test]
1580    fn test_extract_segment_id_with_sg_prefix() {
1581        assert_eq!(extract_segment_id("SG2/NAD/C082/3039"), "NAD");
1582    }
1583
1584    #[test]
1585    fn test_extract_segment_id_nested_sg() {
1586        assert_eq!(extract_segment_id("SG4/SG8/SEQ/C286/6350"), "SEQ");
1587    }
1588
1589    // === Validator tests with mock data ===
1590
1591    #[test]
1592    fn test_validate_missing_mandatory_field() {
1593        let evaluator = MockEvaluator::all_true(&[182, 152]);
1594        let validator = EdifactValidator::new(evaluator);
1595        let external = NoOpExternalProvider;
1596
1597        let workflow = AhbWorkflow {
1598            pruefidentifikator: "11001".to_string(),
1599            description: "Test".to_string(),
1600            communication_direction: None,
1601            fields: vec![AhbFieldRule {
1602                segment_path: "SG2/NAD/C082/3039".to_string(),
1603                name: "MP-ID des MSB".to_string(),
1604                ahb_status: "Muss [182] ∧ [152]".to_string(),
1605                codes: vec![],
1606                parent_group_ahb_status: None,
1607                segment_ahb_status: None,
1608                ..Default::default()
1609            }],
1610            ub_definitions: BTreeMap::new(),
1611        };
1612
1613        // Validate with no segments
1614        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1615
1616        // Should have an error for missing mandatory field
1617        assert!(!report.is_valid());
1618        let errors: Vec<_> = report.errors().collect();
1619        assert_eq!(errors.len(), 1);
1620        assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
1621        assert!(narrate(errors[0]).contains("MP-ID des MSB"));
1622    }
1623
1624    #[test]
1625    fn test_validate_condition_false_no_error() {
1626        // When condition evaluates to False, field is not required
1627        let evaluator = MockEvaluator::new(vec![(182, CR::True), (152, CR::False)]);
1628        let validator = EdifactValidator::new(evaluator);
1629        let external = NoOpExternalProvider;
1630
1631        let workflow = AhbWorkflow {
1632            pruefidentifikator: "11001".to_string(),
1633            description: "Test".to_string(),
1634            communication_direction: None,
1635            fields: vec![AhbFieldRule {
1636                segment_path: "NAD".to_string(),
1637                name: "Partnerrolle".to_string(),
1638                ahb_status: "Muss [182] ∧ [152]".to_string(),
1639                codes: vec![],
1640                parent_group_ahb_status: None,
1641                segment_ahb_status: None,
1642                ..Default::default()
1643            }],
1644            ub_definitions: BTreeMap::new(),
1645        };
1646
1647        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1648
1649        // Condition is false, so field is not required - no error
1650        assert!(report.is_valid());
1651    }
1652
1653    #[test]
1654    fn test_validate_condition_unknown_adds_info() {
1655        // When condition is Unknown, add an info-level note
1656        let evaluator = MockEvaluator::new(vec![(182, CR::True)]);
1657        // 152 is not registered -> Unknown
1658        let validator = EdifactValidator::new(evaluator);
1659        let external = NoOpExternalProvider;
1660
1661        let workflow = AhbWorkflow {
1662            pruefidentifikator: "11001".to_string(),
1663            description: "Test".to_string(),
1664            communication_direction: None,
1665            fields: vec![AhbFieldRule {
1666                segment_path: "NAD".to_string(),
1667                name: "Partnerrolle".to_string(),
1668                ahb_status: "Muss [182] ∧ [152]".to_string(),
1669                codes: vec![],
1670                parent_group_ahb_status: None,
1671                segment_ahb_status: None,
1672                ..Default::default()
1673            }],
1674            ub_definitions: BTreeMap::new(),
1675        };
1676
1677        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1678
1679        // Should be valid (Unknown is not an error) but have an info issue
1680        assert!(report.is_valid());
1681        let infos: Vec<_> = report.infos().collect();
1682        assert_eq!(infos.len(), 1);
1683        assert_eq!(infos[0].code(), ErrorCodes::CONDITION_UNKNOWN);
1684    }
1685
1686    #[test]
1687    fn test_validate_structure_level_skips_conditions() {
1688        let evaluator = MockEvaluator::all_true(&[182, 152]);
1689        let validator = EdifactValidator::new(evaluator);
1690        let external = NoOpExternalProvider;
1691
1692        let workflow = AhbWorkflow {
1693            pruefidentifikator: "11001".to_string(),
1694            description: "Test".to_string(),
1695            communication_direction: None,
1696            fields: vec![AhbFieldRule {
1697                segment_path: "NAD".to_string(),
1698                name: "Partnerrolle".to_string(),
1699                ahb_status: "Muss [182] ∧ [152]".to_string(),
1700                codes: vec![],
1701                parent_group_ahb_status: None,
1702                segment_ahb_status: None,
1703                ..Default::default()
1704            }],
1705            ub_definitions: BTreeMap::new(),
1706        };
1707
1708        // With Structure level, conditions are not checked
1709        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Structure);
1710
1711        // No AHB errors because conditions were not evaluated
1712        assert!(report.is_valid());
1713        assert_eq!(report.by_category(ValidationCategory::Ahb).count(), 0);
1714    }
1715
1716    #[test]
1717    fn test_validate_empty_workflow_no_condition_errors() {
1718        let evaluator = MockEvaluator::all_true(&[]);
1719        let validator = EdifactValidator::new(evaluator);
1720        let external = NoOpExternalProvider;
1721
1722        let empty_workflow = AhbWorkflow {
1723            pruefidentifikator: String::new(),
1724            description: String::new(),
1725            communication_direction: None,
1726            fields: vec![],
1727            ub_definitions: BTreeMap::new(),
1728        };
1729
1730        let report = validator.validate(&[], &empty_workflow, &external, ValidationLevel::Full);
1731
1732        assert!(report.is_valid());
1733    }
1734
1735    #[test]
1736    fn test_validate_bare_muss_always_required() {
1737        let evaluator = MockEvaluator::new(vec![]);
1738        let validator = EdifactValidator::new(evaluator);
1739        let external = NoOpExternalProvider;
1740
1741        let workflow = AhbWorkflow {
1742            pruefidentifikator: "55001".to_string(),
1743            description: "Test".to_string(),
1744            communication_direction: Some("NB an LF".to_string()),
1745            fields: vec![AhbFieldRule {
1746                segment_path: "SG2/NAD/3035".to_string(),
1747                name: "Partnerrolle".to_string(),
1748                ahb_status: "Muss".to_string(), // No conditions
1749                codes: vec![],
1750                parent_group_ahb_status: None,
1751                segment_ahb_status: None,
1752                ..Default::default()
1753            }],
1754            ub_definitions: BTreeMap::new(),
1755        };
1756
1757        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1758
1759        // Bare "Muss" with no conditions -> unconditionally required -> missing = error
1760        assert!(!report.is_valid());
1761        assert_eq!(report.error_count(), 1);
1762    }
1763
1764    #[test]
1765    fn test_validate_x_status_is_mandatory() {
1766        let evaluator = MockEvaluator::new(vec![]);
1767        let validator = EdifactValidator::new(evaluator);
1768        let external = NoOpExternalProvider;
1769
1770        let workflow = AhbWorkflow {
1771            pruefidentifikator: "55001".to_string(),
1772            description: "Test".to_string(),
1773            communication_direction: None,
1774            fields: vec![AhbFieldRule {
1775                segment_path: "DTM".to_string(),
1776                name: "Datum".to_string(),
1777                ahb_status: "X".to_string(),
1778                codes: vec![],
1779                parent_group_ahb_status: None,
1780                segment_ahb_status: None,
1781                ..Default::default()
1782            }],
1783            ub_definitions: BTreeMap::new(),
1784        };
1785
1786        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1787
1788        assert!(!report.is_valid());
1789        let errors: Vec<_> = report.errors().collect();
1790        assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
1791    }
1792
1793    #[test]
1794    fn test_validate_soll_not_mandatory() {
1795        let evaluator = MockEvaluator::new(vec![]);
1796        let validator = EdifactValidator::new(evaluator);
1797        let external = NoOpExternalProvider;
1798
1799        let workflow = AhbWorkflow {
1800            pruefidentifikator: "55001".to_string(),
1801            description: "Test".to_string(),
1802            communication_direction: None,
1803            fields: vec![AhbFieldRule {
1804                segment_path: "DTM".to_string(),
1805                name: "Datum".to_string(),
1806                ahb_status: "Soll".to_string(),
1807                codes: vec![],
1808                parent_group_ahb_status: None,
1809                segment_ahb_status: None,
1810                ..Default::default()
1811            }],
1812            ub_definitions: BTreeMap::new(),
1813        };
1814
1815        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1816
1817        // Soll is not mandatory, so missing is not an error
1818        assert!(report.is_valid());
1819    }
1820
1821    #[test]
1822    fn test_report_includes_metadata() {
1823        let evaluator = MockEvaluator::new(vec![]);
1824        let validator = EdifactValidator::new(evaluator);
1825        let external = NoOpExternalProvider;
1826
1827        let workflow = AhbWorkflow {
1828            pruefidentifikator: "55001".to_string(),
1829            description: String::new(),
1830            communication_direction: None,
1831            fields: vec![],
1832            ub_definitions: BTreeMap::new(),
1833        };
1834
1835        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Full);
1836
1837        assert_eq!(report.format_version.as_deref(), Some("FV2510"));
1838        assert_eq!(report.level, ValidationLevel::Full);
1839        assert_eq!(report.message_type, "UTILMD");
1840        assert_eq!(report.pruefidentifikator.as_deref(), Some("55001"));
1841    }
1842
1843    #[test]
1844    fn test_validate_with_navigator_returns_report() {
1845        let evaluator = MockEvaluator::all_true(&[]);
1846        let validator = EdifactValidator::new(evaluator);
1847        let external = NoOpExternalProvider;
1848        let nav = crate::eval::NoOpGroupNavigator;
1849
1850        let workflow = AhbWorkflow {
1851            pruefidentifikator: "55001".to_string(),
1852            description: "Test".to_string(),
1853            communication_direction: None,
1854            fields: vec![],
1855            ub_definitions: BTreeMap::new(),
1856        };
1857
1858        let report = validator.validate_with_navigator(
1859            &[],
1860            &workflow,
1861            &external,
1862            ValidationLevel::Full,
1863            &nav,
1864        );
1865        assert!(report.is_valid());
1866    }
1867
1868    #[test]
1869    fn test_code_validation_composite_paths_valid_codes() {
1870        // UNH/S009/0065 and UNH/S009/0052 carry codes inside composite S009 at
1871        // element_index=1, component sub_index 0 and 1. Validation should match
1872        // the actual composite positions and find no errors for matching values.
1873        let evaluator = MockEvaluator::new(vec![]);
1874        let validator = EdifactValidator::new(evaluator);
1875        let external = NoOpExternalProvider;
1876
1877        let unh_segment = OwnedSegment {
1878            id: "UNH".to_string(),
1879            elements: vec![
1880                vec!["ALEXANDE951842".to_string()],
1881                vec![
1882                    "UTILMD".to_string(),
1883                    "D".to_string(),
1884                    "11A".to_string(),
1885                    "UN".to_string(),
1886                    "S2.1".to_string(),
1887                ],
1888            ],
1889            segment_number: 1,
1890        };
1891
1892        let workflow = AhbWorkflow {
1893            pruefidentifikator: "55001".to_string(),
1894            description: "Test".to_string(),
1895            communication_direction: None,
1896            fields: vec![
1897                AhbFieldRule {
1898                    segment_path: "UNH/S009/0065".to_string(),
1899                    name: "Nachrichtentyp".to_string(),
1900                    ahb_status: "X".to_string(),
1901                    codes: vec![AhbCodeRule {
1902                        value: "UTILMD".to_string(),
1903                        description: "Stammdaten".to_string(),
1904                        ahb_status: "X".to_string(),
1905                    }],
1906                    parent_group_ahb_status: None,
1907                    segment_ahb_status: None,
1908                    element_index: Some(1),
1909                    component_index: Some(0),
1910                    ..Default::default()
1911                },
1912                AhbFieldRule {
1913                    segment_path: "UNH/S009/0052".to_string(),
1914                    name: "Version".to_string(),
1915                    ahb_status: "X".to_string(),
1916                    codes: vec![AhbCodeRule {
1917                        value: "D".to_string(),
1918                        description: "Draft".to_string(),
1919                        ahb_status: "X".to_string(),
1920                    }],
1921                    parent_group_ahb_status: None,
1922                    segment_ahb_status: None,
1923                    element_index: Some(1),
1924                    component_index: Some(1),
1925                    ..Default::default()
1926                },
1927            ],
1928            ub_definitions: BTreeMap::new(),
1929        };
1930
1931        let report = validator.validate(
1932            &[unh_segment],
1933            &workflow,
1934            &external,
1935            ValidationLevel::Conditions,
1936        );
1937
1938        let code_errors: Vec<_> = report
1939            .by_category(ValidationCategory::Code)
1940            .filter(|i| i.severity == Severity::Error)
1941            .collect();
1942        assert!(
1943            code_errors.is_empty(),
1944            "Expected no code errors when composite values match allowed codes, got: {:?}",
1945            code_errors
1946        );
1947    }
1948
1949    #[test]
1950    fn test_code_validation_partitions_by_mig_number() {
1951        // PID 55018 has two STS migs: 00035 (Statuskategorie=7) constrains
1952        // element 2 to {E03}; 00036 (Statuskategorie=E01) has no constraint there.
1953        // Unioning codes across migs would flag A99 in STS+E01 — it must not.
1954        let evaluator = MockEvaluator::new(vec![]);
1955        let validator = EdifactValidator::new(evaluator);
1956        let external = NoOpExternalProvider;
1957
1958        let sts_7 = OwnedSegment {
1959            id: "STS".to_string(),
1960            elements: vec![
1961                vec!["7".to_string()],
1962                vec![String::new()],
1963                vec!["GH02".to_string()],
1964                vec!["ZW4".to_string()],
1965            ],
1966            segment_number: 1,
1967        };
1968        let sts_e01 = OwnedSegment {
1969            id: "STS".to_string(),
1970            elements: vec![
1971                vec!["E01".to_string()],
1972                vec![String::new()],
1973                vec!["A99".to_string(), "E_0614".to_string()],
1974            ],
1975            segment_number: 2,
1976        };
1977
1978        let workflow = AhbWorkflow {
1979            pruefidentifikator: "55018".to_string(),
1980            description: "Test".to_string(),
1981            communication_direction: None,
1982            fields: vec![
1983                // Mig 00035: qualifier 7 at element 0, E03 required at element 2.
1984                AhbFieldRule {
1985                    segment_path: "SG4/STS/C601/9015".to_string(),
1986                    name: "Statuskategorie".to_string(),
1987                    ahb_status: "X".to_string(),
1988                    codes: vec![AhbCodeRule {
1989                        value: "7".to_string(),
1990                        description: "Transaktionsgrund".to_string(),
1991                        ahb_status: "X".to_string(),
1992                    }],
1993                    parent_group_ahb_status: None,
1994                    segment_ahb_status: None,
1995                    element_index: Some(0),
1996                    component_index: Some(0),
1997                    mig_number: Some("00035".to_string()),
1998                },
1999                AhbFieldRule {
2000                    segment_path: "SG4/STS/C556/9013".to_string(),
2001                    name: "Statusanlaß".to_string(),
2002                    ahb_status: "X".to_string(),
2003                    codes: vec![AhbCodeRule {
2004                        value: "E03".to_string(),
2005                        description: "Transaktionsgrund".to_string(),
2006                        ahb_status: "X".to_string(),
2007                    }],
2008                    parent_group_ahb_status: None,
2009                    segment_ahb_status: None,
2010                    element_index: Some(2),
2011                    component_index: Some(0),
2012                    mig_number: Some("00035".to_string()),
2013                },
2014                // Mig 00036: qualifier E01 at element 0, no codes at element 2.
2015                AhbFieldRule {
2016                    segment_path: "SG4/STS/C601/9015".to_string(),
2017                    name: "Statuskategorie".to_string(),
2018                    ahb_status: "X".to_string(),
2019                    codes: vec![AhbCodeRule {
2020                        value: "E01".to_string(),
2021                        description: "Antwort".to_string(),
2022                        ahb_status: "X".to_string(),
2023                    }],
2024                    parent_group_ahb_status: None,
2025                    segment_ahb_status: None,
2026                    element_index: Some(0),
2027                    component_index: Some(0),
2028                    mig_number: Some("00036".to_string()),
2029                },
2030            ],
2031            ub_definitions: BTreeMap::new(),
2032        };
2033
2034        let report = validator.validate(
2035            &[sts_7, sts_e01],
2036            &workflow,
2037            &external,
2038            ValidationLevel::Conditions,
2039        );
2040
2041        let code_errors: Vec<_> = report
2042            .by_category(ValidationCategory::Code)
2043            .filter(|i| {
2044                i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
2045            })
2046            .collect();
2047        assert_eq!(
2048            code_errors.len(),
2049            1,
2050            "Expected one COD002 (for GH02 only), got: {:?}",
2051            code_errors
2052        );
2053        assert_eq!(code_errors[0].actual_value.as_deref(), Some("GH02"));
2054    }
2055
2056    #[test]
2057    fn test_code_validation_composite_paths_detects_invalid_code() {
2058        // Mirror of the STS+7 case that motivated composite validation: a bad code
2059        // at element_index=2, component_index=0 must produce COD002.
2060        let evaluator = MockEvaluator::new(vec![]);
2061        let validator = EdifactValidator::new(evaluator);
2062        let external = NoOpExternalProvider;
2063
2064        let sts_segment = OwnedSegment {
2065            id: "STS".to_string(),
2066            elements: vec![
2067                vec!["7".to_string()],
2068                vec![String::new()],
2069                vec!["GH02".to_string()],
2070                vec!["ZW4".to_string()],
2071            ],
2072            segment_number: 1,
2073        };
2074
2075        let workflow = AhbWorkflow {
2076            pruefidentifikator: "55018".to_string(),
2077            description: "Test".to_string(),
2078            communication_direction: None,
2079            fields: vec![AhbFieldRule {
2080                segment_path: "SG4/STS/C556/9013".to_string(),
2081                name: "Statusanlaß".to_string(),
2082                ahb_status: "X".to_string(),
2083                codes: vec![AhbCodeRule {
2084                    value: "E03".to_string(),
2085                    description: "Transaktionsgrund".to_string(),
2086                    ahb_status: "X".to_string(),
2087                }],
2088                parent_group_ahb_status: None,
2089                segment_ahb_status: None,
2090                element_index: Some(2),
2091                component_index: Some(0),
2092                ..Default::default()
2093            }],
2094            ub_definitions: BTreeMap::new(),
2095        };
2096
2097        let report = validator.validate(
2098            &[sts_segment],
2099            &workflow,
2100            &external,
2101            ValidationLevel::Conditions,
2102        );
2103
2104        let code_errors: Vec<_> = report
2105            .by_category(ValidationCategory::Code)
2106            .filter(|i| {
2107                i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
2108            })
2109            .collect();
2110        assert_eq!(
2111            code_errors.len(),
2112            1,
2113            "Expected COD002 for GH02, got: {:?}",
2114            code_errors
2115        );
2116        assert_eq!(code_errors[0].actual_value.as_deref(), Some("GH02"));
2117    }
2118
2119    #[test]
2120    fn test_cross_field_code_validation_valid_qualifiers() {
2121        // NAD/3035 has separate field rules: [MS] for sender, [MR] for receiver.
2122        // Cross-field validation unions them → {MS, MR}. Both segments are valid.
2123        let evaluator = MockEvaluator::new(vec![]);
2124        let validator = EdifactValidator::new(evaluator);
2125        let external = NoOpExternalProvider;
2126
2127        let nad_ms = OwnedSegment {
2128            id: "NAD".to_string(),
2129            elements: vec![vec!["MS".to_string()]],
2130            segment_number: 4,
2131        };
2132        let nad_mr = OwnedSegment {
2133            id: "NAD".to_string(),
2134            elements: vec![vec!["MR".to_string()]],
2135            segment_number: 5,
2136        };
2137
2138        let workflow = AhbWorkflow {
2139            pruefidentifikator: "55001".to_string(),
2140            description: "Test".to_string(),
2141            communication_direction: None,
2142            fields: vec![
2143                AhbFieldRule {
2144                    segment_path: "SG2/NAD/3035".to_string(),
2145                    name: "Absender".to_string(),
2146                    ahb_status: "X".to_string(),
2147                    codes: vec![AhbCodeRule {
2148                        value: "MS".to_string(),
2149                        description: "Absender".to_string(),
2150                        ahb_status: "X".to_string(),
2151                    }],
2152                    parent_group_ahb_status: None,
2153                    segment_ahb_status: None,
2154                    ..Default::default()
2155                },
2156                AhbFieldRule {
2157                    segment_path: "SG2/NAD/3035".to_string(),
2158                    name: "Empfaenger".to_string(),
2159                    ahb_status: "X".to_string(),
2160                    codes: vec![AhbCodeRule {
2161                        value: "MR".to_string(),
2162                        description: "Empfaenger".to_string(),
2163                        ahb_status: "X".to_string(),
2164                    }],
2165                    parent_group_ahb_status: None,
2166                    segment_ahb_status: None,
2167                    ..Default::default()
2168                },
2169            ],
2170            ub_definitions: BTreeMap::new(),
2171        };
2172
2173        let report = validator.validate(
2174            &[nad_ms, nad_mr],
2175            &workflow,
2176            &external,
2177            ValidationLevel::Conditions,
2178        );
2179
2180        let code_errors: Vec<_> = report
2181            .by_category(ValidationCategory::Code)
2182            .filter(|i| i.severity == Severity::Error)
2183            .collect();
2184        assert!(
2185            code_errors.is_empty(),
2186            "Expected no code errors for valid qualifiers, got: {:?}",
2187            code_errors
2188        );
2189    }
2190
2191    #[test]
2192    fn test_cross_field_code_validation_catches_invalid_qualifier() {
2193        // NAD+MT is not in the allowed set {MS, MR} → should produce COD002.
2194        let evaluator = MockEvaluator::new(vec![]);
2195        let validator = EdifactValidator::new(evaluator);
2196        let external = NoOpExternalProvider;
2197
2198        let nad_ms = OwnedSegment {
2199            id: "NAD".to_string(),
2200            elements: vec![vec!["MS".to_string()]],
2201            segment_number: 4,
2202        };
2203        let nad_mt = OwnedSegment {
2204            id: "NAD".to_string(),
2205            elements: vec![vec!["MT".to_string()]], // invalid
2206            segment_number: 5,
2207        };
2208
2209        let workflow = AhbWorkflow {
2210            pruefidentifikator: "55001".to_string(),
2211            description: "Test".to_string(),
2212            communication_direction: None,
2213            fields: vec![
2214                AhbFieldRule {
2215                    segment_path: "SG2/NAD/3035".to_string(),
2216                    name: "Absender".to_string(),
2217                    ahb_status: "X".to_string(),
2218                    codes: vec![AhbCodeRule {
2219                        value: "MS".to_string(),
2220                        description: "Absender".to_string(),
2221                        ahb_status: "X".to_string(),
2222                    }],
2223                    parent_group_ahb_status: None,
2224                    segment_ahb_status: None,
2225                    ..Default::default()
2226                },
2227                AhbFieldRule {
2228                    segment_path: "SG2/NAD/3035".to_string(),
2229                    name: "Empfaenger".to_string(),
2230                    ahb_status: "X".to_string(),
2231                    codes: vec![AhbCodeRule {
2232                        value: "MR".to_string(),
2233                        description: "Empfaenger".to_string(),
2234                        ahb_status: "X".to_string(),
2235                    }],
2236                    parent_group_ahb_status: None,
2237                    segment_ahb_status: None,
2238                    ..Default::default()
2239                },
2240            ],
2241            ub_definitions: BTreeMap::new(),
2242        };
2243
2244        let report = validator.validate(
2245            &[nad_ms, nad_mt],
2246            &workflow,
2247            &external,
2248            ValidationLevel::Conditions,
2249        );
2250
2251        let code_errors: Vec<_> = report
2252            .by_category(ValidationCategory::Code)
2253            .filter(|i| i.severity == Severity::Error)
2254            .collect();
2255        assert_eq!(code_errors.len(), 1, "Expected one COD002 error for MT");
2256        assert!(narrate(code_errors[0]).contains("MT"));
2257        assert!(narrate(code_errors[0]).contains("MR"));
2258        assert!(narrate(code_errors[0]).contains("MS"));
2259    }
2260
2261    #[test]
2262    fn test_cross_field_code_validation_unions_across_groups() {
2263        // SG2/NAD/3035 allows {MS, MR}, SG4/SG12/NAD/3035 allows {Z04, Z09}.
2264        // Since find_segments("NAD") returns all NADs, codes must be unioned
2265        // by tag: {MS, MR, Z04, Z09}. NAD+MT should be caught, all others pass.
2266        let evaluator = MockEvaluator::new(vec![]);
2267        let validator = EdifactValidator::new(evaluator);
2268        let external = NoOpExternalProvider;
2269
2270        let segments = vec![
2271            OwnedSegment {
2272                id: "NAD".to_string(),
2273                elements: vec![vec!["MS".to_string()]],
2274                segment_number: 3,
2275            },
2276            OwnedSegment {
2277                id: "NAD".to_string(),
2278                elements: vec![vec!["MR".to_string()]],
2279                segment_number: 4,
2280            },
2281            OwnedSegment {
2282                id: "NAD".to_string(),
2283                elements: vec![vec!["Z04".to_string()]],
2284                segment_number: 20,
2285            },
2286            OwnedSegment {
2287                id: "NAD".to_string(),
2288                elements: vec![vec!["Z09".to_string()]],
2289                segment_number: 21,
2290            },
2291            OwnedSegment {
2292                id: "NAD".to_string(),
2293                elements: vec![vec!["MT".to_string()]], // invalid
2294                segment_number: 22,
2295            },
2296        ];
2297
2298        let workflow = AhbWorkflow {
2299            pruefidentifikator: "55001".to_string(),
2300            description: "Test".to_string(),
2301            communication_direction: None,
2302            fields: vec![
2303                AhbFieldRule {
2304                    segment_path: "SG2/NAD/3035".to_string(),
2305                    name: "Absender".to_string(),
2306                    ahb_status: "X".to_string(),
2307                    codes: vec![AhbCodeRule {
2308                        value: "MS".to_string(),
2309                        description: "Absender".to_string(),
2310                        ahb_status: "X".to_string(),
2311                    }],
2312                    parent_group_ahb_status: None,
2313                    segment_ahb_status: None,
2314                    ..Default::default()
2315                },
2316                AhbFieldRule {
2317                    segment_path: "SG2/NAD/3035".to_string(),
2318                    name: "Empfaenger".to_string(),
2319                    ahb_status: "X".to_string(),
2320                    codes: vec![AhbCodeRule {
2321                        value: "MR".to_string(),
2322                        description: "Empfaenger".to_string(),
2323                        ahb_status: "X".to_string(),
2324                    }],
2325                    parent_group_ahb_status: None,
2326                    segment_ahb_status: None,
2327                    ..Default::default()
2328                },
2329                AhbFieldRule {
2330                    segment_path: "SG4/SG12/NAD/3035".to_string(),
2331                    name: "Anschlussnutzer".to_string(),
2332                    ahb_status: "X".to_string(),
2333                    codes: vec![AhbCodeRule {
2334                        value: "Z04".to_string(),
2335                        description: "Anschlussnutzer".to_string(),
2336                        ahb_status: "X".to_string(),
2337                    }],
2338                    parent_group_ahb_status: None,
2339                    segment_ahb_status: None,
2340                    ..Default::default()
2341                },
2342                AhbFieldRule {
2343                    segment_path: "SG4/SG12/NAD/3035".to_string(),
2344                    name: "Korrespondenzanschrift".to_string(),
2345                    ahb_status: "X".to_string(),
2346                    codes: vec![AhbCodeRule {
2347                        value: "Z09".to_string(),
2348                        description: "Korrespondenzanschrift".to_string(),
2349                        ahb_status: "X".to_string(),
2350                    }],
2351                    parent_group_ahb_status: None,
2352                    segment_ahb_status: None,
2353                    ..Default::default()
2354                },
2355            ],
2356            ub_definitions: BTreeMap::new(),
2357        };
2358
2359        let report =
2360            validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
2361
2362        let code_errors: Vec<_> = report
2363            .by_category(ValidationCategory::Code)
2364            .filter(|i| i.severity == Severity::Error)
2365            .collect();
2366        assert_eq!(
2367            code_errors.len(),
2368            1,
2369            "Expected exactly one COD002 error for MT, got: {:?}",
2370            code_errors
2371        );
2372        assert!(narrate(code_errors[0]).contains("MT"));
2373    }
2374
2375    #[test]
2376    fn test_cross_field_code_validation_accepts_conditionally_allowed_codes() {
2377        // QTY 6063 in MSCONS PID 13025 has three AHB-allowed codes:
2378        //   220  ahb_status "X"                        (unconditional)
2379        //   67   ahb_status "X [35] ∨ ([32] ∧ [77])"   (conditional)
2380        //   Z18  ahb_status "X [35]"                    (conditional)
2381        // All three are "allowed codes for this PID" — they differ only in when
2382        // they may be used. The COD002 "code not allowed for this PID" check
2383        // must accept the conditional ones too, otherwise real-world load-profile
2384        // messages using QTY+67 (Ersatzwert) are incorrectly rejected.
2385        let evaluator = MockEvaluator::new(vec![]);
2386        let validator = EdifactValidator::new(evaluator);
2387        let external = NoOpExternalProvider;
2388
2389        let qty_67 = OwnedSegment {
2390            id: "QTY".to_string(),
2391            elements: vec![vec!["67".to_string(), "0.185".to_string()]],
2392            segment_number: 10,
2393        };
2394
2395        let workflow = AhbWorkflow {
2396            pruefidentifikator: "13025".to_string(),
2397            description: "Test".to_string(),
2398            communication_direction: None,
2399            fields: vec![AhbFieldRule {
2400                segment_path: "SG5/SG6/SG9/SG10/QTY/qualifier".to_string(),
2401                name: "Menge, Qualifier".to_string(),
2402                ahb_status: "X".to_string(),
2403                codes: vec![
2404                    AhbCodeRule {
2405                        value: "220".to_string(),
2406                        description: "Wahrer Wert".to_string(),
2407                        ahb_status: "X".to_string(),
2408                    },
2409                    AhbCodeRule {
2410                        value: "67".to_string(),
2411                        description: "Ersatzwert".to_string(),
2412                        ahb_status: "X [35] ∨ ([32] ∧ [77])".to_string(),
2413                    },
2414                    AhbCodeRule {
2415                        value: "Z18".to_string(),
2416                        description: "Vorläufiger Wert".to_string(),
2417                        ahb_status: "X [35]".to_string(),
2418                    },
2419                ],
2420                parent_group_ahb_status: None,
2421                segment_ahb_status: None,
2422                element_index: Some(0),
2423                component_index: Some(0),
2424                ..Default::default()
2425            }],
2426            ub_definitions: BTreeMap::new(),
2427        };
2428
2429        let report =
2430            validator.validate(&[qty_67], &workflow, &external, ValidationLevel::Conditions);
2431
2432        let code_errors: Vec<_> = report
2433            .by_category(ValidationCategory::Code)
2434            .filter(|i| i.severity == Severity::Error)
2435            .collect();
2436        assert!(
2437            code_errors.is_empty(),
2438            "QTY+67 should be accepted because code '67' is conditionally allowed for this PID (X [35] ∨ ([32] ∧ [77])). Got errors: {:?}",
2439            code_errors
2440        );
2441    }
2442
2443    #[test]
2444    fn test_is_qualifier_field_simple_paths() {
2445        assert!(is_qualifier_field("NAD/3035"));
2446        assert!(is_qualifier_field("SG2/NAD/3035"));
2447        assert!(is_qualifier_field("SG4/SG8/SEQ/6350"));
2448        assert!(is_qualifier_field("LOC/3227"));
2449    }
2450
2451    #[test]
2452    fn test_is_qualifier_field_composite_paths() {
2453        // Composite paths (3 parts after SG stripping) are accepted so composite-level
2454        // codes like STS/C556/9013 get validated. The field rule's element_index and
2455        // component_index carry the exact position.
2456        assert!(is_qualifier_field("UNH/S009/0065"));
2457        assert!(is_qualifier_field("NAD/C082/3039"));
2458        assert!(is_qualifier_field("SG2/NAD/C082/3039"));
2459        assert!(is_qualifier_field("SG4/STS/C556/9013"));
2460    }
2461
2462    #[test]
2463    fn test_is_qualifier_field_bare_segment() {
2464        assert!(!is_qualifier_field("NAD"));
2465        assert!(!is_qualifier_field("SG2/NAD"));
2466    }
2467
2468    #[test]
2469    fn test_is_qualifier_field_rejects_deep_paths() {
2470        // 4+ parts after SG stripping are malformed — reject.
2471        assert!(!is_qualifier_field("SEG/A/B/C/D"));
2472    }
2473
2474    #[test]
2475    fn test_missing_qualifier_instance_is_detected() {
2476        // NAD+MS is present but NAD+MR is missing.
2477        // The Empfaenger field requires [MR] → should produce AHB001.
2478        let evaluator = MockEvaluator::new(vec![]);
2479        let validator = EdifactValidator::new(evaluator);
2480        let external = NoOpExternalProvider;
2481
2482        let nad_ms = OwnedSegment {
2483            id: "NAD".to_string(),
2484            elements: vec![vec!["MS".to_string()]],
2485            segment_number: 3,
2486        };
2487
2488        let workflow = AhbWorkflow {
2489            pruefidentifikator: "55001".to_string(),
2490            description: "Test".to_string(),
2491            communication_direction: None,
2492            fields: vec![
2493                AhbFieldRule {
2494                    segment_path: "SG2/NAD/3035".to_string(),
2495                    name: "Absender".to_string(),
2496                    ahb_status: "X".to_string(),
2497                    codes: vec![AhbCodeRule {
2498                        value: "MS".to_string(),
2499                        description: "Absender".to_string(),
2500                        ahb_status: "X".to_string(),
2501                    }],
2502                    parent_group_ahb_status: None,
2503                    segment_ahb_status: None,
2504                    ..Default::default()
2505                },
2506                AhbFieldRule {
2507                    segment_path: "SG2/NAD/3035".to_string(),
2508                    name: "Empfaenger".to_string(),
2509                    ahb_status: "Muss".to_string(),
2510                    codes: vec![AhbCodeRule {
2511                        value: "MR".to_string(),
2512                        description: "Empfaenger".to_string(),
2513                        ahb_status: "X".to_string(),
2514                    }],
2515                    parent_group_ahb_status: None,
2516                    segment_ahb_status: None,
2517                    ..Default::default()
2518                },
2519            ],
2520            ub_definitions: BTreeMap::new(),
2521        };
2522
2523        let report =
2524            validator.validate(&[nad_ms], &workflow, &external, ValidationLevel::Conditions);
2525
2526        let ahb_errors: Vec<_> = report
2527            .by_category(ValidationCategory::Ahb)
2528            .filter(|i| i.severity == Severity::Error)
2529            .collect();
2530        assert_eq!(
2531            ahb_errors.len(),
2532            1,
2533            "Expected AHB001 for missing NAD+MR, got: {:?}",
2534            ahb_errors
2535        );
2536        assert!(narrate(ahb_errors[0]).contains("Empfaenger"));
2537    }
2538
2539    #[test]
2540    fn test_present_qualifier_instance_no_error() {
2541        // Both NAD+MS and NAD+MR present → no AHB001 for either.
2542        let evaluator = MockEvaluator::new(vec![]);
2543        let validator = EdifactValidator::new(evaluator);
2544        let external = NoOpExternalProvider;
2545
2546        let segments = vec![
2547            OwnedSegment {
2548                id: "NAD".to_string(),
2549                elements: vec![vec!["MS".to_string()]],
2550                segment_number: 3,
2551            },
2552            OwnedSegment {
2553                id: "NAD".to_string(),
2554                elements: vec![vec!["MR".to_string()]],
2555                segment_number: 4,
2556            },
2557        ];
2558
2559        let workflow = AhbWorkflow {
2560            pruefidentifikator: "55001".to_string(),
2561            description: "Test".to_string(),
2562            communication_direction: None,
2563            fields: vec![
2564                AhbFieldRule {
2565                    segment_path: "SG2/NAD/3035".to_string(),
2566                    name: "Absender".to_string(),
2567                    ahb_status: "Muss".to_string(),
2568                    codes: vec![AhbCodeRule {
2569                        value: "MS".to_string(),
2570                        description: "Absender".to_string(),
2571                        ahb_status: "X".to_string(),
2572                    }],
2573                    parent_group_ahb_status: None,
2574                    segment_ahb_status: None,
2575                    ..Default::default()
2576                },
2577                AhbFieldRule {
2578                    segment_path: "SG2/NAD/3035".to_string(),
2579                    name: "Empfaenger".to_string(),
2580                    ahb_status: "Muss".to_string(),
2581                    codes: vec![AhbCodeRule {
2582                        value: "MR".to_string(),
2583                        description: "Empfaenger".to_string(),
2584                        ahb_status: "X".to_string(),
2585                    }],
2586                    parent_group_ahb_status: None,
2587                    segment_ahb_status: None,
2588                    ..Default::default()
2589                },
2590            ],
2591            ub_definitions: BTreeMap::new(),
2592        };
2593
2594        let report =
2595            validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
2596
2597        let ahb_errors: Vec<_> = report
2598            .by_category(ValidationCategory::Ahb)
2599            .filter(|i| i.severity == Severity::Error)
2600            .collect();
2601        assert!(
2602            ahb_errors.is_empty(),
2603            "Expected no AHB001 errors, got: {:?}",
2604            ahb_errors
2605        );
2606    }
2607
2608    #[test]
2609    fn test_extract_group_path_key() {
2610        assert_eq!(extract_group_path_key("SG2/NAD/3035"), "SG2");
2611        assert_eq!(extract_group_path_key("SG4/SG12/NAD/3035"), "SG4/SG12");
2612        assert_eq!(extract_group_path_key("NAD/3035"), "");
2613        assert_eq!(extract_group_path_key("SG4/SG8/SEQ/6350"), "SG4/SG8");
2614    }
2615
2616    #[test]
2617    fn test_absent_optional_group_no_missing_field_error() {
2618        // SG3 is optional ("Kann"). If SG3 is absent, its children CTA/3139
2619        // and CTA/C056/3412 should NOT produce AHB001 errors.
2620        use mig_types::navigator::GroupNavigator;
2621
2622        struct NavWithoutSG3;
2623        impl GroupNavigator for NavWithoutSG3 {
2624            fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
2625                vec![]
2626            }
2627            fn find_segments_with_qualifier_in_group(
2628                &self,
2629                _: &str,
2630                _: usize,
2631                _: &str,
2632                _: &[&str],
2633                _: usize,
2634            ) -> Vec<OwnedSegment> {
2635                vec![]
2636            }
2637            fn group_instance_count(&self, group_path: &[&str]) -> usize {
2638                match group_path {
2639                    ["SG2"] => 2,        // two NAD groups present
2640                    ["SG2", "SG3"] => 0, // SG3 is absent
2641                    _ => 0,
2642                }
2643            }
2644        }
2645
2646        let evaluator = MockEvaluator::new(vec![]);
2647        let validator = EdifactValidator::new(evaluator);
2648        let external = NoOpExternalProvider;
2649        let nav = NavWithoutSG3;
2650
2651        // Only NAD segments present, no CTA
2652        let segments = vec![
2653            OwnedSegment {
2654                id: "NAD".into(),
2655                elements: vec![vec!["MS".into()]],
2656                segment_number: 3,
2657            },
2658            OwnedSegment {
2659                id: "NAD".into(),
2660                elements: vec![vec!["MR".into()]],
2661                segment_number: 4,
2662            },
2663        ];
2664
2665        let workflow = AhbWorkflow {
2666            pruefidentifikator: "55001".to_string(),
2667            description: "Test".to_string(),
2668            communication_direction: None,
2669            fields: vec![
2670                AhbFieldRule {
2671                    segment_path: "SG2/SG3/CTA/3139".to_string(),
2672                    name: "Funktion des Ansprechpartners, Code".to_string(),
2673                    ahb_status: "Muss".to_string(),
2674                    codes: vec![],
2675                    parent_group_ahb_status: None,
2676                    segment_ahb_status: None,
2677                    ..Default::default()
2678                },
2679                AhbFieldRule {
2680                    segment_path: "SG2/SG3/CTA/C056/3412".to_string(),
2681                    name: "Name vom Ansprechpartner".to_string(),
2682                    ahb_status: "X".to_string(),
2683                    codes: vec![],
2684                    parent_group_ahb_status: None,
2685                    segment_ahb_status: None,
2686                    ..Default::default()
2687                },
2688            ],
2689            ub_definitions: BTreeMap::new(),
2690        };
2691
2692        let report = validator.validate_with_navigator(
2693            &segments,
2694            &workflow,
2695            &external,
2696            ValidationLevel::Conditions,
2697            &nav,
2698        );
2699
2700        let ahb_errors: Vec<_> = report
2701            .by_category(ValidationCategory::Ahb)
2702            .filter(|i| i.severity == Severity::Error)
2703            .collect();
2704        assert!(
2705            ahb_errors.is_empty(),
2706            "Expected no AHB001 errors when SG3 is absent, got: {:?}",
2707            ahb_errors
2708        );
2709    }
2710
2711    #[test]
2712    fn test_present_group_still_checks_mandatory_fields() {
2713        // If SG3 IS present but CTA is missing within it → AHB001 error.
2714        use mig_types::navigator::GroupNavigator;
2715
2716        struct NavWithSG3;
2717        impl GroupNavigator for NavWithSG3 {
2718            fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
2719                vec![]
2720            }
2721            fn find_segments_with_qualifier_in_group(
2722                &self,
2723                _: &str,
2724                _: usize,
2725                _: &str,
2726                _: &[&str],
2727                _: usize,
2728            ) -> Vec<OwnedSegment> {
2729                vec![]
2730            }
2731            fn group_instance_count(&self, group_path: &[&str]) -> usize {
2732                match group_path {
2733                    ["SG2"] => 1,
2734                    ["SG2", "SG3"] => 1, // SG3 is present
2735                    _ => 0,
2736                }
2737            }
2738        }
2739
2740        let evaluator = MockEvaluator::new(vec![]);
2741        let validator = EdifactValidator::new(evaluator);
2742        let external = NoOpExternalProvider;
2743        let nav = NavWithSG3;
2744
2745        // SG3 is present (nav says 1 instance) but CTA is not in flat segments
2746        let segments = vec![OwnedSegment {
2747            id: "NAD".into(),
2748            elements: vec![vec!["MS".into()]],
2749            segment_number: 3,
2750        }];
2751
2752        let workflow = AhbWorkflow {
2753            pruefidentifikator: "55001".to_string(),
2754            description: "Test".to_string(),
2755            communication_direction: None,
2756            fields: vec![AhbFieldRule {
2757                segment_path: "SG2/SG3/CTA/3139".to_string(),
2758                name: "Funktion des Ansprechpartners, Code".to_string(),
2759                ahb_status: "Muss".to_string(),
2760                codes: vec![],
2761                parent_group_ahb_status: None,
2762                segment_ahb_status: None,
2763                ..Default::default()
2764            }],
2765            ub_definitions: BTreeMap::new(),
2766        };
2767
2768        let report = validator.validate_with_navigator(
2769            &segments,
2770            &workflow,
2771            &external,
2772            ValidationLevel::Conditions,
2773            &nav,
2774        );
2775
2776        let ahb_errors: Vec<_> = report
2777            .by_category(ValidationCategory::Ahb)
2778            .filter(|i| i.severity == Severity::Error)
2779            .collect();
2780        assert_eq!(
2781            ahb_errors.len(),
2782            1,
2783            "Expected AHB001 error when SG3 is present but CTA missing"
2784        );
2785        assert!(narrate(ahb_errors[0]).contains("CTA"));
2786    }
2787
2788    #[test]
2789    fn test_missing_qualifier_with_navigator_is_detected() {
2790        // NAD+MS is in SG2 but NAD+MR is missing. With a navigator that
2791        // reports SG2 has 1 instance, the missing MR must still be flagged.
2792        use mig_types::navigator::GroupNavigator;
2793
2794        struct NavWithSG2;
2795        impl GroupNavigator for NavWithSG2 {
2796            fn find_segments_in_group(
2797                &self,
2798                segment_id: &str,
2799                group_path: &[&str],
2800                instance_index: usize,
2801            ) -> Vec<OwnedSegment> {
2802                if segment_id == "NAD" && group_path == ["SG2"] && instance_index == 0 {
2803                    vec![OwnedSegment {
2804                        id: "NAD".into(),
2805                        elements: vec![vec!["MS".into()]],
2806                        segment_number: 3,
2807                    }]
2808                } else {
2809                    vec![]
2810                }
2811            }
2812            fn find_segments_with_qualifier_in_group(
2813                &self,
2814                _: &str,
2815                _: usize,
2816                _: &str,
2817                _: &[&str],
2818                _: usize,
2819            ) -> Vec<OwnedSegment> {
2820                vec![]
2821            }
2822            fn group_instance_count(&self, group_path: &[&str]) -> usize {
2823                match group_path {
2824                    ["SG2"] => 1,
2825                    _ => 0,
2826                }
2827            }
2828        }
2829
2830        let evaluator = MockEvaluator::new(vec![]);
2831        let validator = EdifactValidator::new(evaluator);
2832        let external = NoOpExternalProvider;
2833        let nav = NavWithSG2;
2834
2835        let segments = vec![OwnedSegment {
2836            id: "NAD".into(),
2837            elements: vec![vec!["MS".into()]],
2838            segment_number: 3,
2839        }];
2840
2841        let workflow = AhbWorkflow {
2842            pruefidentifikator: "55001".to_string(),
2843            description: "Test".to_string(),
2844            communication_direction: None,
2845            fields: vec![
2846                AhbFieldRule {
2847                    segment_path: "SG2/NAD/3035".to_string(),
2848                    name: "Absender".to_string(),
2849                    ahb_status: "X".to_string(),
2850                    codes: vec![AhbCodeRule {
2851                        value: "MS".to_string(),
2852                        description: "Absender".to_string(),
2853                        ahb_status: "X".to_string(),
2854                    }],
2855                    parent_group_ahb_status: None,
2856                    segment_ahb_status: None,
2857                    ..Default::default()
2858                },
2859                AhbFieldRule {
2860                    segment_path: "SG2/NAD/3035".to_string(),
2861                    name: "Empfaenger".to_string(),
2862                    ahb_status: "Muss".to_string(),
2863                    codes: vec![AhbCodeRule {
2864                        value: "MR".to_string(),
2865                        description: "Empfaenger".to_string(),
2866                        ahb_status: "X".to_string(),
2867                    }],
2868                    parent_group_ahb_status: None,
2869                    segment_ahb_status: None,
2870                    ..Default::default()
2871                },
2872            ],
2873            ub_definitions: BTreeMap::new(),
2874        };
2875
2876        let report = validator.validate_with_navigator(
2877            &segments,
2878            &workflow,
2879            &external,
2880            ValidationLevel::Conditions,
2881            &nav,
2882        );
2883
2884        let ahb_errors: Vec<_> = report
2885            .by_category(ValidationCategory::Ahb)
2886            .filter(|i| i.severity == Severity::Error)
2887            .collect();
2888        assert_eq!(
2889            ahb_errors.len(),
2890            1,
2891            "Expected AHB001 for missing NAD+MR even with navigator, got: {:?}",
2892            ahb_errors
2893        );
2894        assert!(narrate(ahb_errors[0]).contains("Empfaenger"));
2895    }
2896
2897    #[test]
2898    fn test_optional_group_variant_absent_no_error() {
2899        // SG5 is "Kann" (optional) with LOC+Z16 present but LOC+Z17 absent.
2900        // Field rules for LOC/3227 with Z17 and its children should NOT error
2901        // because the parent group is optional and the variant is absent.
2902        // Meanwhile, SG2 is "Muss" — missing NAD+MR MUST still error.
2903        use mig_types::navigator::GroupNavigator;
2904
2905        struct TestNav;
2906        impl GroupNavigator for TestNav {
2907            fn find_segments_in_group(
2908                &self,
2909                segment_id: &str,
2910                group_path: &[&str],
2911                instance_index: usize,
2912            ) -> Vec<OwnedSegment> {
2913                match (segment_id, group_path, instance_index) {
2914                    ("LOC", ["SG4", "SG5"], 0) => vec![OwnedSegment {
2915                        id: "LOC".into(),
2916                        elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
2917                        segment_number: 10,
2918                    }],
2919                    ("NAD", ["SG2"], 0) => vec![OwnedSegment {
2920                        id: "NAD".into(),
2921                        elements: vec![vec!["MS".into()]],
2922                        segment_number: 3,
2923                    }],
2924                    _ => vec![],
2925                }
2926            }
2927            fn find_segments_with_qualifier_in_group(
2928                &self,
2929                _: &str,
2930                _: usize,
2931                _: &str,
2932                _: &[&str],
2933                _: usize,
2934            ) -> Vec<OwnedSegment> {
2935                vec![]
2936            }
2937            fn group_instance_count(&self, group_path: &[&str]) -> usize {
2938                match group_path {
2939                    ["SG2"] => 1,
2940                    ["SG4"] => 1,
2941                    ["SG4", "SG5"] => 1, // only Z16 instance
2942                    _ => 0,
2943                }
2944            }
2945        }
2946
2947        let evaluator = MockEvaluator::new(vec![]);
2948        let validator = EdifactValidator::new(evaluator);
2949        let external = NoOpExternalProvider;
2950        let nav = TestNav;
2951
2952        let segments = vec![
2953            OwnedSegment {
2954                id: "NAD".into(),
2955                elements: vec![vec!["MS".into()]],
2956                segment_number: 3,
2957            },
2958            OwnedSegment {
2959                id: "LOC".into(),
2960                elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
2961                segment_number: 10,
2962            },
2963        ];
2964
2965        let workflow = AhbWorkflow {
2966            pruefidentifikator: "55001".to_string(),
2967            description: "Test".to_string(),
2968            communication_direction: None,
2969            fields: vec![
2970                // SG2 "Muss" — NAD+MS present, NAD+MR missing → should error
2971                AhbFieldRule {
2972                    segment_path: "SG2/NAD/3035".to_string(),
2973                    name: "Absender".to_string(),
2974                    ahb_status: "X".to_string(),
2975                    codes: vec![AhbCodeRule {
2976                        value: "MS".to_string(),
2977                        description: "Absender".to_string(),
2978                        ahb_status: "X".to_string(),
2979                    }],
2980                    parent_group_ahb_status: Some("Muss".to_string()),
2981                    segment_ahb_status: None,
2982                    ..Default::default()
2983                },
2984                AhbFieldRule {
2985                    segment_path: "SG2/NAD/3035".to_string(),
2986                    name: "Empfaenger".to_string(),
2987                    ahb_status: "Muss".to_string(),
2988                    codes: vec![AhbCodeRule {
2989                        value: "MR".to_string(),
2990                        description: "Empfaenger".to_string(),
2991                        ahb_status: "X".to_string(),
2992                    }],
2993                    parent_group_ahb_status: Some("Muss".to_string()),
2994                    segment_ahb_status: None,
2995                    ..Default::default()
2996                },
2997                // SG5 "Kann" — LOC+Z16 present, LOC+Z17 absent → should NOT error
2998                AhbFieldRule {
2999                    segment_path: "SG4/SG5/LOC/3227".to_string(),
3000                    name: "Ortsangabe, Qualifier (Z16)".to_string(),
3001                    ahb_status: "X".to_string(),
3002                    codes: vec![AhbCodeRule {
3003                        value: "Z16".to_string(),
3004                        description: "Marktlokation".to_string(),
3005                        ahb_status: "X".to_string(),
3006                    }],
3007                    parent_group_ahb_status: Some("Kann".to_string()),
3008                    segment_ahb_status: None,
3009                    ..Default::default()
3010                },
3011                AhbFieldRule {
3012                    segment_path: "SG4/SG5/LOC/3227".to_string(),
3013                    name: "Ortsangabe, Qualifier (Z17)".to_string(),
3014                    ahb_status: "Muss".to_string(),
3015                    codes: vec![AhbCodeRule {
3016                        value: "Z17".to_string(),
3017                        description: "Messlokation".to_string(),
3018                        ahb_status: "X".to_string(),
3019                    }],
3020                    parent_group_ahb_status: Some("Kann".to_string()),
3021                    segment_ahb_status: None,
3022                    ..Default::default()
3023                },
3024            ],
3025            ub_definitions: BTreeMap::new(),
3026        };
3027
3028        let report = validator.validate_with_navigator(
3029            &segments,
3030            &workflow,
3031            &external,
3032            ValidationLevel::Conditions,
3033            &nav,
3034        );
3035
3036        let ahb_errors: Vec<_> = report
3037            .by_category(ValidationCategory::Ahb)
3038            .filter(|i| i.severity == Severity::Error)
3039            .collect();
3040
3041        // Exactly 1 error: NAD+MR missing (mandatory group)
3042        // LOC+Z17 should NOT error (optional group variant absent)
3043        assert_eq!(
3044            ahb_errors.len(),
3045            1,
3046            "Expected only AHB001 for missing NAD+MR, got: {:?}",
3047            ahb_errors
3048        );
3049        assert!(
3050            narrate(ahb_errors[0]).contains("Empfaenger"),
3051            "Error should be for missing NAD+MR (Empfaenger)"
3052        );
3053    }
3054
3055    #[test]
3056    fn test_conditional_group_variant_absent_no_error() {
3057        // Real-world scenario: SG5 "Messlokation" has AHB_Status="Soll [165]".
3058        // Condition [165] evaluates to False → LOC+Z17 should NOT error.
3059        // SG5 "Marktlokation" has AHB_Status="Muss [2061]".
3060        // Condition [2061] evaluates to True → LOC+Z16 is present → no error.
3061        use mig_types::navigator::GroupNavigator;
3062
3063        struct TestNav;
3064        impl GroupNavigator for TestNav {
3065            fn find_segments_in_group(
3066                &self,
3067                segment_id: &str,
3068                group_path: &[&str],
3069                instance_index: usize,
3070            ) -> Vec<OwnedSegment> {
3071                if segment_id == "LOC" && group_path == ["SG4", "SG5"] && instance_index == 0 {
3072                    vec![OwnedSegment {
3073                        id: "LOC".into(),
3074                        elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
3075                        segment_number: 10,
3076                    }]
3077                } else {
3078                    vec![]
3079                }
3080            }
3081            fn find_segments_with_qualifier_in_group(
3082                &self,
3083                _: &str,
3084                _: usize,
3085                _: &str,
3086                _: &[&str],
3087                _: usize,
3088            ) -> Vec<OwnedSegment> {
3089                vec![]
3090            }
3091            fn group_instance_count(&self, group_path: &[&str]) -> usize {
3092                match group_path {
3093                    ["SG4"] => 1,
3094                    ["SG4", "SG5"] => 1, // only Z16 instance
3095                    _ => 0,
3096                }
3097            }
3098        }
3099
3100        // Condition 165 → False (Messlokation not required)
3101        // Condition 2061 → True (Marktlokation required)
3102        let evaluator = MockEvaluator::new(vec![(165, CR::False), (2061, CR::True)]);
3103        let validator = EdifactValidator::new(evaluator);
3104        let external = NoOpExternalProvider;
3105        let nav = TestNav;
3106
3107        let segments = vec![OwnedSegment {
3108            id: "LOC".into(),
3109            elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
3110            segment_number: 10,
3111        }];
3112
3113        let workflow = AhbWorkflow {
3114            pruefidentifikator: "55001".to_string(),
3115            description: "Test".to_string(),
3116            communication_direction: None,
3117            fields: vec![
3118                // SG5 "Muss [2061]" with [2061]=True → LOC+Z16 required, present → OK
3119                AhbFieldRule {
3120                    segment_path: "SG4/SG5/LOC/3227".to_string(),
3121                    name: "Ortsangabe, Qualifier (Z16)".to_string(),
3122                    ahb_status: "X".to_string(),
3123                    codes: vec![AhbCodeRule {
3124                        value: "Z16".to_string(),
3125                        description: "Marktlokation".to_string(),
3126                        ahb_status: "X".to_string(),
3127                    }],
3128                    parent_group_ahb_status: Some("Muss [2061]".to_string()),
3129                    segment_ahb_status: None,
3130                    ..Default::default()
3131                },
3132                // SG5 "Soll [165]" with [165]=False → LOC+Z17 NOT required → skip
3133                AhbFieldRule {
3134                    segment_path: "SG4/SG5/LOC/3227".to_string(),
3135                    name: "Ortsangabe, Qualifier (Z17)".to_string(),
3136                    ahb_status: "X".to_string(),
3137                    codes: vec![AhbCodeRule {
3138                        value: "Z17".to_string(),
3139                        description: "Messlokation".to_string(),
3140                        ahb_status: "X".to_string(),
3141                    }],
3142                    parent_group_ahb_status: Some("Soll [165]".to_string()),
3143                    segment_ahb_status: None,
3144                    ..Default::default()
3145                },
3146            ],
3147            ub_definitions: BTreeMap::new(),
3148        };
3149
3150        let report = validator.validate_with_navigator(
3151            &segments,
3152            &workflow,
3153            &external,
3154            ValidationLevel::Conditions,
3155            &nav,
3156        );
3157
3158        let ahb_errors: Vec<_> = report
3159            .by_category(ValidationCategory::Ahb)
3160            .filter(|i| i.severity == Severity::Error)
3161            .collect();
3162
3163        // Zero errors: Z16 is present, and Z17's group condition is False
3164        assert!(
3165            ahb_errors.is_empty(),
3166            "Expected no errors when conditional group variant [165]=False, got: {:?}",
3167            ahb_errors
3168        );
3169    }
3170
3171    #[test]
3172    fn test_conditional_group_variant_unknown_no_error() {
3173        // When a parent group condition evaluates to Unknown (unimplemented
3174        // condition), child fields should NOT produce mandatory-missing errors.
3175        // The group-level entry itself will produce an Info "condition unknown".
3176
3177        // Condition 165 is NOT in the evaluator → returns Unknown
3178        let evaluator = MockEvaluator::new(vec![]);
3179        let validator = EdifactValidator::new(evaluator);
3180        let external = NoOpExternalProvider;
3181
3182        let workflow = AhbWorkflow {
3183            pruefidentifikator: "55001".to_string(),
3184            description: "Test".to_string(),
3185            communication_direction: None,
3186            fields: vec![AhbFieldRule {
3187                segment_path: "SG4/SG5/LOC/3227".to_string(),
3188                name: "Ortsangabe, Qualifier (Z17)".to_string(),
3189                ahb_status: "X".to_string(),
3190                codes: vec![AhbCodeRule {
3191                    value: "Z17".to_string(),
3192                    description: "Messlokation".to_string(),
3193                    ahb_status: "X".to_string(),
3194                }],
3195                parent_group_ahb_status: Some("Soll [165]".to_string()),
3196                segment_ahb_status: None,
3197                ..Default::default()
3198            }],
3199            ub_definitions: BTreeMap::new(),
3200        };
3201
3202        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
3203
3204        let ahb_errors: Vec<_> = report
3205            .by_category(ValidationCategory::Ahb)
3206            .filter(|i| i.severity == Severity::Error)
3207            .collect();
3208
3209        // No errors: parent group condition [165] is Unknown → skip child fields
3210        assert!(
3211            ahb_errors.is_empty(),
3212            "Expected no errors when parent group condition is Unknown, got: {:?}",
3213            ahb_errors
3214        );
3215    }
3216
3217    #[test]
3218    fn test_segment_absent_within_present_group_no_error() {
3219        // MSCONS scenario: SG10 has QTY (entry) + optional STS segments.
3220        // SG10 is present (QTY+220 exists) but STS is absent.
3221        // Fields under STS should NOT produce AHB001 errors.
3222        use mig_types::navigator::GroupNavigator;
3223
3224        struct TestNav;
3225        impl GroupNavigator for TestNav {
3226            fn find_segments_in_group(
3227                &self,
3228                segment_id: &str,
3229                group_path: &[&str],
3230                instance_index: usize,
3231            ) -> Vec<OwnedSegment> {
3232                // SG10 has QTY but no STS
3233                if segment_id == "QTY"
3234                    && group_path == ["SG5", "SG6", "SG9", "SG10"]
3235                    && instance_index == 0
3236                {
3237                    vec![OwnedSegment {
3238                        id: "QTY".into(),
3239                        elements: vec![vec!["220".into(), "0".into()]],
3240                        segment_number: 14,
3241                    }]
3242                } else {
3243                    vec![]
3244                }
3245            }
3246            fn find_segments_with_qualifier_in_group(
3247                &self,
3248                _: &str,
3249                _: usize,
3250                _: &str,
3251                _: &[&str],
3252                _: usize,
3253            ) -> Vec<OwnedSegment> {
3254                vec![]
3255            }
3256            fn group_instance_count(&self, group_path: &[&str]) -> usize {
3257                match group_path {
3258                    ["SG5"] => 1,
3259                    ["SG5", "SG6"] => 1,
3260                    ["SG5", "SG6", "SG9"] => 1,
3261                    ["SG5", "SG6", "SG9", "SG10"] => 1,
3262                    _ => 0,
3263                }
3264            }
3265            fn has_any_segment_in_group(&self, group_path: &[&str], instance_index: usize) -> bool {
3266                // SG10 instance 0 has QTY (the entry segment)
3267                group_path == ["SG5", "SG6", "SG9", "SG10"] && instance_index == 0
3268            }
3269        }
3270
3271        let evaluator = MockEvaluator::all_true(&[]);
3272        let validator = EdifactValidator::new(evaluator);
3273        let external = NoOpExternalProvider;
3274        let nav = TestNav;
3275
3276        let segments = vec![OwnedSegment {
3277            id: "QTY".into(),
3278            elements: vec![vec!["220".into(), "0".into()]],
3279            segment_number: 14,
3280        }];
3281
3282        let workflow = AhbWorkflow {
3283            pruefidentifikator: "13017".to_string(),
3284            description: "Test".to_string(),
3285            communication_direction: None,
3286            fields: vec![
3287                // STS/C601/9015 — mandatory field under STS, but STS is absent from SG10
3288                AhbFieldRule {
3289                    segment_path: "SG5/SG6/SG9/SG10/STS/C601/9015".to_string(),
3290                    name: "Statuskategorie, Code".to_string(),
3291                    ahb_status: "X".to_string(),
3292                    codes: vec![],
3293                    parent_group_ahb_status: Some("Muss".to_string()),
3294                    segment_ahb_status: None,
3295                    ..Default::default()
3296                },
3297                // STS/C556/9013 — another field under STS
3298                AhbFieldRule {
3299                    segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
3300                    name: "Statusanlaß, Code".to_string(),
3301                    ahb_status: "X [5]".to_string(),
3302                    codes: vec![],
3303                    parent_group_ahb_status: Some("Muss".to_string()),
3304                    segment_ahb_status: None,
3305                    ..Default::default()
3306                },
3307            ],
3308            ub_definitions: BTreeMap::new(),
3309        };
3310
3311        let report = validator.validate_with_navigator(
3312            &segments,
3313            &workflow,
3314            &external,
3315            ValidationLevel::Conditions,
3316            &nav,
3317        );
3318
3319        let ahb_errors: Vec<_> = report
3320            .by_category(ValidationCategory::Ahb)
3321            .filter(|i| i.severity == Severity::Error)
3322            .collect();
3323
3324        assert!(
3325            ahb_errors.is_empty(),
3326            "Expected no AHB001 errors when STS segment is absent from SG10, got: {:?}",
3327            ahb_errors
3328        );
3329    }
3330
3331    #[test]
3332    fn test_group_scoped_code_validation_with_navigator() {
3333        // With a navigator, SG2/NAD is checked against {MS, MR} only,
3334        // and SG4/SG12/NAD is checked against {Z04, Z09} only.
3335        // NAD+MT in SG2 → error with allowed [MR, MS] (not the full union).
3336        use mig_types::navigator::GroupNavigator;
3337
3338        struct TestNav;
3339        impl GroupNavigator for TestNav {
3340            fn find_segments_in_group(
3341                &self,
3342                segment_id: &str,
3343                group_path: &[&str],
3344                _instance_index: usize,
3345            ) -> Vec<OwnedSegment> {
3346                if segment_id != "NAD" {
3347                    return vec![];
3348                }
3349                match group_path {
3350                    ["SG2"] => vec![
3351                        OwnedSegment {
3352                            id: "NAD".into(),
3353                            elements: vec![vec!["MS".into()]],
3354                            segment_number: 3,
3355                        },
3356                        OwnedSegment {
3357                            id: "NAD".into(),
3358                            elements: vec![vec!["MT".into()]], // invalid in SG2
3359                            segment_number: 4,
3360                        },
3361                    ],
3362                    ["SG4", "SG12"] => vec![
3363                        OwnedSegment {
3364                            id: "NAD".into(),
3365                            elements: vec![vec!["Z04".into()]],
3366                            segment_number: 20,
3367                        },
3368                        OwnedSegment {
3369                            id: "NAD".into(),
3370                            elements: vec![vec!["Z09".into()]],
3371                            segment_number: 21,
3372                        },
3373                    ],
3374                    _ => vec![],
3375                }
3376            }
3377            fn find_segments_with_qualifier_in_group(
3378                &self,
3379                _: &str,
3380                _: usize,
3381                _: &str,
3382                _: &[&str],
3383                _: usize,
3384            ) -> Vec<OwnedSegment> {
3385                vec![]
3386            }
3387            fn group_instance_count(&self, group_path: &[&str]) -> usize {
3388                match group_path {
3389                    ["SG2"] | ["SG4", "SG12"] => 1,
3390                    _ => 0,
3391                }
3392            }
3393        }
3394
3395        let evaluator = MockEvaluator::new(vec![]);
3396        let validator = EdifactValidator::new(evaluator);
3397        let external = NoOpExternalProvider;
3398        let nav = TestNav;
3399
3400        let workflow = AhbWorkflow {
3401            pruefidentifikator: "55001".to_string(),
3402            description: "Test".to_string(),
3403            communication_direction: None,
3404            fields: vec![
3405                AhbFieldRule {
3406                    segment_path: "SG2/NAD/3035".to_string(),
3407                    name: "Absender".to_string(),
3408                    ahb_status: "X".to_string(),
3409                    codes: vec![AhbCodeRule {
3410                        value: "MS".to_string(),
3411                        description: "Absender".to_string(),
3412                        ahb_status: "X".to_string(),
3413                    }],
3414                    parent_group_ahb_status: None,
3415                    segment_ahb_status: None,
3416                    ..Default::default()
3417                },
3418                AhbFieldRule {
3419                    segment_path: "SG2/NAD/3035".to_string(),
3420                    name: "Empfaenger".to_string(),
3421                    ahb_status: "X".to_string(),
3422                    codes: vec![AhbCodeRule {
3423                        value: "MR".to_string(),
3424                        description: "Empfaenger".to_string(),
3425                        ahb_status: "X".to_string(),
3426                    }],
3427                    parent_group_ahb_status: None,
3428                    segment_ahb_status: None,
3429                    ..Default::default()
3430                },
3431                AhbFieldRule {
3432                    segment_path: "SG4/SG12/NAD/3035".to_string(),
3433                    name: "Anschlussnutzer".to_string(),
3434                    ahb_status: "X".to_string(),
3435                    codes: vec![AhbCodeRule {
3436                        value: "Z04".to_string(),
3437                        description: "Anschlussnutzer".to_string(),
3438                        ahb_status: "X".to_string(),
3439                    }],
3440                    parent_group_ahb_status: None,
3441                    segment_ahb_status: None,
3442                    ..Default::default()
3443                },
3444                AhbFieldRule {
3445                    segment_path: "SG4/SG12/NAD/3035".to_string(),
3446                    name: "Korrespondenzanschrift".to_string(),
3447                    ahb_status: "X".to_string(),
3448                    codes: vec![AhbCodeRule {
3449                        value: "Z09".to_string(),
3450                        description: "Korrespondenzanschrift".to_string(),
3451                        ahb_status: "X".to_string(),
3452                    }],
3453                    parent_group_ahb_status: None,
3454                    segment_ahb_status: None,
3455                    ..Default::default()
3456                },
3457            ],
3458            ub_definitions: BTreeMap::new(),
3459        };
3460
3461        // All segments flat (for condition evaluation), navigator provides group scope.
3462        let all_segments = vec![
3463            OwnedSegment {
3464                id: "NAD".into(),
3465                elements: vec![vec!["MS".into()]],
3466                segment_number: 3,
3467            },
3468            OwnedSegment {
3469                id: "NAD".into(),
3470                elements: vec![vec!["MT".into()]],
3471                segment_number: 4,
3472            },
3473            OwnedSegment {
3474                id: "NAD".into(),
3475                elements: vec![vec!["Z04".into()]],
3476                segment_number: 20,
3477            },
3478            OwnedSegment {
3479                id: "NAD".into(),
3480                elements: vec![vec!["Z09".into()]],
3481                segment_number: 21,
3482            },
3483        ];
3484
3485        let report = validator.validate_with_navigator(
3486            &all_segments,
3487            &workflow,
3488            &external,
3489            ValidationLevel::Conditions,
3490            &nav,
3491        );
3492
3493        let code_errors: Vec<_> = report
3494            .by_category(ValidationCategory::Code)
3495            .filter(|i| i.severity == Severity::Error)
3496            .collect();
3497
3498        // Only one error: MT in SG2 (not allowed in {MS, MR}).
3499        // Z04 and Z09 are NOT checked against {MS, MR} — group-scoped.
3500        assert_eq!(
3501            code_errors.len(),
3502            1,
3503            "Expected exactly one COD002 error for MT in SG2, got: {:?}",
3504            code_errors
3505        );
3506        assert!(narrate(code_errors[0]).contains("MT"));
3507        // Error should show only SG2's allowed codes, not the full union
3508        assert!(narrate(code_errors[0]).contains("MR"));
3509        assert!(narrate(code_errors[0]).contains("MS"));
3510        assert!(
3511            !narrate(code_errors[0]).contains("Z04"),
3512            "SG4/SG12 codes should not leak into SG2 error"
3513        );
3514        // Field path should include the group
3515        assert!(
3516            code_errors[0]
3517                .field_path
3518                .as_deref()
3519                .unwrap_or("")
3520                .contains("SG2"),
3521            "Error field_path should reference SG2, got: {:?}",
3522            code_errors[0].field_path
3523        );
3524    }
3525
3526    // === Package cardinality tests ===
3527
3528    #[test]
3529    fn test_package_cardinality_within_bounds() {
3530        // 1 code present from package [4P0..1], max=1 -> OK
3531        let evaluator = MockEvaluator::all_true(&[]);
3532        let validator = EdifactValidator::new(evaluator);
3533        let external = NoOpExternalProvider;
3534
3535        let segments = vec![OwnedSegment {
3536            id: "STS".into(),
3537            elements: vec![
3538                vec!["Z33".into()], // element[0]
3539                vec![],             // element[1]
3540                vec!["E01".into()], // element[2], component[0]
3541            ],
3542            segment_number: 5,
3543        }];
3544
3545        let workflow = AhbWorkflow {
3546            pruefidentifikator: "13017".to_string(),
3547            description: "Test".to_string(),
3548            communication_direction: None,
3549            ub_definitions: BTreeMap::new(),
3550            fields: vec![AhbFieldRule {
3551                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
3552                name: "Statusanlaß, Code".to_string(),
3553                ahb_status: "X".to_string(),
3554                element_index: Some(2),
3555                component_index: Some(0),
3556                codes: vec![
3557                    AhbCodeRule {
3558                        value: "E01".into(),
3559                        description: "Code 1".into(),
3560                        ahb_status: "X [4P0..1]".into(),
3561                    },
3562                    AhbCodeRule {
3563                        value: "E02".into(),
3564                        description: "Code 2".into(),
3565                        ahb_status: "X [4P0..1]".into(),
3566                    },
3567                ],
3568                parent_group_ahb_status: Some("Muss".to_string()),
3569                segment_ahb_status: None,
3570                mig_number: None,
3571            }],
3572        };
3573
3574        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
3575        let pkg_errors: Vec<_> = report
3576            .by_category(ValidationCategory::Ahb)
3577            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
3578            .collect();
3579        assert!(
3580            pkg_errors.is_empty(),
3581            "1 code within [4P0..1] bounds — no error expected, got: {:?}",
3582            pkg_errors
3583        );
3584    }
3585
3586    #[test]
3587    fn test_package_cardinality_zero_present_min_zero() {
3588        // No codes from the package present, min=0 -> OK
3589        let evaluator = MockEvaluator::all_true(&[]);
3590        let validator = EdifactValidator::new(evaluator);
3591        let external = NoOpExternalProvider;
3592
3593        let segments = vec![OwnedSegment {
3594            id: "STS".into(),
3595            elements: vec![
3596                vec!["Z33".into()],
3597                vec![],
3598                vec!["X99".into()], // X99 not in package
3599            ],
3600            segment_number: 5,
3601        }];
3602
3603        let workflow = AhbWorkflow {
3604            pruefidentifikator: "13017".to_string(),
3605            description: "Test".to_string(),
3606            communication_direction: None,
3607            ub_definitions: BTreeMap::new(),
3608            fields: vec![AhbFieldRule {
3609                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
3610                name: "Statusanlaß, Code".to_string(),
3611                ahb_status: "X".to_string(),
3612                element_index: Some(2),
3613                component_index: Some(0),
3614                codes: vec![
3615                    AhbCodeRule {
3616                        value: "E01".into(),
3617                        description: "Code 1".into(),
3618                        ahb_status: "X [4P0..1]".into(),
3619                    },
3620                    AhbCodeRule {
3621                        value: "E02".into(),
3622                        description: "Code 2".into(),
3623                        ahb_status: "X [4P0..1]".into(),
3624                    },
3625                ],
3626                parent_group_ahb_status: Some("Muss".to_string()),
3627                segment_ahb_status: None,
3628                mig_number: None,
3629            }],
3630        };
3631
3632        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
3633        let pkg_errors: Vec<_> = report
3634            .by_category(ValidationCategory::Ahb)
3635            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
3636            .collect();
3637        assert!(
3638            pkg_errors.is_empty(),
3639            "0 codes, min=0 — no error expected, got: {:?}",
3640            pkg_errors
3641        );
3642    }
3643
3644    #[test]
3645    fn test_package_cardinality_too_many() {
3646        // 2 codes present from package [4P0..1], max=1 -> ERROR
3647        let evaluator = MockEvaluator::all_true(&[]);
3648        let validator = EdifactValidator::new(evaluator);
3649        let external = NoOpExternalProvider;
3650
3651        // Two STS segments, each with a different package code
3652        let segments = vec![
3653            OwnedSegment {
3654                id: "STS".into(),
3655                elements: vec![vec!["Z33".into()], vec![], vec!["E01".into()]],
3656                segment_number: 5,
3657            },
3658            OwnedSegment {
3659                id: "STS".into(),
3660                elements: vec![vec!["Z33".into()], vec![], vec!["E02".into()]],
3661                segment_number: 6,
3662            },
3663        ];
3664
3665        let workflow = AhbWorkflow {
3666            pruefidentifikator: "13017".to_string(),
3667            description: "Test".to_string(),
3668            communication_direction: None,
3669            ub_definitions: BTreeMap::new(),
3670            fields: vec![AhbFieldRule {
3671                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
3672                name: "Statusanlaß, Code".to_string(),
3673                ahb_status: "X".to_string(),
3674                element_index: Some(2),
3675                component_index: Some(0),
3676                codes: vec![
3677                    AhbCodeRule {
3678                        value: "E01".into(),
3679                        description: "Code 1".into(),
3680                        ahb_status: "X [4P0..1]".into(),
3681                    },
3682                    AhbCodeRule {
3683                        value: "E02".into(),
3684                        description: "Code 2".into(),
3685                        ahb_status: "X [4P0..1]".into(),
3686                    },
3687                ],
3688                parent_group_ahb_status: Some("Muss".to_string()),
3689                segment_ahb_status: None,
3690                mig_number: None,
3691            }],
3692        };
3693
3694        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
3695        let pkg_errors: Vec<_> = report
3696            .by_category(ValidationCategory::Ahb)
3697            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
3698            .collect();
3699        assert_eq!(
3700            pkg_errors.len(),
3701            1,
3702            "2 codes present, max=1 — expected 1 error, got: {:?}",
3703            pkg_errors
3704        );
3705        assert!(narrate(pkg_errors[0]).contains("[4P0..1]"));
3706        assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("2"));
3707        assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("0..1"));
3708    }
3709
3710    #[test]
3711    fn test_package_cardinality_too_few() {
3712        // 0 codes present from package [5P1..3], min=1 -> ERROR
3713        let evaluator = MockEvaluator::all_true(&[]);
3714        let validator = EdifactValidator::new(evaluator);
3715        let external = NoOpExternalProvider;
3716
3717        let segments = vec![OwnedSegment {
3718            id: "STS".into(),
3719            elements: vec![
3720                vec!["Z33".into()],
3721                vec![],
3722                vec!["X99".into()], // not in package
3723            ],
3724            segment_number: 5,
3725        }];
3726
3727        let workflow = AhbWorkflow {
3728            pruefidentifikator: "13017".to_string(),
3729            description: "Test".to_string(),
3730            communication_direction: None,
3731            ub_definitions: BTreeMap::new(),
3732            fields: vec![AhbFieldRule {
3733                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
3734                name: "Statusanlaß, Code".to_string(),
3735                ahb_status: "X".to_string(),
3736                element_index: Some(2),
3737                component_index: Some(0),
3738                codes: vec![
3739                    AhbCodeRule {
3740                        value: "E01".into(),
3741                        description: "Code 1".into(),
3742                        ahb_status: "X [5P1..3]".into(),
3743                    },
3744                    AhbCodeRule {
3745                        value: "E02".into(),
3746                        description: "Code 2".into(),
3747                        ahb_status: "X [5P1..3]".into(),
3748                    },
3749                    AhbCodeRule {
3750                        value: "E03".into(),
3751                        description: "Code 3".into(),
3752                        ahb_status: "X [5P1..3]".into(),
3753                    },
3754                ],
3755                parent_group_ahb_status: Some("Muss".to_string()),
3756                segment_ahb_status: None,
3757                mig_number: None,
3758            }],
3759        };
3760
3761        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
3762        let pkg_errors: Vec<_> = report
3763            .by_category(ValidationCategory::Ahb)
3764            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
3765            .collect();
3766        assert_eq!(
3767            pkg_errors.len(),
3768            1,
3769            "0 codes present, min=1 — expected 1 error, got: {:?}",
3770            pkg_errors
3771        );
3772        assert!(narrate(pkg_errors[0]).contains("[5P1..3]"));
3773        assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("0"));
3774        assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("1..3"));
3775    }
3776
3777    #[test]
3778    fn test_package_cardinality_no_packages_in_workflow() {
3779        // Codes without package annotations -> no package errors
3780        let evaluator = MockEvaluator::all_true(&[]);
3781        let validator = EdifactValidator::new(evaluator);
3782        let external = NoOpExternalProvider;
3783
3784        let segments = vec![OwnedSegment {
3785            id: "STS".into(),
3786            elements: vec![vec!["E01".into()]],
3787            segment_number: 5,
3788        }];
3789
3790        let workflow = AhbWorkflow {
3791            pruefidentifikator: "13017".to_string(),
3792            description: "Test".to_string(),
3793            communication_direction: None,
3794            ub_definitions: BTreeMap::new(),
3795            fields: vec![AhbFieldRule {
3796                segment_path: "STS/9015".to_string(),
3797                name: "Status Code".to_string(),
3798                ahb_status: "X".to_string(),
3799                codes: vec![AhbCodeRule {
3800                    value: "E01".into(),
3801                    description: "Code 1".into(),
3802                    ahb_status: "X".into(),
3803                }],
3804                parent_group_ahb_status: Some("Muss".to_string()),
3805                segment_ahb_status: None,
3806                ..Default::default()
3807            }],
3808        };
3809
3810        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
3811        let pkg_errors: Vec<_> = report
3812            .by_category(ValidationCategory::Ahb)
3813            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
3814            .collect();
3815        assert!(
3816            pkg_errors.is_empty(),
3817            "No packages in workflow — no errors expected"
3818        );
3819    }
3820
3821    #[test]
3822    fn test_package_cardinality_with_condition_and_package() {
3823        // Code status "X [901] [4P0..1]" has both condition and package
3824        let evaluator = MockEvaluator::all_true(&[901]);
3825        let validator = EdifactValidator::new(evaluator);
3826        let external = NoOpExternalProvider;
3827
3828        let segments = vec![OwnedSegment {
3829            id: "STS".into(),
3830            elements: vec![vec![], vec![], vec!["E01".into()]],
3831            segment_number: 5,
3832        }];
3833
3834        let workflow = AhbWorkflow {
3835            pruefidentifikator: "13017".to_string(),
3836            description: "Test".to_string(),
3837            communication_direction: None,
3838            ub_definitions: BTreeMap::new(),
3839            fields: vec![AhbFieldRule {
3840                segment_path: "SG10/STS/C556/9013".to_string(),
3841                name: "Code".to_string(),
3842                ahb_status: "X".to_string(),
3843                element_index: Some(2),
3844                component_index: Some(0),
3845                codes: vec![
3846                    AhbCodeRule {
3847                        value: "E01".into(),
3848                        description: "Code 1".into(),
3849                        ahb_status: "X [901] [4P0..1]".into(),
3850                    },
3851                    AhbCodeRule {
3852                        value: "E02".into(),
3853                        description: "Code 2".into(),
3854                        ahb_status: "X [901] [4P0..1]".into(),
3855                    },
3856                ],
3857                parent_group_ahb_status: Some("Muss".to_string()),
3858                segment_ahb_status: None,
3859                mig_number: None,
3860            }],
3861        };
3862
3863        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
3864        let pkg_errors: Vec<_> = report
3865            .by_category(ValidationCategory::Ahb)
3866            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
3867            .collect();
3868        assert!(
3869            pkg_errors.is_empty(),
3870            "1 code within [4P0..1] bounds — no error, got: {:?}",
3871            pkg_errors
3872        );
3873    }
3874
3875    #[test]
3876    fn test_package_cardinality_scoped_per_group_instance() {
3877        // Package [4P0..1] caps the number of codes per SG10 group instance at 0..1.
3878        // MSCONS PID 13025 messages contain many SG10 reps (one per interval); each
3879        // rep has its own STS+Z32++<reason> segment using one code from the package.
3880        // The old global count summed codes across reps and flagged every
3881        // multi-interval message (e.g. 96 intervals → 96 codes "allowed 0..1").
3882        // With a navigator present, package cardinality must be evaluated per
3883        // group instance, not message-wide.
3884        use mig_types::navigator::GroupNavigator;
3885
3886        struct TwoSg10s {
3887            sts_a: OwnedSegment,
3888            sts_b: OwnedSegment,
3889        }
3890        impl GroupNavigator for TwoSg10s {
3891            fn find_segments_in_group(
3892                &self,
3893                segment_id: &str,
3894                group_path: &[&str],
3895                instance_index: usize,
3896            ) -> Vec<OwnedSegment> {
3897                if group_path == ["SG5", "SG6", "SG9", "SG10"] && segment_id == "STS" {
3898                    match instance_index {
3899                        0 => vec![self.sts_a.clone()],
3900                        1 => vec![self.sts_b.clone()],
3901                        _ => vec![],
3902                    }
3903                } else {
3904                    vec![]
3905                }
3906            }
3907            fn find_segments_with_qualifier_in_group(
3908                &self,
3909                _: &str,
3910                _: usize,
3911                _: &str,
3912                _: &[&str],
3913                _: usize,
3914            ) -> Vec<OwnedSegment> {
3915                vec![]
3916            }
3917            fn group_instance_count(&self, group_path: &[&str]) -> usize {
3918                match group_path {
3919                    ["SG5"] | ["SG5", "SG6"] | ["SG5", "SG6", "SG9"] => 1,
3920                    ["SG5", "SG6", "SG9", "SG10"] => 2,
3921                    _ => 0,
3922                }
3923            }
3924        }
3925
3926        let sts_a = OwnedSegment {
3927            id: "STS".into(),
3928            elements: vec![vec!["Z32".into()], vec![], vec!["E01".into()]],
3929            segment_number: 10,
3930        };
3931        let sts_b = OwnedSegment {
3932            id: "STS".into(),
3933            elements: vec![vec!["Z32".into()], vec![], vec!["E01".into()]],
3934            segment_number: 15,
3935        };
3936        let nav = TwoSg10s {
3937            sts_a: sts_a.clone(),
3938            sts_b: sts_b.clone(),
3939        };
3940
3941        let evaluator = MockEvaluator::all_true(&[]);
3942        let validator = EdifactValidator::new(evaluator);
3943        let external = NoOpExternalProvider;
3944
3945        let workflow = AhbWorkflow {
3946            pruefidentifikator: "13025".to_string(),
3947            description: "Test".to_string(),
3948            communication_direction: None,
3949            ub_definitions: BTreeMap::new(),
3950            fields: vec![AhbFieldRule {
3951                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
3952                name: "Statusanlaß, Code".to_string(),
3953                ahb_status: "X".to_string(),
3954                element_index: Some(2),
3955                component_index: Some(0),
3956                codes: vec![
3957                    AhbCodeRule {
3958                        value: "E01".into(),
3959                        description: "Code 1".into(),
3960                        ahb_status: "X [4P0..1]".into(),
3961                    },
3962                    AhbCodeRule {
3963                        value: "E02".into(),
3964                        description: "Code 2".into(),
3965                        ahb_status: "X [4P0..1]".into(),
3966                    },
3967                ],
3968                parent_group_ahb_status: Some("Muss".to_string()),
3969                segment_ahb_status: None,
3970                mig_number: None,
3971            }],
3972        };
3973
3974        let report = validator.validate_with_navigator(
3975            &[sts_a, sts_b],
3976            &workflow,
3977            &external,
3978            ValidationLevel::Full,
3979            &nav,
3980        );
3981        let pkg_errors: Vec<_> = report
3982            .by_category(ValidationCategory::Ahb)
3983            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
3984            .collect();
3985        assert!(
3986            pkg_errors.is_empty(),
3987            "Package cardinality is per-instance: 1 code per SG10 rep is within [4P0..1]. Got: {:?}",
3988            pkg_errors
3989        );
3990    }
3991
3992    fn make_segment(id: &str, elements: Vec<Vec<&str>>) -> OwnedSegment {
3993        OwnedSegment {
3994            id: id.to_string(),
3995            elements: elements
3996                .into_iter()
3997                .map(|e| e.into_iter().map(|s| s.to_string()).collect())
3998                .collect(),
3999            segment_number: 0,
4000        }
4001    }
4002
4003    /// Package cardinality must not fire against sibling group variants.
4004    ///
4005    /// PID 55218 has two SG8 variants: `sg8_z01` (SEQ mig=00115, no
4006    /// package) and `sg8_z45_z84` (SEQ mig=00171, with `[1P1..n]`
4007    /// package on Z45/Z84). The rule is scoped to mig=00171, but the
4008    /// old per-instance loop counted codes across every SG8 instance
4009    /// (including the Z01 sibling) and fired "0 codes present" on
4010    /// instances belonging to the other variant. With
4011    /// `instance_has_mig_number` the loop skips instances whose variant
4012    /// doesn't define the rule's mig_number.
4013    #[test]
4014    fn test_package_cardinality_scoped_to_rule_mig_variant() {
4015        use mig_types::navigator::GroupNavigator;
4016
4017        struct TwoSg8Variants {
4018            seq_z01: OwnedSegment,
4019            seq_z45: OwnedSegment,
4020        }
4021        impl GroupNavigator for TwoSg8Variants {
4022            fn find_segments_in_group(
4023                &self,
4024                segment_id: &str,
4025                group_path: &[&str],
4026                instance_index: usize,
4027            ) -> Vec<OwnedSegment> {
4028                if group_path == ["SG4", "SG8"] && segment_id == "SEQ" {
4029                    match instance_index {
4030                        0 => vec![self.seq_z01.clone()],
4031                        1 => vec![self.seq_z45.clone()],
4032                        _ => vec![],
4033                    }
4034                } else {
4035                    vec![]
4036                }
4037            }
4038            fn find_segments_with_qualifier_in_group(
4039                &self,
4040                _: &str,
4041                _: usize,
4042                _: &str,
4043                _: &[&str],
4044                _: usize,
4045            ) -> Vec<OwnedSegment> {
4046                vec![]
4047            }
4048            fn group_instance_count(&self, group_path: &[&str]) -> usize {
4049                match group_path {
4050                    ["SG4"] => 1,
4051                    ["SG4", "SG8"] => 2,
4052                    _ => 0,
4053                }
4054            }
4055            fn instance_has_mig_number(
4056                &self,
4057                group_path: &[&str],
4058                instance_index: usize,
4059                mig_number: &str,
4060            ) -> bool {
4061                if group_path != ["SG4", "SG8"] {
4062                    return true;
4063                }
4064                match (instance_index, mig_number) {
4065                    (0, "00115") => true,
4066                    (0, _) => false,
4067                    (1, "00171") => true,
4068                    (1, _) => false,
4069                    _ => false,
4070                }
4071            }
4072        }
4073
4074        let seq_z01 = OwnedSegment {
4075            id: "SEQ".into(),
4076            elements: vec![vec!["Z01".into()], vec!["1".into()]],
4077            segment_number: 10,
4078        };
4079        let seq_z45 = OwnedSegment {
4080            id: "SEQ".into(),
4081            elements: vec![vec!["Z45".into()], vec!["1".into()]],
4082            segment_number: 20,
4083        };
4084        let nav = TwoSg8Variants {
4085            seq_z01: seq_z01.clone(),
4086            seq_z45: seq_z45.clone(),
4087        };
4088
4089        let evaluator = MockEvaluator::all_true(&[]);
4090        let validator = EdifactValidator::new(evaluator);
4091        let external = NoOpExternalProvider;
4092
4093        let workflow = AhbWorkflow {
4094            pruefidentifikator: "55218".to_string(),
4095            description: "Test".to_string(),
4096            communication_direction: None,
4097            ub_definitions: BTreeMap::new(),
4098            fields: vec![AhbFieldRule {
4099                segment_path: "SG4/SG8/SEQ/1229".to_string(),
4100                name: "Handlung, Code".to_string(),
4101                ahb_status: "X".to_string(),
4102                element_index: Some(0),
4103                component_index: Some(0),
4104                codes: vec![
4105                    AhbCodeRule {
4106                        value: "Z45".into(),
4107                        description: "NNA".into(),
4108                        ahb_status: "X [1P1..4294967295]".into(),
4109                    },
4110                    AhbCodeRule {
4111                        value: "Z84".into(),
4112                        description: "Differenz-NNA".into(),
4113                        ahb_status: "X [1P0..4294967295]".into(),
4114                    },
4115                ],
4116                parent_group_ahb_status: Some("Muss".to_string()),
4117                segment_ahb_status: None,
4118                mig_number: Some("00171".to_string()),
4119            }],
4120        };
4121
4122        let report = validator.validate_with_navigator(
4123            &[seq_z01, seq_z45],
4124            &workflow,
4125            &external,
4126            ValidationLevel::Full,
4127            &nav,
4128        );
4129        let pkg_errors: Vec<_> = report
4130            .by_category(ValidationCategory::Ahb)
4131            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4132            .collect();
4133        assert!(
4134            pkg_errors.is_empty(),
4135            "Package rule with mig=00171 must only count the Z45 instance (which has 1 code), not the sibling Z01 variant. Got: {:?}",
4136            pkg_errors
4137        );
4138    }
4139
4140    #[test]
4141    fn test_unt_count_correct() {
4142        // UNH + BGM + DTM + UNT = 4 segments, UNT declares 4
4143        let segments = vec![
4144            make_segment("UNH", vec![vec!["001"]]),
4145            make_segment("BGM", vec![vec!["E01"]]),
4146            make_segment("DTM", vec![vec!["137", "20250401"]]),
4147            make_segment("UNT", vec![vec!["4", "001"]]),
4148        ];
4149        assert!(
4150            validate_unt_segment_count(&segments).is_none(),
4151            "Correct count should produce no issue"
4152        );
4153    }
4154
4155    #[test]
4156    fn test_unt_count_mismatch() {
4157        // UNH + BGM + UNT = 3 segments, but UNT declares 5
4158        let segments = vec![
4159            make_segment("UNH", vec![vec!["001"]]),
4160            make_segment("BGM", vec![vec!["E01"]]),
4161            make_segment("UNT", vec![vec!["5", "001"]]),
4162        ];
4163        let issue =
4164            validate_unt_segment_count(&segments).expect("Mismatch should produce an issue");
4165        assert_eq!(issue.code(), ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH);
4166        assert_eq!(issue.severity, Severity::Error);
4167        assert!(narrate(&issue).contains("declared 5"));
4168        assert!(narrate(&issue).contains("actual 3"));
4169    }
4170
4171    #[test]
4172    fn test_unt_count_excludes_envelope() {
4173        // Envelope segments (UNA, UNB, UNZ) should not be counted
4174        let segments = vec![
4175            make_segment("UNA", vec![]),
4176            make_segment("UNB", vec![vec!["UNOC", "3"]]),
4177            make_segment("UNH", vec![vec!["001"]]),
4178            make_segment("BGM", vec![vec!["E01"]]),
4179            make_segment("UNT", vec![vec!["3", "001"]]),
4180            make_segment("UNZ", vec![vec!["1"]]),
4181        ];
4182        assert!(
4183            validate_unt_segment_count(&segments).is_none(),
4184            "Envelope segments excluded — count should be 3 (UNH+BGM+UNT)"
4185        );
4186    }
4187
4188    #[test]
4189    fn test_unt_count_no_unt_returns_none() {
4190        let segments = vec![
4191            make_segment("UNH", vec![vec!["001"]]),
4192            make_segment("BGM", vec![vec!["E01"]]),
4193        ];
4194        assert!(
4195            validate_unt_segment_count(&segments).is_none(),
4196            "No UNT segment should return None (not our problem)"
4197        );
4198    }
4199
4200    #[test]
4201    fn test_unt_count_rejects_multi_message_input() {
4202        // Two messages spliced together — should return an error
4203        let segments = vec![
4204            make_segment("UNH", vec![vec!["001"]]),
4205            make_segment("BGM", vec![vec!["E01"]]),
4206            make_segment("UNT", vec![vec!["3", "001"]]),
4207            make_segment("UNH", vec![vec!["002"]]),
4208            make_segment("BGM", vec![vec!["E02"]]),
4209            make_segment("UNT", vec![vec!["3", "002"]]),
4210        ];
4211        let issue = validate_unt_segment_count(&segments)
4212            .expect("Multi-message input should produce an error");
4213        assert_eq!(issue.code(), ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH);
4214        assert!(
4215            narrate(&issue).contains("2 UNH"),
4216            "Should mention UNH count: {}",
4217            narrate(&issue)
4218        );
4219    }
4220
4221    #[test]
4222    fn test_code_validation_accepts_multi_code_variant_qualifier() {
4223        // PID 55035 SG4/SG8 has many RFF variants — one per mig_number. The
4224        // variant with mig=00075 has TWO required codes (Z31, Z39): both are
4225        // valid qualifier values for that variant. Prior behavior only treated
4226        // buckets with exactly one required code as qualifier buckets, so
4227        // segments with Z31/Z39 fell through and were compared against the
4228        // union of *other* variants' codes — producing a false COD002.
4229        let evaluator = MockEvaluator::new(vec![]);
4230        let validator = EdifactValidator::new(evaluator);
4231        let external = NoOpExternalProvider;
4232
4233        let rff_z39 = OwnedSegment {
4234            id: "RFF".to_string(),
4235            elements: vec![
4236                vec!["RFF".to_string()],
4237                vec!["Z39".to_string(), "REF1".to_string()],
4238            ],
4239            segment_number: 1,
4240        };
4241
4242        let workflow = AhbWorkflow {
4243            pruefidentifikator: "55035".to_string(),
4244            description: "Test".to_string(),
4245            communication_direction: None,
4246            fields: vec![
4247                // Mig 00075 variant: qualifier is Z31 or Z39 (multi-code bucket)
4248                AhbFieldRule {
4249                    segment_path: "SG4/SG8/RFF/C506/1153".to_string(),
4250                    name: "Referenznummer Qualifier".to_string(),
4251                    ahb_status: "Muss".to_string(),
4252                    codes: vec![
4253                        AhbCodeRule {
4254                            value: "Z31".to_string(),
4255                            description: "".to_string(),
4256                            ahb_status: "X".to_string(),
4257                        },
4258                        AhbCodeRule {
4259                            value: "Z39".to_string(),
4260                            description: "".to_string(),
4261                            ahb_status: "X".to_string(),
4262                        },
4263                    ],
4264                    parent_group_ahb_status: None,
4265                    segment_ahb_status: None,
4266                    element_index: Some(1),
4267                    component_index: Some(0),
4268                    mig_number: Some("00075".to_string()),
4269                },
4270                // Mig 00078 variant: single-code qualifier Z33
4271                AhbFieldRule {
4272                    segment_path: "SG4/SG8/RFF/C506/1153".to_string(),
4273                    name: "Referenznummer Qualifier".to_string(),
4274                    ahb_status: "Muss".to_string(),
4275                    codes: vec![AhbCodeRule {
4276                        value: "Z33".to_string(),
4277                        description: "".to_string(),
4278                        ahb_status: "X".to_string(),
4279                    }],
4280                    parent_group_ahb_status: None,
4281                    segment_ahb_status: None,
4282                    element_index: Some(1),
4283                    component_index: Some(0),
4284                    mig_number: Some("00078".to_string()),
4285                },
4286            ],
4287            ub_definitions: BTreeMap::new(),
4288        };
4289
4290        let report = validator.validate(
4291            &[rff_z39],
4292            &workflow,
4293            &external,
4294            ValidationLevel::Conditions,
4295        );
4296
4297        let code_errors: Vec<_> = report
4298            .by_category(ValidationCategory::Code)
4299            .filter(|i| {
4300                i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
4301            })
4302            .collect();
4303        assert!(
4304            code_errors.is_empty(),
4305            "RFF+Z39 must be accepted (Z39 is valid for mig=00075). Got: {:?}",
4306            code_errors
4307        );
4308    }
4309
4310    #[test]
4311    fn test_code_validation_disambiguates_migs_by_full_code_profile() {
4312        // PID 55035 SG4/SG8 PIA variants all share element 0 code '5' as
4313        // qualifier. They differ only at composite C212/7143 (one variant
4314        // allows Z12, another allows SRW). Matching on the first single-code
4315        // field alone picks an arbitrary bucket and then rejects the other
4316        // variant's composite value — a false COD002.
4317        let evaluator = MockEvaluator::new(vec![]);
4318        let validator = EdifactValidator::new(evaluator);
4319        let external = NoOpExternalProvider;
4320
4321        // Include BOTH variants so the current bug is triggered deterministically:
4322        // match_segment_to_mig picks one bucket by HashMap iteration order, so
4323        // exactly one of {Z12, SRW} is rejected under the current logic.
4324        let pia_5_z12 = OwnedSegment {
4325            id: "PIA".to_string(),
4326            elements: vec![vec!["5".to_string()], vec!["Z12".to_string()]],
4327            segment_number: 1,
4328        };
4329        let pia_5_srw = OwnedSegment {
4330            id: "PIA".to_string(),
4331            elements: vec![vec!["5".to_string()], vec!["SRW".to_string()]],
4332            segment_number: 2,
4333        };
4334
4335        let make_rules = |mig: &str, composite_code: &str| {
4336            vec![
4337                AhbFieldRule {
4338                    segment_path: "SG4/SG8/PIA/4347".to_string(),
4339                    name: "Produkt-ID-Funktion".to_string(),
4340                    ahb_status: "Muss".to_string(),
4341                    codes: vec![AhbCodeRule {
4342                        value: "5".to_string(),
4343                        description: "".to_string(),
4344                        ahb_status: "X".to_string(),
4345                    }],
4346                    parent_group_ahb_status: None,
4347                    segment_ahb_status: None,
4348                    element_index: Some(0),
4349                    component_index: Some(0),
4350                    mig_number: Some(mig.to_string()),
4351                },
4352                AhbFieldRule {
4353                    segment_path: "SG4/SG8/PIA/C212/7143".to_string(),
4354                    name: "Artikel/Dienstleistung-Identifikator".to_string(),
4355                    ahb_status: "Muss".to_string(),
4356                    codes: vec![AhbCodeRule {
4357                        value: composite_code.to_string(),
4358                        description: "".to_string(),
4359                        ahb_status: "X".to_string(),
4360                    }],
4361                    parent_group_ahb_status: None,
4362                    segment_ahb_status: None,
4363                    element_index: Some(1),
4364                    component_index: Some(0),
4365                    mig_number: Some(mig.to_string()),
4366                },
4367            ]
4368        };
4369
4370        let mut fields = make_rules("00108", "Z12");
4371        fields.extend(make_rules("00197", "SRW"));
4372
4373        let workflow = AhbWorkflow {
4374            pruefidentifikator: "55035".to_string(),
4375            description: "Test".to_string(),
4376            communication_direction: None,
4377            fields,
4378            ub_definitions: BTreeMap::new(),
4379        };
4380
4381        let report = validator.validate(
4382            &[pia_5_z12, pia_5_srw],
4383            &workflow,
4384            &external,
4385            ValidationLevel::Conditions,
4386        );
4387
4388        let code_errors: Vec<_> = report
4389            .by_category(ValidationCategory::Code)
4390            .filter(|i| {
4391                i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
4392            })
4393            .collect();
4394        assert!(
4395            code_errors.is_empty(),
4396            "Both PIA+5+Z12 (mig=00108) and PIA+5+SRW (mig=00197) must be accepted. Got: {:?}",
4397            code_errors
4398        );
4399    }
4400}