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