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