Skip to main content

automapper_validation/validator/
validate.rs

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