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