Skip to main content

automapper_validation/validator/
validate.rs

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