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