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