Skip to main content

automapper_validation/validator/
validate.rs

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