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