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