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