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`).
1489fn should_skip_for_parent_group<E: ConditionEvaluator>(
1490    field: &AhbFieldRule,
1491    expr_eval: &ConditionExprEvaluator<E>,
1492    ctx: &EvaluationContext,
1493    ub_definitions: &BTreeMap<String, ConditionExpr>,
1494) -> bool {
1495    if let Some(ref group_status) = field.parent_group_ahb_status {
1496        if group_status.contains('[') {
1497            let result = expr_eval.evaluate_status_with_ub(group_status, ctx, ub_definitions);
1498            return matches!(result, ConditionResult::False | ConditionResult::Unknown);
1499        }
1500    }
1501    false
1502}
1503
1504/// Whether the field's parent group is required here: the status that applies
1505/// to it (the strictest of its lines that holds) is `Muss`/`X`.
1506///
1507/// `should_skip_for_parent_group` has already dropped fields whose group does
1508/// not apply at all; this tells a required group from a recommended one, which
1509/// for `Soll [47] ∧ … / Muss [46] ∧ …` depends on which line holds.
1510fn is_parent_group_required<E: ConditionEvaluator>(
1511    field: &AhbFieldRule,
1512    expr_eval: &ConditionExprEvaluator<E>,
1513    ctx: &EvaluationContext,
1514    ub_definitions: &BTreeMap<String, ConditionExpr>,
1515) -> bool {
1516    field
1517        .parent_group_ahb_status
1518        .as_deref()
1519        .is_some_and(|status| {
1520            let status = expr_eval.resolve_status(status, ctx, ub_definitions);
1521            status.result == ConditionResult::True && status.kind == Some(StatusKind::Muss)
1522        })
1523}
1524
1525/// Check if a field's required segment instance is present.
1526///
1527/// For fields with qualifier codes (e.g., `SG2/NAD/3035` with `[MR]`),
1528/// checks that a segment with one of those qualifier values exists.
1529/// Otherwise just checks that any segment with the tag is present.
1530///
1531/// This prevents false negatives where `NAD+MS` is present but the
1532/// validator incorrectly says NAD "exists" when `NAD+MR` is missing.
1533fn is_field_present(ctx: &EvaluationContext, field: &AhbFieldRule) -> bool {
1534    let segment_id = extract_segment_id(&field.segment_path);
1535
1536    // When a field has known codes, check for a segment containing one of those
1537    // specific values at the right element/component position. This applies to both
1538    // simple qualifier paths (NAD/3035) and composite paths (CCI/C240/7037) —
1539    // without this, a CCI segment from a different group variant (e.g., CCI+Z61)
1540    // would falsely satisfy the check for CCI with C240/7037 codes Z15/Z18.
1541    if !field.codes.is_empty() {
1542        if let (Some(el_idx), Some(comp_idx)) = (field.element_index, field.component_index) {
1543            let required_codes: Vec<&str> = field.codes.iter().map(|c| c.value.as_str()).collect();
1544            let matching = ctx.find_segments(&segment_id);
1545            return matching.iter().any(|seg| {
1546                seg.elements
1547                    .get(el_idx)
1548                    .and_then(|e| e.get(comp_idx))
1549                    .is_some_and(|v| required_codes.contains(&v.as_str()))
1550            });
1551        }
1552        // Fallback for fields with codes but no element/component indices:
1553        // use simple qualifier check if it's a qualifier-style path.
1554        if is_qualifier_field(&field.segment_path) {
1555            let required_codes: Vec<&str> = field.codes.iter().map(|c| c.value.as_str()).collect();
1556            let el_idx = field.element_index.unwrap_or(0);
1557            let comp_idx = field.component_index.unwrap_or(0);
1558            let matching = ctx.find_segments(&segment_id);
1559            return matching.iter().any(|seg| {
1560                seg.elements
1561                    .get(el_idx)
1562                    .and_then(|e| e.get(comp_idx))
1563                    .is_some_and(|v| required_codes.contains(&v.as_str()))
1564            });
1565        }
1566    }
1567
1568    ctx.has_segment(&segment_id)
1569}
1570
1571/// Check if the group variant for a field is absent from the message.
1572///
1573/// Uses tree-based logic:
1574/// 1. **Group entirely absent**: If the group path has 0 instances, the
1575///    whole group is absent and its children aren't required.
1576/// 2. **Optional group variant absent**: If the parent group is optional
1577///    ("Kann") and the specific qualifier variant isn't present, the variant
1578///    is absent. E.g., SG5 "Kann" with LOC+Z17 — if no SG5 instance has
1579///    LOC+Z17, all fields under that variant are skipped.
1580///
1581/// Returns `false` (not absent) when:
1582/// - The field has no group prefix (e.g., `NAD/3035`)
1583/// - No navigator is available (can't determine group presence)
1584/// - The parent group is mandatory and has instances
1585fn is_group_variant_absent(
1586    ctx: &EvaluationContext,
1587    field: &AhbFieldRule,
1588    is_group_mandatory: bool,
1589) -> bool {
1590    let group_path: Vec<&str> = field
1591        .segment_path
1592        .split('/')
1593        .take_while(|p| p.starts_with("SG"))
1594        .collect();
1595
1596    if group_path.is_empty() {
1597        return false;
1598    }
1599
1600    let nav = match ctx.navigator {
1601        Some(nav) => nav,
1602        None => return false,
1603    };
1604
1605    let instance_count = nav.group_instance_count(&group_path);
1606
1607    // Case 1: group entirely absent — only skip if the group is NOT mandatory.
1608    // When a mandatory group (parent_group_ahb_status contains "Muss" or "X")
1609    // has 0 instances, the group's absence is itself the error, so we should
1610    // NOT skip field validation — let the field-level check report the missing field.
1611    if instance_count == 0 {
1612        if !is_group_mandatory {
1613            return true;
1614        }
1615        // Mandatory group with 0 instances → don't suppress field errors
1616        return false;
1617    }
1618
1619    // Case 2: group has instances, but the specific qualifier variant may be absent.
1620    // Only applies when the parent group is not required here ("Kann", "Soll",
1621    // or "Kann [345]" whose condition holds — allowed, never demanded):
1622    // required groups ("Muss", "X") require all their qualifier variants.
1623    if field.parent_group_ahb_status.is_some() && !is_group_mandatory {
1624        // Check if the field's qualifier variant is present in any instance.
1625        if !field.codes.is_empty() && is_qualifier_field(&field.segment_path) {
1626            let segment_id = extract_segment_id(&field.segment_path);
1627            let required_codes: Vec<&str> = field.codes.iter().map(|c| c.value.as_str()).collect();
1628
1629            let any_instance_has_qualifier = (0..instance_count).any(|i| {
1630                nav.find_segments_in_group(&segment_id, &group_path, i)
1631                    .iter()
1632                    .any(|seg| {
1633                        seg.elements
1634                            .first()
1635                            .and_then(|e| e.first())
1636                            .is_some_and(|v| required_codes.contains(&v.as_str()))
1637                    })
1638            });
1639
1640            if !any_instance_has_qualifier {
1641                return true; // optional group variant absent
1642            }
1643        }
1644    }
1645
1646    // Case 3: non-entry segment absent from all group instances.
1647    // E.g., SG10 has QTY (entry) + optional STS segments. If no STS appears
1648    // in any SG10 instance but the entry segment (QTY) is present, fields
1649    // under STS are not required.
1650    //
1651    // We check `has_any_segment_in_group` to confirm the group instance is
1652    // genuinely populated (proving our target segment is a non-entry optional
1653    // one) vs a navigator that simply can't resolve segments.
1654    let segment_id = extract_segment_id(&field.segment_path);
1655    let segment_absent_from_all = (0..instance_count).all(|i| {
1656        nav.find_segments_in_group(&segment_id, &group_path, i)
1657            .is_empty()
1658    });
1659    if segment_absent_from_all {
1660        let group_has_other_segments =
1661            (0..instance_count).any(|i| nav.has_any_segment_in_group(&group_path, i));
1662        if group_has_other_segments {
1663            return true;
1664        }
1665    }
1666
1667    false
1668}
1669
1670/// Recursively collect all AhbNodes from a group node in depth-first order.
1671fn collect_nodes_depth_first<'a, 'b>(group: &'b AhbGroupNode<'a>, out: &mut Vec<&'b AhbNode<'a>>) {
1672    out.extend(group.fields.iter());
1673    for child in &group.children {
1674        collect_nodes_depth_first(child, out);
1675    }
1676}
1677
1678/// Evaluate a single `AhbNode` with the given (possibly scoped) context,
1679/// emitting AHB001/AHB003/AHB005 issues as appropriate. When `instance_index`
1680/// is `Some`, errors are tagged with it via `with_instance_index`.
1681#[allow(clippy::too_many_arguments)]
1682fn evaluate_node<E: ConditionEvaluator>(
1683    node: &AhbNode,
1684    ctx: &EvaluationContext,
1685    expr_eval: &ConditionExprEvaluator<E>,
1686    evaluator: &E,
1687    ub_definitions: &BTreeMap<String, ConditionExpr>,
1688    tag_migs: &HashMap<String, HashSet<&str>>,
1689    instance_index: Option<usize>,
1690    slot_holds: bool,
1691    report: &mut ValidationReport,
1692) {
1693    let field = node.rule;
1694
1695    // Create a field-scoped context with resolved value from the tree node.
1696    let node_ctx = ctx.with_resolved(node.value, node.segment_elements);
1697
1698    // A present value no longer than its MIG format allows (`an..512`: FTX
1699    // C108/4440). Checked whatever the condition says — the format binds
1700    // wherever the field is sent,
1701    // so before the group condition decides whether the rest applies.
1702    if let (Some(value), Some(max)) = (node.value, field.format.as_deref().and_then(max_length)) {
1703        let length = value_length(field.format.as_deref().unwrap_or_default(), value);
1704        if length > max {
1705            let mut issue = ValidationIssue::new(
1706                Severity::Error,
1707                IssueKind::ValueTooLong {
1708                    field_name: field.name.clone(),
1709                    length,
1710                    max,
1711                },
1712            )
1713            .with_field_path(&field.segment_path)
1714            .with_actual(value)
1715            .with_expected(field.format.clone().unwrap_or_default());
1716            if let Some(idx) = instance_index {
1717                issue = issue.with_instance_index(idx);
1718            }
1719            if let Some(num) = node.matched_segment_number {
1720                issue = issue.with_position(crate::SegmentPosition {
1721                    segment_number: num,
1722                    byte_offset: 0,
1723                    message_number: 1,
1724                });
1725            }
1726            report.add_issue(attach_field_position(issue, field));
1727        }
1728    }
1729
1730    // A value of a numeric format that is no number (`n..10`: RFF+Z60
1731    // `O5D2DXBG1A`). A code element is judged by its code list.
1732    if let (Some(value), Some(format)) = (node.value, field.format.as_deref()) {
1733        if slot_holds
1734            && format.trim().starts_with('n')
1735            && field.codes.is_empty()
1736            && !value.is_empty()
1737            && !crate::eval::format_validators::is_edifact_number(value)
1738        {
1739            let mut issue = ValidationIssue::new(
1740                Severity::Error,
1741                IssueKind::ValueNotNumeric {
1742                    field_name: field.name.clone(),
1743                },
1744            )
1745            .with_field_path(&field.segment_path)
1746            .with_actual(value)
1747            .with_expected(format);
1748            if let Some(idx) = instance_index {
1749                issue = issue.with_instance_index(idx);
1750            }
1751            if let Some(num) = node.matched_segment_number {
1752                issue = issue.with_position(crate::SegmentPosition {
1753                    segment_number: num,
1754                    byte_offset: 0,
1755                    message_number: 1,
1756                });
1757            }
1758            report.add_issue(attach_field_position(issue, field));
1759        }
1760    }
1761
1762    // Skip if parent group condition is not met (False or Unknown).
1763    if should_skip_for_parent_group(field, expr_eval, ctx, ub_definitions) {
1764        return;
1765    }
1766
1767    // Evaluate the field's AHB status condition using the node context
1768    // (WITH resolved_value set). A present code is judged by its own status.
1769    let status = governing_status(field, node.value);
1770    let StatusResolution {
1771        result: condition_result,
1772        kind,
1773        unknown_ids,
1774    } = expr_eval.resolve_status(status, &node_ctx, ub_definitions);
1775    let mandatory = kind == Some(StatusKind::Muss);
1776
1777    match condition_result {
1778        ConditionResult::True => {
1779            // Condition is met -- field is required/applicable.
1780            //
1781            // Normally rely on the tree's resolved value. The exception:
1782            // if assembly didn't claim the segment at all (value AND
1783            // segment_elements both absent) AND the tag has exactly one
1784            // mig variant in this PID, a flat-segment hit means the
1785            // segment IS present and assembly just failed to pick it up.
1786            // Don't double-report — structure diagnostics already warn.
1787            if mandatory && node.value.is_none() {
1788                let tag = extract_segment_id(&field.segment_path);
1789                let single_variant_present = node.segment_elements.is_none()
1790                    && tag_migs.get(&tag).is_some_and(|ms| ms.len() == 1)
1791                    && is_field_present(ctx, field);
1792                // Suppress missing-field errors when the containing segment
1793                // is optional (Kann/Soll) and absent from the instance.
1794                // "X" on a Kann segment's sub-element means "required IF
1795                // segment present"; the segment's absence is legal.
1796                let segment_optional_and_absent = node.segment_elements.is_none()
1797                    && field
1798                        .segment_ahb_status
1799                        .as_deref()
1800                        .is_some_and(is_optional_segment_status);
1801                // Likewise when the absent segment is required only under a
1802                // condition that does not hold here: a position without an
1803                // Eichgültigkeit (QUOTES 15001 `DTM+Z04`, `Muss [5] ⊻ [3] ⊻
1804                // [22]`), a position that is not the SMGW (ORDERS 17001
1805                // `FTX+Z30`, `Muss [185] ∧ [593]`).
1806                let segment_not_required_and_absent = node.segment_elements.is_none()
1807                    && field.segment_ahb_status.as_deref().is_some_and(|st| {
1808                        expr_eval
1809                            .resolve_status(st, &node_ctx, ub_definitions)
1810                            .result
1811                            != ConditionResult::True
1812                    });
1813                if !single_variant_present
1814                    && !segment_optional_and_absent
1815                    && !segment_not_required_and_absent
1816                {
1817                    let mut issue = ValidationIssue::new(
1818                        Severity::Error,
1819                        IssueKind::MissingRequiredField {
1820                            field_name: field.name.clone(),
1821                        },
1822                    )
1823                    .with_field_path(&field.segment_path)
1824                    .with_rule(&field.ahb_status);
1825                    if let Some(first_code) = field.codes.first() {
1826                        issue.expected_value = Some(first_code.value.clone());
1827                    }
1828                    if let Some(idx) = instance_index {
1829                        issue = issue.with_instance_index(idx);
1830                    }
1831                    if let Some(num) = node.matched_segment_number {
1832                        issue = issue.with_position(crate::SegmentPosition {
1833                            segment_number: num,
1834                            byte_offset: 0,
1835                            message_number: 1,
1836                        });
1837                    }
1838                    issue = attach_field_position(issue, field);
1839                    report.add_issue(issue);
1840                }
1841            }
1842        }
1843        ConditionResult::False => {
1844            // Condition not met. If mandatory AND value is present,
1845            // the value violates the condition constraint.
1846            if mandatory && node.value.is_some() {
1847                let mut issue = ValidationIssue::new(
1848                    Severity::Error,
1849                    IssueKind::FieldConditionNotSatisfied {
1850                        field_name: field.name.clone(),
1851                    },
1852                )
1853                .with_field_path(&field.segment_path)
1854                .with_rule(&field.ahb_status);
1855                if let Some(idx) = instance_index {
1856                    issue = issue.with_instance_index(idx);
1857                }
1858                if let Some(num) = node.matched_segment_number {
1859                    issue = issue.with_position(crate::SegmentPosition {
1860                        segment_number: num,
1861                        byte_offset: 0,
1862                        message_number: 1,
1863                    });
1864                }
1865                issue = attach_field_position(issue, field);
1866                report.add_issue(issue);
1867            }
1868        }
1869        ConditionResult::Unknown => {
1870            // Partition unknown IDs into external / undetermined / missing.
1871            let mut external_ids = Vec::new();
1872            let mut undetermined_ids = Vec::new();
1873            let mut missing_ids = Vec::new();
1874            for id in unknown_ids {
1875                if evaluator.is_external(id) {
1876                    external_ids.push(id);
1877                } else if evaluator.is_known(id) {
1878                    undetermined_ids.push(id);
1879                } else {
1880                    missing_ids.push(id);
1881                }
1882            }
1883
1884            let mut issue = ValidationIssue::new(
1885                Severity::Info,
1886                IssueKind::ConditionUnknown {
1887                    field_name: field.name.clone(),
1888                    unresolved: UnresolvedConditions {
1889                        external: external_ids,
1890                        undetermined: undetermined_ids,
1891                        missing: missing_ids,
1892                    },
1893                },
1894            )
1895            .with_field_path(&field.segment_path)
1896            .with_rule(&field.ahb_status);
1897            if let Some(idx) = instance_index {
1898                issue = issue.with_instance_index(idx);
1899            }
1900            if let Some(num) = node.matched_segment_number {
1901                issue = issue.with_position(crate::SegmentPosition {
1902                    segment_number: num,
1903                    byte_offset: 0,
1904                    message_number: 1,
1905                });
1906            }
1907            issue = attach_field_position(issue, field);
1908            report.add_issue(issue);
1909        }
1910    }
1911}
1912
1913/// Attach UCD `S011` position info derived from the AHB field rule's
1914/// `element_index` / `component_index`. Both are 0-based array indices
1915/// in the parsed segment; CONTRL DE0098 / DE0104 are 1-based and
1916/// DE0098 counts the segment identifier as position 1.
1917fn attach_field_position(issue: ValidationIssue, field: &AhbFieldRule) -> ValidationIssue {
1918    match field.element_index {
1919        Some(el) => {
1920            let element_pos = (el as u32) + 2;
1921            let component_pos = field.component_index.map(|c| (c as u32) + 1);
1922            issue.with_field_position(element_pos, component_pos)
1923        }
1924        None => issue,
1925    }
1926}
1927
1928/// The groups and segments of one transaction that its AHB allows once per
1929/// transaction (`[2061]`, see [`crate::eval::binds_once`]) but that it sends
1930/// more often: two SG8 SEQ+Z98 "Informative Daten der Marktlokation", two
1931/// STS+E01. Group variants are told apart by their entry segment's MIG number,
1932/// segments by the qualifier their slot's rule names; a segment whose status is
1933/// its group's is counted with the group.
1934fn report_repeated_in_transaction(
1935    transaction: &AhbGroupNode,
1936    transaction_index: usize,
1937    once: u32,
1938    ctx: &EvaluationContext,
1939    report: &mut ValidationReport,
1940) {
1941    // (path, variant) -> (count, status)
1942    let mut counts: BTreeMap<(String, String), (usize, String)> = BTreeMap::new();
1943    fn visit<'a>(
1944        node: &'a AhbGroupNode<'a>,
1945        path: &mut Vec<(&'a str, usize)>,
1946        once: u32,
1947        ctx: &EvaluationContext,
1948        counts: &mut BTreeMap<(String, String), (usize, String)>,
1949    ) {
1950        let ids: Vec<&str> = path.iter().map(|(g, _)| *g).collect();
1951        let mut seen: HashSet<Option<&str>> = HashSet::new();
1952        for n in &node.fields {
1953            let rule = n.rule;
1954            let Some(status) = rule.segment_ahb_status.as_deref() else {
1955                continue;
1956            };
1957            if Some(status) == node.ahb_status
1958                || rule.element_index.unwrap_or(0) != 0
1959                || rule.component_index.unwrap_or(0) != 0
1960                || rule.codes.is_empty()
1961                || !crate::eval::binds_once(status, once)
1962                || !seen.insert(rule.mig_number.as_deref())
1963            {
1964                continue;
1965            }
1966            let tag = extract_segment_id(&rule.segment_path);
1967            let sent = ctx.navigator().map_or(0, |nav| {
1968                nav.find_segments_in_instance(&tag, path)
1969                    .iter()
1970                    .filter(|s| rule.codes.iter().any(|c| c.value == s.get_component(0, 0)))
1971                    .count()
1972            });
1973            let entry = counts
1974                .entry((
1975                    format!("{}/{tag}", ids.join("/")),
1976                    rule.mig_number.clone().unwrap_or_default(),
1977                ))
1978                .or_insert((0, status.to_string()));
1979            entry.0 += sent;
1980        }
1981        for child in &node.children {
1982            if let Some(status) = child
1983                .ahb_status
1984                .filter(|s| crate::eval::binds_once(s, once))
1985            {
1986                let variant = child
1987                    .fields
1988                    .first()
1989                    .and_then(|n| n.rule.mig_number.clone())
1990                    .unwrap_or_default();
1991                let entry = counts
1992                    .entry((format!("{}/{}", ids.join("/"), child.group_id), variant))
1993                    .or_insert((0, status.to_string()));
1994                entry.0 += 1;
1995            }
1996            path.push((child.group_id, child.instance_index));
1997            visit(child, path, once, ctx, counts);
1998            path.pop();
1999        }
2000    }
2001    let mut path = vec![(transaction.group_id, transaction_index)];
2002    visit(transaction, &mut path, once, ctx, &mut counts);
2003    for ((path, _), (count, status)) in counts {
2004        if count > 1 {
2005            report.add_issue(
2006                ValidationIssue::new(
2007                    Severity::Error,
2008                    IssueKind::RepeatedInTransaction {
2009                        path: path.clone(),
2010                        count,
2011                    },
2012                )
2013                .with_field_path(path)
2014                .with_rule(status)
2015                .with_instance_index(transaction_index),
2016            );
2017        }
2018    }
2019}
2020
2021/// The segments of `group` sent although their own condition does not hold
2022/// (#258): `CAV+Z56` (Wärmenutzung, `Muss [239]`) without CAV+Z65 in its SG10.
2023/// A segment counts when the qualifier rule of its slot finds its code there
2024/// — that identifies the slot among same-tag segments — and is reported once. A
2025/// segment whose status is the group's own is the group check's business.
2026/// The message's root segments come with no group (`group_status` `None`).
2027#[allow(clippy::too_many_arguments)]
2028fn refuse_segments<E: ConditionEvaluator>(
2029    fields: &[AhbNode],
2030    group_status: Option<&str>,
2031    path: &[(&str, usize)],
2032    scoped_ctx: &EvaluationContext,
2033    evaluator: &E,
2034    ub_definitions: &BTreeMap<String, ConditionExpr>,
2035    instance_index: usize,
2036    report: &mut ValidationReport,
2037) {
2038    let mut seen: Vec<u32> = Vec::new();
2039    for node in fields {
2040        let rule = node.rule;
2041        let (Some(status), Some(value), Some(number)) = (
2042            rule.segment_ahb_status.as_deref(),
2043            node.value,
2044            node.matched_segment_number,
2045        ) else {
2046            continue;
2047        };
2048        // Only the qualifier (element 0, component 0) names the slot: a
2049        // format code such as DTM's `303` is shared by DTM+92 and DTM+93.
2050        let qualifier =
2051            rule.element_index.unwrap_or(0) == 0 && rule.component_index.unwrap_or(0) == 0;
2052        if !qualifier
2053            || seen.contains(&number)
2054            || Some(status) == group_status
2055            || !rule.codes.iter().any(|c| c.value == value)
2056        {
2057            continue;
2058        }
2059        seen.push(number);
2060        if !crate::eval::refuses_presence(status, evaluator, scoped_ctx, ub_definitions) {
2061            continue;
2062        }
2063        let mut segment: Vec<&str> = path.iter().map(|(g, _)| *g).collect();
2064        segment.extend(rule.segment_path.split('/').find(|p| !p.starts_with("SG")));
2065        report.add_issue(
2066            ValidationIssue::new(
2067                Severity::Warning,
2068                IssueKind::SegmentNotAllowed {
2069                    segment: segment.join("/"),
2070                },
2071            )
2072            .with_field_path(segment.join("/"))
2073            .with_rule(status)
2074            .with_instance_index(instance_index)
2075            .with_position(crate::SegmentPosition {
2076                segment_number: number,
2077                byte_offset: 0,
2078                message_number: 1,
2079            }),
2080        );
2081    }
2082}
2083
2084/// Recursively walk a group instance (and its children) evaluating every
2085/// `AhbNode` in the scope of the instance it belongs to.
2086///
2087/// `path` addresses `group` by its repetition at every level from the root
2088/// (`[("SG4", 1), ("SG8", 2)]`); each child instance is walked with the path
2089/// one level deeper, so "in dieser SG8" means this SG8, not every SG8 of the
2090/// first SG4. `instance_index` stays the top-level repetition, which is what
2091/// issues report.
2092#[allow(clippy::too_many_arguments)]
2093fn walk_group_instance<E: ConditionEvaluator>(
2094    group: &AhbGroupNode,
2095    path: &[(&str, usize)],
2096    scoped_ctx: &EvaluationContext,
2097    expr_eval: &ConditionExprEvaluator<E>,
2098    evaluator: &E,
2099    ub_definitions: &BTreeMap<String, ConditionExpr>,
2100    tag_migs: &HashMap<String, HashSet<&str>>,
2101    instance_index: usize,
2102    report_segments: bool,
2103    report: &mut ValidationReport,
2104) {
2105    // A group sent although its condition does not hold (a warning while the
2106    // check proves itself). Its fields are then skipped as before.
2107    let mut group_refused = false;
2108    if let Some(status) = group.ahb_status {
2109        if crate::eval::refuses_presence(status, evaluator, scoped_ctx, ub_definitions) {
2110            group_refused = true;
2111            let group_path: Vec<&str> = path.iter().map(|(g, _)| *g).collect();
2112            let mut issue = ValidationIssue::new(
2113                Severity::Warning,
2114                IssueKind::GroupNotAllowed {
2115                    group: group_path.join("/"),
2116                },
2117            )
2118            .with_field_path(group_path.join("/"))
2119            .with_rule(status)
2120            .with_instance_index(instance_index);
2121            if let Some(num) = group.fields.iter().find_map(|n| n.matched_segment_number) {
2122                issue = issue.with_position(crate::SegmentPosition {
2123                    segment_number: num,
2124                    byte_offset: 0,
2125                    message_number: 1,
2126                });
2127            }
2128            report.add_issue(issue);
2129        }
2130    }
2131    if report_segments && !group_refused {
2132        refuse_segments(
2133            &group.fields,
2134            group.ahb_status,
2135            path,
2136            scoped_ctx,
2137            evaluator,
2138            ub_definitions,
2139            instance_index,
2140            report,
2141        );
2142    }
2143    // Nodes bound to a segment their slot's qualifier rule does not name: a
2144    // repeated CAV+Z30 taken for the CAV+MIW after it (55043). What that slot
2145    // says about its values does not apply to them.
2146    let slot_codes: HashMap<Option<&str>, &[AhbCodeRule]> = group
2147        .fields
2148        .iter()
2149        .filter(|n| {
2150            n.rule.element_index.unwrap_or(0) == 0
2151                && n.rule.component_index.unwrap_or(0) == 0
2152                && !n.rule.codes.is_empty()
2153        })
2154        .map(|n| (n.rule.mig_number.as_deref(), n.rule.codes.as_slice()))
2155        .collect();
2156    let holds = |node: &AhbNode| {
2157        let (Some(codes), Some(elements)) = (
2158            slot_codes.get(&node.rule.mig_number.as_deref()),
2159            node.segment_elements,
2160        ) else {
2161            return true;
2162        };
2163        let qualifier = elements
2164            .first()
2165            .and_then(|e| e.first())
2166            .map(String::as_str)
2167            .unwrap_or_default();
2168        codes.iter().any(|c| c.value == qualifier)
2169    };
2170    for node in &group.fields {
2171        evaluate_node(
2172            node,
2173            scoped_ctx,
2174            expr_eval,
2175            evaluator,
2176            ub_definitions,
2177            tag_migs,
2178            Some(instance_index),
2179            holds(node),
2180            report,
2181        );
2182    }
2183    for child in &group.children {
2184        let mut child_path = path.to_vec();
2185        child_path.push((child.group_id, child.instance_index));
2186        let child_ctx = scoped_ctx.with_scope(GroupScope { path: &child_path });
2187        walk_group_instance(
2188            child,
2189            &child_path,
2190            &child_ctx,
2191            expr_eval,
2192            evaluator,
2193            ub_definitions,
2194            tag_migs,
2195            instance_index,
2196            report_segments,
2197            report,
2198        );
2199    }
2200}
2201
2202/// Recursively collect all Package nodes from a condition expression tree.
2203/// Report the child groups `group` lacks although they are required in it:
2204/// the variants its kind lists ([`AhbGroupNode::absent_children`]) whose
2205/// group status and own status hold here, judged in this instance's scope.
2206/// The message-wide check for absent variants asks whether *any* such segment
2207/// is in the message, so another transaction's stood in for this one's (a
2208/// UTILMD list's `SEQ+Z22` for a Vorgang's, #343). Descends into the present
2209/// child groups; a group sent although its condition does not hold demands
2210/// nothing.
2211#[allow(clippy::too_many_arguments)]
2212fn report_absent_children<E: ConditionEvaluator>(
2213    group: &AhbGroupNode,
2214    path: &[(&str, usize)],
2215    scoped_ctx: &EvaluationContext,
2216    absent_eval: &ConditionExprEvaluator<crate::eval::AbsentTarget<'_, E>>,
2217    evaluator: &E,
2218    ub_definitions: &BTreeMap<String, ConditionExpr>,
2219    instance_index: usize,
2220    report: &mut ValidationReport,
2221) {
2222    if group.ahb_status.is_some_and(|status| {
2223        crate::eval::refuses_presence(status, evaluator, scoped_ctx, ub_definitions)
2224    }) {
2225        return;
2226    }
2227    for rule in &group.absent_children {
2228        if should_skip_for_parent_group(rule, absent_eval, scoped_ctx, ub_definitions)
2229            || !is_parent_group_required(rule, absent_eval, scoped_ctx, ub_definitions)
2230        {
2231            continue;
2232        }
2233        let required = |status: &str| {
2234            let resolved = absent_eval.resolve_status(status, scoped_ctx, ub_definitions);
2235            resolved.result == ConditionResult::True && resolved.kind == Some(StatusKind::Muss)
2236        };
2237        if !required(&rule.ahb_status)
2238            || rule
2239                .segment_ahb_status
2240                .as_deref()
2241                .is_some_and(|status| !required(status))
2242        {
2243            continue;
2244        }
2245        let mut issue = ValidationIssue::new(
2246            Severity::Error,
2247            IssueKind::MissingRequiredField {
2248                field_name: rule.name.clone(),
2249            },
2250        )
2251        .with_field_path(&rule.segment_path)
2252        .with_rule(&rule.ahb_status)
2253        .with_instance_index(instance_index);
2254        if let Some(first_code) = rule.codes.first() {
2255            issue.expected_value = Some(first_code.value.clone());
2256        }
2257        report.add_issue(attach_field_position(issue, rule));
2258    }
2259    for child in &group.children {
2260        let mut child_path = path.to_vec();
2261        child_path.push((child.group_id, child.instance_index));
2262        let child_ctx = scoped_ctx.with_scope(GroupScope { path: &child_path });
2263        report_absent_children(
2264            child,
2265            &child_path,
2266            &child_ctx,
2267            absent_eval,
2268            evaluator,
2269            ub_definitions,
2270            instance_index,
2271            report,
2272        );
2273    }
2274}
2275
2276fn collect_packages(expr: &ConditionExpr, out: &mut Vec<(u32, u32, u32)>) {
2277    match expr {
2278        ConditionExpr::Package { id, min, max } => {
2279            out.push((*id, *min, *max));
2280        }
2281        ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
2282            for e in exprs {
2283                collect_packages(e, out);
2284            }
2285        }
2286        ConditionExpr::Xor(left, right) => {
2287            collect_packages(left, out);
2288            collect_packages(right, out);
2289        }
2290        ConditionExpr::Not(inner) => {
2291            collect_packages(inner, out);
2292        }
2293        ConditionExpr::Ref(_) => {}
2294    }
2295}
2296
2297/// Check if a segment-level AHB status indicates an optional segment.
2298///
2299/// "Kann" / "Kann [x]" — always optional: absence is legal.
2300/// "Soll" / "Soll [x]" — should-be: absence is tolerated (not an error).
2301/// "Muss" (with or without conditions) — required; absence must be reported.
2302///
2303/// Used to suppress AHB001 missing-field errors on sub-elements of a segment
2304/// that is both optional at segment-level and absent from the assembled
2305/// instance: "X" on such a sub-element means "required IF segment present".
2306fn is_optional_segment_status(status: &str) -> bool {
2307    let trimmed = status.trim();
2308    trimmed.starts_with("Kann") || trimmed.starts_with("Soll")
2309}
2310
2311/// Check if a field path points to a simple qualifier element (element[0] of the segment).
2312///
2313/// Returns `true` for paths like `[SG/]*/SEG/ELEMENT` where the data element is
2314/// directly under the segment (no composite wrapper). These fields have their code
2315/// in `element[0][0]` and can be validated.
2316///
2317/// Also accepts composite paths like `SEG/COMPOSITE/ELEMENT` (e.g., `STS/C556/9013`).
2318/// The `element_index` and `component_index` on the field rule carry the exact
2319/// position, so composites can be checked just like simple elements.
2320fn is_qualifier_field(path: &str) -> bool {
2321    let parts: Vec<&str> = path.split('/').filter(|p| !p.starts_with("SG")).collect();
2322    // [SEG, ELEMENT] for simple data elements; [SEG, COMPOSITE, ELEMENT] for composites.
2323    matches!(parts.len(), 2 | 3)
2324}
2325
2326/// Per-mig code constraints at one (group_key, tag) location.
2327///
2328/// A bucket represents one mig_number (or the `None` bucket when rules lack
2329/// mig numbers). Each bucket tracks its qualifier position and the set of
2330/// valid qualifier values there, so multiple single-code rules under the same
2331/// mig key (e.g. two distinct NAD qualifiers both under mig=None in a test
2332/// fixture) all contribute as valid qualifier matches.
2333#[derive(Default)]
2334struct MigCodeBucket<'a> {
2335    /// The position (element_index, component_index) treated as the discriminator
2336    /// for this bucket — the first single-code required field seen in the workflow.
2337    qualifier_position: Option<(usize, usize)>,
2338    /// Valid qualifier values at `qualifier_position`. Empty if no single-code
2339    /// required field exists for this bucket.
2340    qualifier_values: HashSet<&'a str>,
2341    /// Allowed codes for this bucket, keyed by `(element_index, component_index)`.
2342    /// Includes the qualifier position itself.
2343    codes: HashMap<(usize, usize), HashSet<&'a str>>,
2344}
2345
2346/// Partition workflow code rules by `(group_key, tag, mig_number)`. Without this
2347/// split, codes from multiple migs sharing a tag (e.g. STS+7 mig 00035 and
2348/// STS+E01 mig 00036 in PID 55018) get unioned and every segment is checked
2349/// against codes that only apply to one mig.
2350fn partition_codes_by_mig(
2351    workflow: &AhbWorkflow,
2352) -> HashMap<(String, String), HashMap<Option<String>, MigCodeBucket<'_>>> {
2353    let mut out: HashMap<(String, String), HashMap<Option<String>, MigCodeBucket>> = HashMap::new();
2354    for field in &workflow.fields {
2355        if field.codes.is_empty() || !is_qualifier_field(&field.segment_path) {
2356            continue;
2357        }
2358        // The codes do not restrict an element they are alternatives to: a
2359        // value they do not list is judged by the element's own status.
2360        if codes_are_alternatives(field) {
2361            continue;
2362        }
2363        let tag = extract_segment_id(&field.segment_path);
2364        let group_key = extract_group_path_key(&field.segment_path);
2365        let mig = field.mig_number.clone();
2366        let el = field.element_index.unwrap_or(0);
2367        let c = field.component_index.unwrap_or(0);
2368
2369        let bucket = out
2370            .entry((group_key, tag))
2371            .or_default()
2372            .entry(mig)
2373            .or_default();
2374
2375        // A code is "allowed for this PID" whenever its AHB status declares it
2376        // usable — whether unconditionally (`X`, `Muss`) or under a condition
2377        // (`X [35]`, `X [35] ∨ ([32] ∧ [77])`). Matching only the exact string
2378        // `"X"` drops every conditionally-allowed code, causing COD002 false
2379        // positives on fields like MSCONS QTY 6063 (220 unconditional, 67/Z18
2380        // conditional).
2381        let required: Vec<&str> = field
2382            .codes
2383            .iter()
2384            .filter(|code| code.ahb_status.starts_with('X') || code.ahb_status.starts_with("Muss"))
2385            .map(|code| code.value.as_str())
2386            .collect();
2387
2388        // The first code-bearing field locks in the qualifier position; any
2389        // further required codes at that position contribute additional valid
2390        // qualifier values. A bucket whose entry qualifier permits multiple
2391        // codes (e.g. PID 55035 SG8/RFF mig=00075 allows {Z31, Z39}) must be
2392        // matchable by any of them — without this, segments with Z39 fall
2393        // through to the union-of-other-migs path and produce a false COD002.
2394        if !required.is_empty() {
2395            if bucket.qualifier_position.is_none() {
2396                bucket.qualifier_position = Some((el, c));
2397            }
2398            if bucket.qualifier_position == Some((el, c)) {
2399                bucket.qualifier_values.extend(required.iter().copied());
2400            }
2401        }
2402
2403        for v in required {
2404            bucket.codes.entry((el, c)).or_default().insert(v);
2405        }
2406    }
2407    out
2408}
2409
2410/// For each tag, collect every qualifier value used anywhere in the workflow,
2411/// keyed by qualifier position. This lets code validation distinguish "segment
2412/// has an unknown qualifier value" (truly invalid — report) from "segment has
2413/// a valid qualifier used at a different location than the current bucket"
2414/// (belongs elsewhere — skip at this location).
2415fn global_qualifiers_by_tag(
2416    by_loc: &HashMap<(String, String), HashMap<Option<String>, MigCodeBucket<'_>>>,
2417) -> HashMap<String, HashMap<(usize, usize), HashSet<String>>> {
2418    let mut out: HashMap<String, HashMap<(usize, usize), HashSet<String>>> = HashMap::new();
2419    for ((_group, tag), migs) in by_loc {
2420        let tag_entry = out.entry(tag.clone()).or_default();
2421        for bucket in migs.values() {
2422            if let Some(pos) = bucket.qualifier_position {
2423                tag_entry
2424                    .entry(pos)
2425                    .or_default()
2426                    .extend(bucket.qualifier_values.iter().map(|s| s.to_string()));
2427            }
2428        }
2429    }
2430    out
2431}
2432
2433/// Find the bucket that best matches this segment.
2434///
2435/// A bucket is a candidate if its `qualifier_values` contain the segment's
2436/// value at `qualifier_position`. Among candidates, the one with the most
2437/// additional code positions matching the segment wins. This disambiguates
2438/// migs that share the same primary qualifier but differ on a secondary
2439/// code position (e.g. PID 55035 SG8/PIA variants all use 4347='5' but
2440/// differ on C212/7143={Z12} vs {SRW}).
2441///
2442/// Returns `None` if no bucket's qualifier matches the segment.
2443fn match_segment_to_mig<'a, 'b>(
2444    seg: &OwnedSegment,
2445    migs: &'a HashMap<Option<String>, MigCodeBucket<'b>>,
2446) -> Option<&'a MigCodeBucket<'b>> {
2447    let actual_at = |el: usize, c: usize| -> &str {
2448        seg.elements
2449            .get(el)
2450            .and_then(|e| e.get(c))
2451            .map(|s| s.as_str())
2452            .unwrap_or("")
2453    };
2454
2455    let mut best: Option<&MigCodeBucket> = None;
2456    let mut best_matches = 0usize;
2457
2458    for bucket in migs.values() {
2459        let Some((el, c)) = bucket.qualifier_position else {
2460            continue;
2461        };
2462        if !bucket.qualifier_values.contains(actual_at(el, c)) {
2463            continue;
2464        }
2465        let extra_matches = bucket
2466            .codes
2467            .iter()
2468            .filter(|(pos, _)| **pos != (el, c))
2469            .filter(|((e, k), allowed)| {
2470                let v = actual_at(*e, *k);
2471                !v.is_empty() && allowed.contains(v)
2472            })
2473            .count();
2474        if best.is_none() || extra_matches > best_matches {
2475            best = Some(bucket);
2476            best_matches = extra_matches;
2477        }
2478    }
2479    best
2480}
2481
2482/// Extract the group path prefix from a field path.
2483///
2484/// `"SG2/NAD/3035"` → `"SG2"`, `"SG4/SG12/NAD/3035"` → `"SG4/SG12"`,
2485/// `"NAD/3035"` → `""` (no group prefix).
2486fn extract_group_path_key(path: &str) -> String {
2487    let sg_parts: Vec<&str> = path
2488        .split('/')
2489        .take_while(|p| p.starts_with("SG"))
2490        .collect();
2491    sg_parts.join("/")
2492}
2493
2494/// Extract the segment ID from a field path like "SG2/NAD/C082/3039" -> "NAD".
2495fn extract_segment_id(path: &str) -> String {
2496    for part in path.split('/') {
2497        // Skip segment group identifiers and composite/element identifiers
2498        if part.starts_with("SG") || part.starts_with("C_") || part.starts_with("D_") {
2499            continue;
2500        }
2501        // Return first 3-letter uppercase segment identifier
2502        if part.len() >= 3
2503            && part
2504                .chars()
2505                .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
2506        {
2507            return part.to_string();
2508        }
2509    }
2510    // Fallback: return the last part
2511    path.split('/').next_back().unwrap_or(path).to_string()
2512}
2513
2514/// Validate that the UNT segment count matches the actual number of segments.
2515///
2516/// The UNT segment's first data element (D0074) declares the number of segments
2517/// in the message, counting from UNH to UNT inclusive. This function compares
2518/// that declared value against the actual segment count.
2519///
2520/// Pass per-message segments (from [`MessageChunk::message_segments()`] or
2521/// [`MessageChunk::all_segments()`]), not the full interchange. For multi-message
2522/// interchanges, call once per message.
2523///
2524/// Returns `Some(ValidationIssue)` if there's a mismatch, `None` if correct.
2525pub fn validate_unt_segment_count(segments: &[OwnedSegment]) -> Option<ValidationIssue> {
2526    // Reject multi-message input — counting would be incorrect.
2527    let unh_count = segments.iter().filter(|s| s.id == "UNH").count();
2528    if unh_count > 1 {
2529        return Some(ValidationIssue::new(
2530            Severity::Error,
2531            IssueKind::UntCountNotVerifiable { unh_count },
2532        ));
2533    }
2534
2535    // Find the UNT segment
2536    let unt = segments.iter().rfind(|s| s.id == "UNT")?;
2537    let declared: usize = unt.get_element(0).parse().ok()?;
2538
2539    // Count segments from UNH to UNT inclusive
2540    let actual = segments
2541        .iter()
2542        .filter(|s| s.id != "UNA" && s.id != "UNB" && s.id != "UNZ")
2543        .count();
2544
2545    if declared != actual {
2546        Some(
2547            ValidationIssue::new(
2548                Severity::Error,
2549                IssueKind::UntSegmentCountMismatch { declared, actual },
2550            )
2551            .with_field_path("UNT/0074")
2552            .with_expected(actual.to_string())
2553            .with_actual(declared.to_string()),
2554        )
2555    } else {
2556        None
2557    }
2558}
2559
2560#[cfg(test)]
2561mod tests {
2562
2563    #[test]
2564    fn a_numeric_length_counts_digits_only() {
2565        assert_eq!(value_length("n..15", "-12.50"), 4);
2566        assert_eq!(value_length("n..6", "JMMHAYJXGIJT"), 12);
2567        assert_eq!(value_length("n5", "55001"), 5);
2568        assert_eq!(value_length("an..3", "Z.1"), 3);
2569        assert_eq!(value_length("an..35", "Müller"), 6);
2570    }
2571    use super::super::codes::ErrorCodes;
2572    use super::super::issue::ValidationCategory;
2573    use super::*;
2574    use crate::display::{EdifactView, IssueNarrator, IssueView, TechnicalNarrator};
2575    use crate::eval::{ConditionResult as CR, NoOpExternalProvider};
2576    use std::collections::HashMap;
2577
2578    /// Narrate an issue the way the EDIFACT view would, for assertions that
2579    /// used to read `.message` before Task 5 removed it.
2580    fn narrate(issue: &ValidationIssue) -> String {
2581        TechnicalNarrator.describe(issue, EdifactView.location(issue).as_deref())
2582    }
2583
2584    /// Mock evaluator for testing the validator.
2585    struct MockEvaluator {
2586        results: HashMap<u32, CR>,
2587    }
2588
2589    impl MockEvaluator {
2590        fn new(results: Vec<(u32, CR)>) -> Self {
2591            Self {
2592                results: results.into_iter().collect(),
2593            }
2594        }
2595
2596        fn all_true(ids: &[u32]) -> Self {
2597            Self::new(ids.iter().map(|&id| (id, CR::True)).collect())
2598        }
2599    }
2600
2601    impl ConditionEvaluator for MockEvaluator {
2602        fn evaluate(&self, condition: u32, _ctx: &EvaluationContext) -> CR {
2603            self.results.get(&condition).copied().unwrap_or(CR::Unknown)
2604        }
2605        fn is_external(&self, _condition: u32) -> bool {
2606            false
2607        }
2608        fn message_type(&self) -> &str {
2609            "UTILMD"
2610        }
2611        fn format_version(&self) -> &str {
2612            "FV2510"
2613        }
2614    }
2615
2616    // === Helper function tests ===
2617
2618    #[test]
2619    fn a_present_code_is_judged_by_its_own_status() {
2620        // 55010 STS Transaktionsgrund: the schema gives the element E01's status.
2621        let code = |value: &str, status: &str| AhbCodeRule {
2622            value: value.to_string(),
2623            description: String::new(),
2624            ahb_status: status.to_string(),
2625        };
2626        let field = AhbFieldRule {
2627            segment_path: "SG4/STS/C556/9013".to_string(),
2628            ahb_status: "X [192]".to_string(),
2629            codes: vec![code("E01", "X [192]"), code("E03", "X")],
2630            ..Default::default()
2631        };
2632        assert_eq!(governing_status(&field, Some("E03")), "X");
2633        assert_eq!(governing_status(&field, Some("E01")), "X [192]");
2634        assert_eq!(governing_status(&field, None), "X [192]");
2635        assert_eq!(governing_status(&field, Some("ZZZ")), "X [192]");
2636
2637        // An element with a condition of its own keeps it.
2638        let own = AhbFieldRule {
2639            ahb_status: "X [931]".to_string(),
2640            codes: vec![code("303", "X")],
2641            ..Default::default()
2642        };
2643        assert_eq!(governing_status(&own, Some("303")), "X [931]");
2644        assert!(!codes_are_alternatives(&own));
2645
2646        // IFTSTA 21025 STS 9013 (#319): the element status admits E_0286's
2647        // answers, the code rows their codes without DE1131.
2648        let alternatives = AhbFieldRule {
2649            ahb_status: "X [153] ∧ [538]".to_string(),
2650            codes: vec![
2651                code("Z74", "X [103] ∧ [537]"),
2652                code("Z75", "X [103] ∧ [537]"),
2653            ],
2654            ..Default::default()
2655        };
2656        assert!(codes_are_alternatives(&alternatives));
2657        assert_eq!(
2658            governing_status(&alternatives, Some("Z74")),
2659            "X [103] ∧ [537]"
2660        );
2661        assert_eq!(
2662            governing_status(&alternatives, Some("A01")),
2663            "X [153] ∧ [538]"
2664        );
2665        assert_eq!(governing_status(&alternatives, None), "X [153] ∧ [538]");
2666
2667        // CONTRL 0013: unconditional code rows only restrict the element.
2668        let restricting = AhbFieldRule {
2669            ahb_status: "X [2] ∨ [3]".to_string(),
2670            codes: vec![code("UNH", "X"), code("UNT", "X")],
2671            ..Default::default()
2672        };
2673        assert!(!codes_are_alternatives(&restricting));
2674        assert_eq!(governing_status(&restricting, Some("UNH")), "X [2] ∨ [3]");
2675    }
2676
2677    #[test]
2678    fn test_extract_segment_id_simple() {
2679        assert_eq!(extract_segment_id("NAD"), "NAD");
2680    }
2681
2682    #[test]
2683    fn test_extract_segment_id_with_sg_prefix() {
2684        assert_eq!(extract_segment_id("SG2/NAD/C082/3039"), "NAD");
2685    }
2686
2687    #[test]
2688    fn test_extract_segment_id_nested_sg() {
2689        assert_eq!(extract_segment_id("SG4/SG8/SEQ/C286/6350"), "SEQ");
2690    }
2691
2692    // === Validator tests with mock data ===
2693
2694    #[test]
2695    fn test_validate_missing_mandatory_field() {
2696        let evaluator = MockEvaluator::all_true(&[182, 152]);
2697        let validator = EdifactValidator::new(evaluator);
2698        let external = NoOpExternalProvider;
2699
2700        let workflow = AhbWorkflow {
2701            pruefidentifikator: "11001".to_string(),
2702            description: "Test".to_string(),
2703            communication_direction: None,
2704            fields: vec![AhbFieldRule {
2705                segment_path: "SG2/NAD/C082/3039".to_string(),
2706                name: "MP-ID des MSB".to_string(),
2707                ahb_status: "Muss [182] ∧ [152]".to_string(),
2708                codes: vec![],
2709                parent_group_ahb_status: None,
2710                segment_ahb_status: None,
2711                ..Default::default()
2712            }],
2713            ub_definitions: BTreeMap::new(),
2714            package_conditions: Default::default(),
2715        };
2716
2717        // Validate with no segments
2718        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2719
2720        // Should have an error for missing mandatory field
2721        assert!(!report.is_valid());
2722        let errors: Vec<_> = report.errors().collect();
2723        assert_eq!(errors.len(), 1);
2724        assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
2725        assert!(narrate(errors[0]).contains("MP-ID des MSB"));
2726    }
2727
2728    #[test]
2729    fn test_validate_condition_false_no_error() {
2730        // When condition evaluates to False, field is not required
2731        let evaluator = MockEvaluator::new(vec![(182, CR::True), (152, CR::False)]);
2732        let validator = EdifactValidator::new(evaluator);
2733        let external = NoOpExternalProvider;
2734
2735        let workflow = AhbWorkflow {
2736            pruefidentifikator: "11001".to_string(),
2737            description: "Test".to_string(),
2738            communication_direction: None,
2739            fields: vec![AhbFieldRule {
2740                segment_path: "NAD".to_string(),
2741                name: "Partnerrolle".to_string(),
2742                ahb_status: "Muss [182] ∧ [152]".to_string(),
2743                codes: vec![],
2744                parent_group_ahb_status: None,
2745                segment_ahb_status: None,
2746                ..Default::default()
2747            }],
2748            ub_definitions: BTreeMap::new(),
2749            package_conditions: Default::default(),
2750        };
2751
2752        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2753
2754        // Condition is false, so field is not required - no error
2755        assert!(report.is_valid());
2756    }
2757
2758    /// Validate an absent field with `status`, conditions set as given.
2759    fn validate_absent_field(status: &str, conditions: Vec<(u32, CR)>) -> ValidationReport {
2760        let validator = EdifactValidator::new(MockEvaluator::new(conditions));
2761        let workflow = AhbWorkflow {
2762            pruefidentifikator: "11001".to_string(),
2763            description: "Test".to_string(),
2764            communication_direction: None,
2765            fields: vec![AhbFieldRule {
2766                segment_path: "SG2/NAD/C082/3039".to_string(),
2767                name: "MP-ID".to_string(),
2768                ahb_status: status.to_string(),
2769                ..Default::default()
2770            }],
2771            ub_definitions: BTreeMap::new(),
2772            package_conditions: Default::default(),
2773        };
2774        validator.validate(
2775            &[],
2776            &workflow,
2777            &NoOpExternalProvider,
2778            ValidationLevel::Conditions,
2779        )
2780    }
2781
2782    #[test]
2783    fn a_later_soll_line_alone_does_not_require_the_field() {
2784        // Muss [2119] / Soll [130], only [130] holds: recommended, not required.
2785        let report = validate_absent_field(
2786            "Muss [2119] \r\nSoll [130]",
2787            vec![(2119, CR::False), (130, CR::True)],
2788        );
2789        assert!(
2790            report.is_valid(),
2791            "{:?}",
2792            report.errors().collect::<Vec<_>>()
2793        );
2794    }
2795
2796    #[test]
2797    fn a_later_muss_line_requires_the_field() {
2798        // Soll [47] / Muss [46], only [46] holds: required.
2799        let report = validate_absent_field(
2800            "Soll [47]\r\nMuss [46]",
2801            vec![(47, CR::False), (46, CR::True)],
2802        );
2803        let errors: Vec<_> = report.errors().collect();
2804        assert_eq!(errors.len(), 1);
2805        assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
2806    }
2807
2808    #[test]
2809    fn a_weaker_unknown_line_does_not_stop_a_muss() {
2810        // [130] cannot make it any stricter than the Muss that holds.
2811        let report = validate_absent_field("Muss [2119]\nSoll [130]", vec![(2119, CR::True)]);
2812        let errors: Vec<_> = report.errors().collect();
2813        assert_eq!(errors.len(), 1);
2814        assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
2815    }
2816
2817    #[test]
2818    fn a_stricter_unknown_line_leaves_the_status_undecided() {
2819        let report = validate_absent_field("Muss [2119]\nSoll [130]", vec![(130, CR::True)]);
2820        assert!(report.is_valid());
2821        let infos: Vec<_> = report.infos().collect();
2822        assert_eq!(infos.len(), 1, "{infos:?}");
2823        match &infos[0].kind {
2824            IssueKind::ConditionUnknown { unresolved, .. } => {
2825                assert_eq!(unresolved.missing, vec![2119]);
2826            }
2827            other => panic!("expected ConditionUnknown, got {other:?}"),
2828        }
2829    }
2830
2831    #[test]
2832    fn test_validate_condition_unknown_adds_info() {
2833        // When condition is Unknown, add an info-level note
2834        let evaluator = MockEvaluator::new(vec![(182, CR::True)]);
2835        // 152 is not registered -> Unknown
2836        let validator = EdifactValidator::new(evaluator);
2837        let external = NoOpExternalProvider;
2838
2839        let workflow = AhbWorkflow {
2840            pruefidentifikator: "11001".to_string(),
2841            description: "Test".to_string(),
2842            communication_direction: None,
2843            fields: vec![AhbFieldRule {
2844                segment_path: "NAD".to_string(),
2845                name: "Partnerrolle".to_string(),
2846                ahb_status: "Muss [182] ∧ [152]".to_string(),
2847                codes: vec![],
2848                parent_group_ahb_status: None,
2849                segment_ahb_status: None,
2850                ..Default::default()
2851            }],
2852            ub_definitions: BTreeMap::new(),
2853            package_conditions: Default::default(),
2854        };
2855
2856        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2857
2858        // Should be valid (Unknown is not an error) but have an info issue
2859        assert!(report.is_valid());
2860        let infos: Vec<_> = report.infos().collect();
2861        assert_eq!(infos.len(), 1);
2862        assert_eq!(infos[0].code(), ErrorCodes::CONDITION_UNKNOWN);
2863    }
2864
2865    #[test]
2866    fn test_validate_structure_level_skips_conditions() {
2867        let evaluator = MockEvaluator::all_true(&[182, 152]);
2868        let validator = EdifactValidator::new(evaluator);
2869        let external = NoOpExternalProvider;
2870
2871        let workflow = AhbWorkflow {
2872            pruefidentifikator: "11001".to_string(),
2873            description: "Test".to_string(),
2874            communication_direction: None,
2875            fields: vec![AhbFieldRule {
2876                segment_path: "NAD".to_string(),
2877                name: "Partnerrolle".to_string(),
2878                ahb_status: "Muss [182] ∧ [152]".to_string(),
2879                codes: vec![],
2880                parent_group_ahb_status: None,
2881                segment_ahb_status: None,
2882                ..Default::default()
2883            }],
2884            ub_definitions: BTreeMap::new(),
2885            package_conditions: Default::default(),
2886        };
2887
2888        // With Structure level, conditions are not checked
2889        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Structure);
2890
2891        // No AHB errors because conditions were not evaluated
2892        assert!(report.is_valid());
2893        assert_eq!(report.by_category(ValidationCategory::Ahb).count(), 0);
2894    }
2895
2896    #[test]
2897    fn test_validate_empty_workflow_no_condition_errors() {
2898        let evaluator = MockEvaluator::all_true(&[]);
2899        let validator = EdifactValidator::new(evaluator);
2900        let external = NoOpExternalProvider;
2901
2902        let empty_workflow = AhbWorkflow {
2903            pruefidentifikator: String::new(),
2904            description: String::new(),
2905            communication_direction: None,
2906            fields: vec![],
2907            ub_definitions: BTreeMap::new(),
2908            package_conditions: Default::default(),
2909        };
2910
2911        let report = validator.validate(&[], &empty_workflow, &external, ValidationLevel::Full);
2912
2913        assert!(report.is_valid());
2914    }
2915
2916    #[test]
2917    fn test_validate_bare_muss_always_required() {
2918        let evaluator = MockEvaluator::new(vec![]);
2919        let validator = EdifactValidator::new(evaluator);
2920        let external = NoOpExternalProvider;
2921
2922        let workflow = AhbWorkflow {
2923            pruefidentifikator: "55001".to_string(),
2924            description: "Test".to_string(),
2925            communication_direction: Some("NB an LF".to_string()),
2926            fields: vec![AhbFieldRule {
2927                segment_path: "SG2/NAD/3035".to_string(),
2928                name: "Partnerrolle".to_string(),
2929                ahb_status: "Muss".to_string(), // No conditions
2930                codes: vec![],
2931                parent_group_ahb_status: None,
2932                segment_ahb_status: None,
2933                ..Default::default()
2934            }],
2935            ub_definitions: BTreeMap::new(),
2936            package_conditions: Default::default(),
2937        };
2938
2939        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2940
2941        // Bare "Muss" with no conditions -> unconditionally required -> missing = error
2942        assert!(!report.is_valid());
2943        assert_eq!(report.error_count(), 1);
2944    }
2945
2946    #[test]
2947    fn test_validate_x_status_is_mandatory() {
2948        let evaluator = MockEvaluator::new(vec![]);
2949        let validator = EdifactValidator::new(evaluator);
2950        let external = NoOpExternalProvider;
2951
2952        let workflow = AhbWorkflow {
2953            pruefidentifikator: "55001".to_string(),
2954            description: "Test".to_string(),
2955            communication_direction: None,
2956            fields: vec![AhbFieldRule {
2957                segment_path: "DTM".to_string(),
2958                name: "Datum".to_string(),
2959                ahb_status: "X".to_string(),
2960                codes: vec![],
2961                parent_group_ahb_status: None,
2962                segment_ahb_status: None,
2963                ..Default::default()
2964            }],
2965            ub_definitions: BTreeMap::new(),
2966            package_conditions: Default::default(),
2967        };
2968
2969        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2970
2971        assert!(!report.is_valid());
2972        let errors: Vec<_> = report.errors().collect();
2973        assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
2974    }
2975
2976    #[test]
2977    fn test_validate_soll_not_mandatory() {
2978        let evaluator = MockEvaluator::new(vec![]);
2979        let validator = EdifactValidator::new(evaluator);
2980        let external = NoOpExternalProvider;
2981
2982        let workflow = AhbWorkflow {
2983            pruefidentifikator: "55001".to_string(),
2984            description: "Test".to_string(),
2985            communication_direction: None,
2986            fields: vec![AhbFieldRule {
2987                segment_path: "DTM".to_string(),
2988                name: "Datum".to_string(),
2989                ahb_status: "Soll".to_string(),
2990                codes: vec![],
2991                parent_group_ahb_status: None,
2992                segment_ahb_status: None,
2993                ..Default::default()
2994            }],
2995            ub_definitions: BTreeMap::new(),
2996            package_conditions: Default::default(),
2997        };
2998
2999        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
3000
3001        // Soll is not mandatory, so missing is not an error
3002        assert!(report.is_valid());
3003    }
3004
3005    #[test]
3006    fn test_report_includes_metadata() {
3007        let evaluator = MockEvaluator::new(vec![]);
3008        let validator = EdifactValidator::new(evaluator);
3009        let external = NoOpExternalProvider;
3010
3011        let workflow = AhbWorkflow {
3012            pruefidentifikator: "55001".to_string(),
3013            description: String::new(),
3014            communication_direction: None,
3015            fields: vec![],
3016            ub_definitions: BTreeMap::new(),
3017            package_conditions: Default::default(),
3018        };
3019
3020        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Full);
3021
3022        assert_eq!(report.format_version.as_deref(), Some("FV2510"));
3023        assert_eq!(report.level, ValidationLevel::Full);
3024        assert_eq!(report.message_type, "UTILMD");
3025        assert_eq!(report.pruefidentifikator.as_deref(), Some("55001"));
3026    }
3027
3028    #[test]
3029    fn test_validate_with_navigator_returns_report() {
3030        let evaluator = MockEvaluator::all_true(&[]);
3031        let validator = EdifactValidator::new(evaluator);
3032        let external = NoOpExternalProvider;
3033        let nav = crate::eval::NoOpGroupNavigator;
3034
3035        let workflow = AhbWorkflow {
3036            pruefidentifikator: "55001".to_string(),
3037            description: "Test".to_string(),
3038            communication_direction: None,
3039            fields: vec![],
3040            ub_definitions: BTreeMap::new(),
3041            package_conditions: Default::default(),
3042        };
3043
3044        let report = validator.validate_with_navigator(
3045            &[],
3046            &workflow,
3047            &external,
3048            ValidationLevel::Full,
3049            &nav,
3050        );
3051        assert!(report.is_valid());
3052    }
3053
3054    #[test]
3055    fn test_code_validation_composite_paths_valid_codes() {
3056        // UNH/S009/0065 and UNH/S009/0052 carry codes inside composite S009 at
3057        // element_index=1, component sub_index 0 and 1. Validation should match
3058        // the actual composite positions and find no errors for matching values.
3059        let evaluator = MockEvaluator::new(vec![]);
3060        let validator = EdifactValidator::new(evaluator);
3061        let external = NoOpExternalProvider;
3062
3063        let unh_segment = OwnedSegment {
3064            id: "UNH".to_string(),
3065            elements: vec![
3066                vec!["ALEXANDE951842".to_string()],
3067                vec![
3068                    "UTILMD".to_string(),
3069                    "D".to_string(),
3070                    "11A".to_string(),
3071                    "UN".to_string(),
3072                    "S2.1".to_string(),
3073                ],
3074            ],
3075            segment_number: 1,
3076        };
3077
3078        let workflow = AhbWorkflow {
3079            pruefidentifikator: "55001".to_string(),
3080            description: "Test".to_string(),
3081            communication_direction: None,
3082            fields: vec![
3083                AhbFieldRule {
3084                    segment_path: "UNH/S009/0065".to_string(),
3085                    name: "Nachrichtentyp".to_string(),
3086                    ahb_status: "X".to_string(),
3087                    codes: vec![AhbCodeRule {
3088                        value: "UTILMD".to_string(),
3089                        description: "Stammdaten".to_string(),
3090                        ahb_status: "X".to_string(),
3091                    }],
3092                    parent_group_ahb_status: None,
3093                    segment_ahb_status: None,
3094                    element_index: Some(1),
3095                    component_index: Some(0),
3096                    ..Default::default()
3097                },
3098                AhbFieldRule {
3099                    segment_path: "UNH/S009/0052".to_string(),
3100                    name: "Version".to_string(),
3101                    ahb_status: "X".to_string(),
3102                    codes: vec![AhbCodeRule {
3103                        value: "D".to_string(),
3104                        description: "Draft".to_string(),
3105                        ahb_status: "X".to_string(),
3106                    }],
3107                    parent_group_ahb_status: None,
3108                    segment_ahb_status: None,
3109                    element_index: Some(1),
3110                    component_index: Some(1),
3111                    ..Default::default()
3112                },
3113            ],
3114            ub_definitions: BTreeMap::new(),
3115            package_conditions: Default::default(),
3116        };
3117
3118        let report = validator.validate(
3119            &[unh_segment],
3120            &workflow,
3121            &external,
3122            ValidationLevel::Conditions,
3123        );
3124
3125        let code_errors: Vec<_> = report
3126            .by_category(ValidationCategory::Code)
3127            .filter(|i| i.severity == Severity::Error)
3128            .collect();
3129        assert!(
3130            code_errors.is_empty(),
3131            "Expected no code errors when composite values match allowed codes, got: {:?}",
3132            code_errors
3133        );
3134    }
3135
3136    #[test]
3137    fn test_code_validation_partitions_by_mig_number() {
3138        // PID 55018 has two STS migs: 00035 (Statuskategorie=7) constrains
3139        // element 2 to {E03}; 00036 (Statuskategorie=E01) has no constraint there.
3140        // Unioning codes across migs would flag A99 in STS+E01 — it must not.
3141        let evaluator = MockEvaluator::new(vec![]);
3142        let validator = EdifactValidator::new(evaluator);
3143        let external = NoOpExternalProvider;
3144
3145        let sts_7 = OwnedSegment {
3146            id: "STS".to_string(),
3147            elements: vec![
3148                vec!["7".to_string()],
3149                vec![String::new()],
3150                vec!["GH02".to_string()],
3151                vec!["ZW4".to_string()],
3152            ],
3153            segment_number: 1,
3154        };
3155        let sts_e01 = OwnedSegment {
3156            id: "STS".to_string(),
3157            elements: vec![
3158                vec!["E01".to_string()],
3159                vec![String::new()],
3160                vec!["A99".to_string(), "E_0614".to_string()],
3161            ],
3162            segment_number: 2,
3163        };
3164
3165        let workflow = AhbWorkflow {
3166            pruefidentifikator: "55018".to_string(),
3167            description: "Test".to_string(),
3168            communication_direction: None,
3169            fields: vec![
3170                // Mig 00035: qualifier 7 at element 0, E03 required at element 2.
3171                AhbFieldRule {
3172                    segment_path: "SG4/STS/C601/9015".to_string(),
3173                    name: "Statuskategorie".to_string(),
3174                    ahb_status: "X".to_string(),
3175                    codes: vec![AhbCodeRule {
3176                        value: "7".to_string(),
3177                        description: "Transaktionsgrund".to_string(),
3178                        ahb_status: "X".to_string(),
3179                    }],
3180                    parent_group_ahb_status: None,
3181                    segment_ahb_status: None,
3182                    element_index: Some(0),
3183                    component_index: Some(0),
3184                    mig_number: Some("00035".to_string()),
3185                    format: None,
3186                },
3187                AhbFieldRule {
3188                    segment_path: "SG4/STS/C556/9013".to_string(),
3189                    name: "Statusanlaß".to_string(),
3190                    ahb_status: "X".to_string(),
3191                    codes: vec![AhbCodeRule {
3192                        value: "E03".to_string(),
3193                        description: "Transaktionsgrund".to_string(),
3194                        ahb_status: "X".to_string(),
3195                    }],
3196                    parent_group_ahb_status: None,
3197                    segment_ahb_status: None,
3198                    element_index: Some(2),
3199                    component_index: Some(0),
3200                    mig_number: Some("00035".to_string()),
3201                    format: None,
3202                },
3203                // Mig 00036: qualifier E01 at element 0, no codes at element 2.
3204                AhbFieldRule {
3205                    segment_path: "SG4/STS/C601/9015".to_string(),
3206                    name: "Statuskategorie".to_string(),
3207                    ahb_status: "X".to_string(),
3208                    codes: vec![AhbCodeRule {
3209                        value: "E01".to_string(),
3210                        description: "Antwort".to_string(),
3211                        ahb_status: "X".to_string(),
3212                    }],
3213                    parent_group_ahb_status: None,
3214                    segment_ahb_status: None,
3215                    element_index: Some(0),
3216                    component_index: Some(0),
3217                    mig_number: Some("00036".to_string()),
3218                    format: None,
3219                },
3220            ],
3221            ub_definitions: BTreeMap::new(),
3222            package_conditions: Default::default(),
3223        };
3224
3225        let report = validator.validate(
3226            &[sts_7, sts_e01],
3227            &workflow,
3228            &external,
3229            ValidationLevel::Conditions,
3230        );
3231
3232        let code_errors: Vec<_> = report
3233            .by_category(ValidationCategory::Code)
3234            .filter(|i| {
3235                i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
3236            })
3237            .collect();
3238        assert_eq!(
3239            code_errors.len(),
3240            1,
3241            "Expected one COD002 (for GH02 only), got: {:?}",
3242            code_errors
3243        );
3244        assert_eq!(code_errors[0].actual_value.as_deref(), Some("GH02"));
3245    }
3246
3247    #[test]
3248    fn test_code_validation_composite_paths_detects_invalid_code() {
3249        // Mirror of the STS+7 case that motivated composite validation: a bad code
3250        // at element_index=2, component_index=0 must produce COD002.
3251        let evaluator = MockEvaluator::new(vec![]);
3252        let validator = EdifactValidator::new(evaluator);
3253        let external = NoOpExternalProvider;
3254
3255        let sts_segment = OwnedSegment {
3256            id: "STS".to_string(),
3257            elements: vec![
3258                vec!["7".to_string()],
3259                vec![String::new()],
3260                vec!["GH02".to_string()],
3261                vec!["ZW4".to_string()],
3262            ],
3263            segment_number: 1,
3264        };
3265
3266        let workflow = AhbWorkflow {
3267            pruefidentifikator: "55018".to_string(),
3268            description: "Test".to_string(),
3269            communication_direction: None,
3270            fields: vec![AhbFieldRule {
3271                segment_path: "SG4/STS/C556/9013".to_string(),
3272                name: "Statusanlaß".to_string(),
3273                ahb_status: "X".to_string(),
3274                codes: vec![AhbCodeRule {
3275                    value: "E03".to_string(),
3276                    description: "Transaktionsgrund".to_string(),
3277                    ahb_status: "X".to_string(),
3278                }],
3279                parent_group_ahb_status: None,
3280                segment_ahb_status: None,
3281                element_index: Some(2),
3282                component_index: Some(0),
3283                ..Default::default()
3284            }],
3285            ub_definitions: BTreeMap::new(),
3286            package_conditions: Default::default(),
3287        };
3288
3289        let report = validator.validate(
3290            &[sts_segment],
3291            &workflow,
3292            &external,
3293            ValidationLevel::Conditions,
3294        );
3295
3296        let code_errors: Vec<_> = report
3297            .by_category(ValidationCategory::Code)
3298            .filter(|i| {
3299                i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
3300            })
3301            .collect();
3302        assert_eq!(
3303            code_errors.len(),
3304            1,
3305            "Expected COD002 for GH02, got: {:?}",
3306            code_errors
3307        );
3308        assert_eq!(code_errors[0].actual_value.as_deref(), Some("GH02"));
3309    }
3310
3311    #[test]
3312    fn test_cross_field_code_validation_valid_qualifiers() {
3313        // NAD/3035 has separate field rules: [MS] for sender, [MR] for receiver.
3314        // Cross-field validation unions them → {MS, MR}. Both segments are valid.
3315        let evaluator = MockEvaluator::new(vec![]);
3316        let validator = EdifactValidator::new(evaluator);
3317        let external = NoOpExternalProvider;
3318
3319        let nad_ms = OwnedSegment {
3320            id: "NAD".to_string(),
3321            elements: vec![vec!["MS".to_string()]],
3322            segment_number: 4,
3323        };
3324        let nad_mr = OwnedSegment {
3325            id: "NAD".to_string(),
3326            elements: vec![vec!["MR".to_string()]],
3327            segment_number: 5,
3328        };
3329
3330        let workflow = AhbWorkflow {
3331            pruefidentifikator: "55001".to_string(),
3332            description: "Test".to_string(),
3333            communication_direction: None,
3334            fields: vec![
3335                AhbFieldRule {
3336                    segment_path: "SG2/NAD/3035".to_string(),
3337                    name: "Absender".to_string(),
3338                    ahb_status: "X".to_string(),
3339                    codes: vec![AhbCodeRule {
3340                        value: "MS".to_string(),
3341                        description: "Absender".to_string(),
3342                        ahb_status: "X".to_string(),
3343                    }],
3344                    parent_group_ahb_status: None,
3345                    segment_ahb_status: None,
3346                    ..Default::default()
3347                },
3348                AhbFieldRule {
3349                    segment_path: "SG2/NAD/3035".to_string(),
3350                    name: "Empfaenger".to_string(),
3351                    ahb_status: "X".to_string(),
3352                    codes: vec![AhbCodeRule {
3353                        value: "MR".to_string(),
3354                        description: "Empfaenger".to_string(),
3355                        ahb_status: "X".to_string(),
3356                    }],
3357                    parent_group_ahb_status: None,
3358                    segment_ahb_status: None,
3359                    ..Default::default()
3360                },
3361            ],
3362            ub_definitions: BTreeMap::new(),
3363            package_conditions: Default::default(),
3364        };
3365
3366        let report = validator.validate(
3367            &[nad_ms, nad_mr],
3368            &workflow,
3369            &external,
3370            ValidationLevel::Conditions,
3371        );
3372
3373        let code_errors: Vec<_> = report
3374            .by_category(ValidationCategory::Code)
3375            .filter(|i| i.severity == Severity::Error)
3376            .collect();
3377        assert!(
3378            code_errors.is_empty(),
3379            "Expected no code errors for valid qualifiers, got: {:?}",
3380            code_errors
3381        );
3382    }
3383
3384    #[test]
3385    fn test_cross_field_code_validation_catches_invalid_qualifier() {
3386        // NAD+MT is not in the allowed set {MS, MR} → should produce COD002.
3387        let evaluator = MockEvaluator::new(vec![]);
3388        let validator = EdifactValidator::new(evaluator);
3389        let external = NoOpExternalProvider;
3390
3391        let nad_ms = OwnedSegment {
3392            id: "NAD".to_string(),
3393            elements: vec![vec!["MS".to_string()]],
3394            segment_number: 4,
3395        };
3396        let nad_mt = OwnedSegment {
3397            id: "NAD".to_string(),
3398            elements: vec![vec!["MT".to_string()]], // invalid
3399            segment_number: 5,
3400        };
3401
3402        let workflow = AhbWorkflow {
3403            pruefidentifikator: "55001".to_string(),
3404            description: "Test".to_string(),
3405            communication_direction: None,
3406            fields: vec![
3407                AhbFieldRule {
3408                    segment_path: "SG2/NAD/3035".to_string(),
3409                    name: "Absender".to_string(),
3410                    ahb_status: "X".to_string(),
3411                    codes: vec![AhbCodeRule {
3412                        value: "MS".to_string(),
3413                        description: "Absender".to_string(),
3414                        ahb_status: "X".to_string(),
3415                    }],
3416                    parent_group_ahb_status: None,
3417                    segment_ahb_status: None,
3418                    ..Default::default()
3419                },
3420                AhbFieldRule {
3421                    segment_path: "SG2/NAD/3035".to_string(),
3422                    name: "Empfaenger".to_string(),
3423                    ahb_status: "X".to_string(),
3424                    codes: vec![AhbCodeRule {
3425                        value: "MR".to_string(),
3426                        description: "Empfaenger".to_string(),
3427                        ahb_status: "X".to_string(),
3428                    }],
3429                    parent_group_ahb_status: None,
3430                    segment_ahb_status: None,
3431                    ..Default::default()
3432                },
3433            ],
3434            ub_definitions: BTreeMap::new(),
3435            package_conditions: Default::default(),
3436        };
3437
3438        let report = validator.validate(
3439            &[nad_ms, nad_mt],
3440            &workflow,
3441            &external,
3442            ValidationLevel::Conditions,
3443        );
3444
3445        let code_errors: Vec<_> = report
3446            .by_category(ValidationCategory::Code)
3447            .filter(|i| i.severity == Severity::Error)
3448            .collect();
3449        assert_eq!(code_errors.len(), 1, "Expected one COD002 error for MT");
3450        assert!(narrate(code_errors[0]).contains("MT"));
3451        assert!(narrate(code_errors[0]).contains("MR"));
3452        assert!(narrate(code_errors[0]).contains("MS"));
3453    }
3454
3455    #[test]
3456    fn test_cross_field_code_validation_unions_across_groups() {
3457        // SG2/NAD/3035 allows {MS, MR}, SG4/SG12/NAD/3035 allows {Z04, Z09}.
3458        // Since find_segments("NAD") returns all NADs, codes must be unioned
3459        // by tag: {MS, MR, Z04, Z09}. NAD+MT should be caught, all others pass.
3460        let evaluator = MockEvaluator::new(vec![]);
3461        let validator = EdifactValidator::new(evaluator);
3462        let external = NoOpExternalProvider;
3463
3464        let segments = vec![
3465            OwnedSegment {
3466                id: "NAD".to_string(),
3467                elements: vec![vec!["MS".to_string()]],
3468                segment_number: 3,
3469            },
3470            OwnedSegment {
3471                id: "NAD".to_string(),
3472                elements: vec![vec!["MR".to_string()]],
3473                segment_number: 4,
3474            },
3475            OwnedSegment {
3476                id: "NAD".to_string(),
3477                elements: vec![vec!["Z04".to_string()]],
3478                segment_number: 20,
3479            },
3480            OwnedSegment {
3481                id: "NAD".to_string(),
3482                elements: vec![vec!["Z09".to_string()]],
3483                segment_number: 21,
3484            },
3485            OwnedSegment {
3486                id: "NAD".to_string(),
3487                elements: vec![vec!["MT".to_string()]], // invalid
3488                segment_number: 22,
3489            },
3490        ];
3491
3492        let workflow = AhbWorkflow {
3493            pruefidentifikator: "55001".to_string(),
3494            description: "Test".to_string(),
3495            communication_direction: None,
3496            fields: vec![
3497                AhbFieldRule {
3498                    segment_path: "SG2/NAD/3035".to_string(),
3499                    name: "Absender".to_string(),
3500                    ahb_status: "X".to_string(),
3501                    codes: vec![AhbCodeRule {
3502                        value: "MS".to_string(),
3503                        description: "Absender".to_string(),
3504                        ahb_status: "X".to_string(),
3505                    }],
3506                    parent_group_ahb_status: None,
3507                    segment_ahb_status: None,
3508                    ..Default::default()
3509                },
3510                AhbFieldRule {
3511                    segment_path: "SG2/NAD/3035".to_string(),
3512                    name: "Empfaenger".to_string(),
3513                    ahb_status: "X".to_string(),
3514                    codes: vec![AhbCodeRule {
3515                        value: "MR".to_string(),
3516                        description: "Empfaenger".to_string(),
3517                        ahb_status: "X".to_string(),
3518                    }],
3519                    parent_group_ahb_status: None,
3520                    segment_ahb_status: None,
3521                    ..Default::default()
3522                },
3523                AhbFieldRule {
3524                    segment_path: "SG4/SG12/NAD/3035".to_string(),
3525                    name: "Anschlussnutzer".to_string(),
3526                    ahb_status: "X".to_string(),
3527                    codes: vec![AhbCodeRule {
3528                        value: "Z04".to_string(),
3529                        description: "Anschlussnutzer".to_string(),
3530                        ahb_status: "X".to_string(),
3531                    }],
3532                    parent_group_ahb_status: None,
3533                    segment_ahb_status: None,
3534                    ..Default::default()
3535                },
3536                AhbFieldRule {
3537                    segment_path: "SG4/SG12/NAD/3035".to_string(),
3538                    name: "Korrespondenzanschrift".to_string(),
3539                    ahb_status: "X".to_string(),
3540                    codes: vec![AhbCodeRule {
3541                        value: "Z09".to_string(),
3542                        description: "Korrespondenzanschrift".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            ],
3550            ub_definitions: BTreeMap::new(),
3551            package_conditions: Default::default(),
3552        };
3553
3554        let report =
3555            validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
3556
3557        let code_errors: Vec<_> = report
3558            .by_category(ValidationCategory::Code)
3559            .filter(|i| i.severity == Severity::Error)
3560            .collect();
3561        assert_eq!(
3562            code_errors.len(),
3563            1,
3564            "Expected exactly one COD002 error for MT, got: {:?}",
3565            code_errors
3566        );
3567        assert!(narrate(code_errors[0]).contains("MT"));
3568    }
3569
3570    #[test]
3571    fn test_cross_field_code_validation_accepts_conditionally_allowed_codes() {
3572        // QTY 6063 in MSCONS PID 13025 has three AHB-allowed codes:
3573        //   220  ahb_status "X"                        (unconditional)
3574        //   67   ahb_status "X [35] ∨ ([32] ∧ [77])"   (conditional)
3575        //   Z18  ahb_status "X [35]"                    (conditional)
3576        // All three are "allowed codes for this PID" — they differ only in when
3577        // they may be used. The COD002 "code not allowed for this PID" check
3578        // must accept the conditional ones too, otherwise real-world load-profile
3579        // messages using QTY+67 (Ersatzwert) are incorrectly rejected.
3580        let evaluator = MockEvaluator::new(vec![]);
3581        let validator = EdifactValidator::new(evaluator);
3582        let external = NoOpExternalProvider;
3583
3584        let qty_67 = OwnedSegment {
3585            id: "QTY".to_string(),
3586            elements: vec![vec!["67".to_string(), "0.185".to_string()]],
3587            segment_number: 10,
3588        };
3589
3590        let workflow = AhbWorkflow {
3591            pruefidentifikator: "13025".to_string(),
3592            description: "Test".to_string(),
3593            communication_direction: None,
3594            fields: vec![AhbFieldRule {
3595                segment_path: "SG5/SG6/SG9/SG10/QTY/qualifier".to_string(),
3596                name: "Menge, Qualifier".to_string(),
3597                ahb_status: "X".to_string(),
3598                codes: vec![
3599                    AhbCodeRule {
3600                        value: "220".to_string(),
3601                        description: "Wahrer Wert".to_string(),
3602                        ahb_status: "X".to_string(),
3603                    },
3604                    AhbCodeRule {
3605                        value: "67".to_string(),
3606                        description: "Ersatzwert".to_string(),
3607                        ahb_status: "X [35] ∨ ([32] ∧ [77])".to_string(),
3608                    },
3609                    AhbCodeRule {
3610                        value: "Z18".to_string(),
3611                        description: "Vorläufiger Wert".to_string(),
3612                        ahb_status: "X [35]".to_string(),
3613                    },
3614                ],
3615                parent_group_ahb_status: None,
3616                segment_ahb_status: None,
3617                element_index: Some(0),
3618                component_index: Some(0),
3619                ..Default::default()
3620            }],
3621            ub_definitions: BTreeMap::new(),
3622            package_conditions: Default::default(),
3623        };
3624
3625        let report =
3626            validator.validate(&[qty_67], &workflow, &external, ValidationLevel::Conditions);
3627
3628        let code_errors: Vec<_> = report
3629            .by_category(ValidationCategory::Code)
3630            .filter(|i| i.severity == Severity::Error)
3631            .collect();
3632        assert!(
3633            code_errors.is_empty(),
3634            "QTY+67 should be accepted because code '67' is conditionally allowed for this PID (X [35] ∨ ([32] ∧ [77])). Got errors: {:?}",
3635            code_errors
3636        );
3637    }
3638
3639    #[test]
3640    fn test_is_qualifier_field_simple_paths() {
3641        assert!(is_qualifier_field("NAD/3035"));
3642        assert!(is_qualifier_field("SG2/NAD/3035"));
3643        assert!(is_qualifier_field("SG4/SG8/SEQ/6350"));
3644        assert!(is_qualifier_field("LOC/3227"));
3645    }
3646
3647    #[test]
3648    fn test_is_qualifier_field_composite_paths() {
3649        // Composite paths (3 parts after SG stripping) are accepted so composite-level
3650        // codes like STS/C556/9013 get validated. The field rule's element_index and
3651        // component_index carry the exact position.
3652        assert!(is_qualifier_field("UNH/S009/0065"));
3653        assert!(is_qualifier_field("NAD/C082/3039"));
3654        assert!(is_qualifier_field("SG2/NAD/C082/3039"));
3655        assert!(is_qualifier_field("SG4/STS/C556/9013"));
3656    }
3657
3658    #[test]
3659    fn test_is_qualifier_field_bare_segment() {
3660        assert!(!is_qualifier_field("NAD"));
3661        assert!(!is_qualifier_field("SG2/NAD"));
3662    }
3663
3664    #[test]
3665    fn test_is_qualifier_field_rejects_deep_paths() {
3666        // 4+ parts after SG stripping are malformed — reject.
3667        assert!(!is_qualifier_field("SEG/A/B/C/D"));
3668    }
3669
3670    #[test]
3671    fn test_missing_qualifier_instance_is_detected() {
3672        // NAD+MS is present but NAD+MR is missing.
3673        // The Empfaenger field requires [MR] → should produce AHB001.
3674        let evaluator = MockEvaluator::new(vec![]);
3675        let validator = EdifactValidator::new(evaluator);
3676        let external = NoOpExternalProvider;
3677
3678        let nad_ms = OwnedSegment {
3679            id: "NAD".to_string(),
3680            elements: vec![vec!["MS".to_string()]],
3681            segment_number: 3,
3682        };
3683
3684        let workflow = AhbWorkflow {
3685            pruefidentifikator: "55001".to_string(),
3686            description: "Test".to_string(),
3687            communication_direction: None,
3688            fields: vec![
3689                AhbFieldRule {
3690                    segment_path: "SG2/NAD/3035".to_string(),
3691                    name: "Absender".to_string(),
3692                    ahb_status: "X".to_string(),
3693                    codes: vec![AhbCodeRule {
3694                        value: "MS".to_string(),
3695                        description: "Absender".to_string(),
3696                        ahb_status: "X".to_string(),
3697                    }],
3698                    parent_group_ahb_status: None,
3699                    segment_ahb_status: None,
3700                    ..Default::default()
3701                },
3702                AhbFieldRule {
3703                    segment_path: "SG2/NAD/3035".to_string(),
3704                    name: "Empfaenger".to_string(),
3705                    ahb_status: "Muss".to_string(),
3706                    codes: vec![AhbCodeRule {
3707                        value: "MR".to_string(),
3708                        description: "Empfaenger".to_string(),
3709                        ahb_status: "X".to_string(),
3710                    }],
3711                    parent_group_ahb_status: None,
3712                    segment_ahb_status: None,
3713                    ..Default::default()
3714                },
3715            ],
3716            ub_definitions: BTreeMap::new(),
3717            package_conditions: Default::default(),
3718        };
3719
3720        let report =
3721            validator.validate(&[nad_ms], &workflow, &external, ValidationLevel::Conditions);
3722
3723        let ahb_errors: Vec<_> = report
3724            .by_category(ValidationCategory::Ahb)
3725            .filter(|i| i.severity == Severity::Error)
3726            .collect();
3727        assert_eq!(
3728            ahb_errors.len(),
3729            1,
3730            "Expected AHB001 for missing NAD+MR, got: {:?}",
3731            ahb_errors
3732        );
3733        assert!(narrate(ahb_errors[0]).contains("Empfaenger"));
3734    }
3735
3736    #[test]
3737    fn test_present_qualifier_instance_no_error() {
3738        // Both NAD+MS and NAD+MR present → no AHB001 for either.
3739        let evaluator = MockEvaluator::new(vec![]);
3740        let validator = EdifactValidator::new(evaluator);
3741        let external = NoOpExternalProvider;
3742
3743        let segments = vec![
3744            OwnedSegment {
3745                id: "NAD".to_string(),
3746                elements: vec![vec!["MS".to_string()]],
3747                segment_number: 3,
3748            },
3749            OwnedSegment {
3750                id: "NAD".to_string(),
3751                elements: vec![vec!["MR".to_string()]],
3752                segment_number: 4,
3753            },
3754        ];
3755
3756        let workflow = AhbWorkflow {
3757            pruefidentifikator: "55001".to_string(),
3758            description: "Test".to_string(),
3759            communication_direction: None,
3760            fields: vec![
3761                AhbFieldRule {
3762                    segment_path: "SG2/NAD/3035".to_string(),
3763                    name: "Absender".to_string(),
3764                    ahb_status: "Muss".to_string(),
3765                    codes: vec![AhbCodeRule {
3766                        value: "MS".to_string(),
3767                        description: "Absender".to_string(),
3768                        ahb_status: "X".to_string(),
3769                    }],
3770                    parent_group_ahb_status: None,
3771                    segment_ahb_status: None,
3772                    ..Default::default()
3773                },
3774                AhbFieldRule {
3775                    segment_path: "SG2/NAD/3035".to_string(),
3776                    name: "Empfaenger".to_string(),
3777                    ahb_status: "Muss".to_string(),
3778                    codes: vec![AhbCodeRule {
3779                        value: "MR".to_string(),
3780                        description: "Empfaenger".to_string(),
3781                        ahb_status: "X".to_string(),
3782                    }],
3783                    parent_group_ahb_status: None,
3784                    segment_ahb_status: None,
3785                    ..Default::default()
3786                },
3787            ],
3788            ub_definitions: BTreeMap::new(),
3789            package_conditions: Default::default(),
3790        };
3791
3792        let report =
3793            validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
3794
3795        let ahb_errors: Vec<_> = report
3796            .by_category(ValidationCategory::Ahb)
3797            .filter(|i| i.severity == Severity::Error)
3798            .collect();
3799        assert!(
3800            ahb_errors.is_empty(),
3801            "Expected no AHB001 errors, got: {:?}",
3802            ahb_errors
3803        );
3804    }
3805
3806    #[test]
3807    fn test_extract_group_path_key() {
3808        assert_eq!(extract_group_path_key("SG2/NAD/3035"), "SG2");
3809        assert_eq!(extract_group_path_key("SG4/SG12/NAD/3035"), "SG4/SG12");
3810        assert_eq!(extract_group_path_key("NAD/3035"), "");
3811        assert_eq!(extract_group_path_key("SG4/SG8/SEQ/6350"), "SG4/SG8");
3812    }
3813
3814    /// Validate a field of an absent SG9 whose group status is `group_status`.
3815    fn validate_field_of_absent_group(
3816        group_status: &str,
3817        conditions: Vec<(u32, CR)>,
3818    ) -> ValidationReport {
3819        use mig_types::navigator::GroupNavigator;
3820
3821        struct NoSg9;
3822        impl GroupNavigator for NoSg9 {
3823            fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
3824                vec![]
3825            }
3826            fn find_segments_with_qualifier_in_group(
3827                &self,
3828                _: &str,
3829                _: usize,
3830                _: &str,
3831                _: &[&str],
3832                _: usize,
3833            ) -> Vec<OwnedSegment> {
3834                vec![]
3835            }
3836            fn group_instance_count(&self, _: &[&str]) -> usize {
3837                0
3838            }
3839        }
3840
3841        let validator = EdifactValidator::new(MockEvaluator::new(conditions));
3842        let workflow = AhbWorkflow {
3843            pruefidentifikator: "44001".to_string(),
3844            description: "Test".to_string(),
3845            communication_direction: None,
3846            fields: vec![AhbFieldRule {
3847                segment_path: "SG9/QTY/C186/6060".to_string(),
3848                name: "Menge".to_string(),
3849                ahb_status: "X".to_string(),
3850                parent_group_ahb_status: Some(group_status.to_string()),
3851                ..Default::default()
3852            }],
3853            ub_definitions: BTreeMap::new(),
3854            package_conditions: Default::default(),
3855        };
3856        validator.validate_with_navigator(
3857            &[],
3858            &workflow,
3859            &NoOpExternalProvider,
3860            ValidationLevel::Conditions,
3861            &NoSg9,
3862        )
3863    }
3864
3865    #[test]
3866    fn an_absent_group_whose_holding_line_is_soll_is_not_missing() {
3867        // UTILMD Gas SG9 "TUM Kundenwert": Soll [47] ∧ … / Muss [46] ∧ …
3868        let report = validate_field_of_absent_group(
3869            "Soll [47]\r\nMuss [46]",
3870            vec![(47, CR::True), (46, CR::False)],
3871        );
3872        assert!(
3873            report.is_valid(),
3874            "{:?}",
3875            report.errors().collect::<Vec<_>>()
3876        );
3877    }
3878
3879    #[test]
3880    fn an_absent_group_whose_holding_line_is_muss_is_missing() {
3881        let report = validate_field_of_absent_group(
3882            "Soll [47]\r\nMuss [46]",
3883            vec![(47, CR::False), (46, CR::True)],
3884        );
3885        let errors: Vec<_> = report.errors().collect();
3886        assert_eq!(errors.len(), 1, "{errors:?}");
3887        assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
3888    }
3889
3890    #[test]
3891    fn test_absent_optional_group_no_missing_field_error() {
3892        // SG3 is optional ("Kann"). If SG3 is absent, its children CTA/3139
3893        // and CTA/C056/3412 should NOT produce AHB001 errors.
3894        use mig_types::navigator::GroupNavigator;
3895
3896        struct NavWithoutSG3;
3897        impl GroupNavigator for NavWithoutSG3 {
3898            fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
3899                vec![]
3900            }
3901            fn find_segments_with_qualifier_in_group(
3902                &self,
3903                _: &str,
3904                _: usize,
3905                _: &str,
3906                _: &[&str],
3907                _: usize,
3908            ) -> Vec<OwnedSegment> {
3909                vec![]
3910            }
3911            fn group_instance_count(&self, group_path: &[&str]) -> usize {
3912                match group_path {
3913                    ["SG2"] => 2,        // two NAD groups present
3914                    ["SG2", "SG3"] => 0, // SG3 is absent
3915                    _ => 0,
3916                }
3917            }
3918        }
3919
3920        let evaluator = MockEvaluator::new(vec![]);
3921        let validator = EdifactValidator::new(evaluator);
3922        let external = NoOpExternalProvider;
3923        let nav = NavWithoutSG3;
3924
3925        // Only NAD segments present, no CTA
3926        let segments = vec![
3927            OwnedSegment {
3928                id: "NAD".into(),
3929                elements: vec![vec!["MS".into()]],
3930                segment_number: 3,
3931            },
3932            OwnedSegment {
3933                id: "NAD".into(),
3934                elements: vec![vec!["MR".into()]],
3935                segment_number: 4,
3936            },
3937        ];
3938
3939        let workflow = AhbWorkflow {
3940            pruefidentifikator: "55001".to_string(),
3941            description: "Test".to_string(),
3942            communication_direction: None,
3943            fields: vec![
3944                AhbFieldRule {
3945                    segment_path: "SG2/SG3/CTA/3139".to_string(),
3946                    name: "Funktion des Ansprechpartners, Code".to_string(),
3947                    ahb_status: "Muss".to_string(),
3948                    codes: vec![],
3949                    parent_group_ahb_status: None,
3950                    segment_ahb_status: None,
3951                    ..Default::default()
3952                },
3953                AhbFieldRule {
3954                    segment_path: "SG2/SG3/CTA/C056/3412".to_string(),
3955                    name: "Name vom Ansprechpartner".to_string(),
3956                    ahb_status: "X".to_string(),
3957                    codes: vec![],
3958                    parent_group_ahb_status: None,
3959                    segment_ahb_status: None,
3960                    ..Default::default()
3961                },
3962            ],
3963            ub_definitions: BTreeMap::new(),
3964            package_conditions: Default::default(),
3965        };
3966
3967        let report = validator.validate_with_navigator(
3968            &segments,
3969            &workflow,
3970            &external,
3971            ValidationLevel::Conditions,
3972            &nav,
3973        );
3974
3975        let ahb_errors: Vec<_> = report
3976            .by_category(ValidationCategory::Ahb)
3977            .filter(|i| i.severity == Severity::Error)
3978            .collect();
3979        assert!(
3980            ahb_errors.is_empty(),
3981            "Expected no AHB001 errors when SG3 is absent, got: {:?}",
3982            ahb_errors
3983        );
3984    }
3985
3986    #[test]
3987    fn test_present_group_still_checks_mandatory_fields() {
3988        // If SG3 IS present but CTA is missing within it → AHB001 error.
3989        use mig_types::navigator::GroupNavigator;
3990
3991        struct NavWithSG3;
3992        impl GroupNavigator for NavWithSG3 {
3993            fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
3994                vec![]
3995            }
3996            fn find_segments_with_qualifier_in_group(
3997                &self,
3998                _: &str,
3999                _: usize,
4000                _: &str,
4001                _: &[&str],
4002                _: usize,
4003            ) -> Vec<OwnedSegment> {
4004                vec![]
4005            }
4006            fn group_instance_count(&self, group_path: &[&str]) -> usize {
4007                match group_path {
4008                    ["SG2"] => 1,
4009                    ["SG2", "SG3"] => 1, // SG3 is present
4010                    _ => 0,
4011                }
4012            }
4013        }
4014
4015        let evaluator = MockEvaluator::new(vec![]);
4016        let validator = EdifactValidator::new(evaluator);
4017        let external = NoOpExternalProvider;
4018        let nav = NavWithSG3;
4019
4020        // SG3 is present (nav says 1 instance) but CTA is not in flat segments
4021        let segments = vec![OwnedSegment {
4022            id: "NAD".into(),
4023            elements: vec![vec!["MS".into()]],
4024            segment_number: 3,
4025        }];
4026
4027        let workflow = AhbWorkflow {
4028            pruefidentifikator: "55001".to_string(),
4029            description: "Test".to_string(),
4030            communication_direction: None,
4031            fields: vec![AhbFieldRule {
4032                segment_path: "SG2/SG3/CTA/3139".to_string(),
4033                name: "Funktion des Ansprechpartners, Code".to_string(),
4034                ahb_status: "Muss".to_string(),
4035                codes: vec![],
4036                parent_group_ahb_status: None,
4037                segment_ahb_status: None,
4038                ..Default::default()
4039            }],
4040            ub_definitions: BTreeMap::new(),
4041            package_conditions: Default::default(),
4042        };
4043
4044        let report = validator.validate_with_navigator(
4045            &segments,
4046            &workflow,
4047            &external,
4048            ValidationLevel::Conditions,
4049            &nav,
4050        );
4051
4052        let ahb_errors: Vec<_> = report
4053            .by_category(ValidationCategory::Ahb)
4054            .filter(|i| i.severity == Severity::Error)
4055            .collect();
4056        assert_eq!(
4057            ahb_errors.len(),
4058            1,
4059            "Expected AHB001 error when SG3 is present but CTA missing"
4060        );
4061        assert!(narrate(ahb_errors[0]).contains("CTA"));
4062    }
4063
4064    #[test]
4065    fn test_missing_qualifier_with_navigator_is_detected() {
4066        // NAD+MS is in SG2 but NAD+MR is missing. With a navigator that
4067        // reports SG2 has 1 instance, the missing MR must still be flagged.
4068        use mig_types::navigator::GroupNavigator;
4069
4070        struct NavWithSG2;
4071        impl GroupNavigator for NavWithSG2 {
4072            fn find_segments_in_group(
4073                &self,
4074                segment_id: &str,
4075                group_path: &[&str],
4076                instance_index: usize,
4077            ) -> Vec<OwnedSegment> {
4078                if segment_id == "NAD" && group_path == ["SG2"] && instance_index == 0 {
4079                    vec![OwnedSegment {
4080                        id: "NAD".into(),
4081                        elements: vec![vec!["MS".into()]],
4082                        segment_number: 3,
4083                    }]
4084                } else {
4085                    vec![]
4086                }
4087            }
4088            fn find_segments_with_qualifier_in_group(
4089                &self,
4090                _: &str,
4091                _: usize,
4092                _: &str,
4093                _: &[&str],
4094                _: usize,
4095            ) -> Vec<OwnedSegment> {
4096                vec![]
4097            }
4098            fn group_instance_count(&self, group_path: &[&str]) -> usize {
4099                match group_path {
4100                    ["SG2"] => 1,
4101                    _ => 0,
4102                }
4103            }
4104        }
4105
4106        let evaluator = MockEvaluator::new(vec![]);
4107        let validator = EdifactValidator::new(evaluator);
4108        let external = NoOpExternalProvider;
4109        let nav = NavWithSG2;
4110
4111        let segments = vec![OwnedSegment {
4112            id: "NAD".into(),
4113            elements: vec![vec!["MS".into()]],
4114            segment_number: 3,
4115        }];
4116
4117        let workflow = AhbWorkflow {
4118            pruefidentifikator: "55001".to_string(),
4119            description: "Test".to_string(),
4120            communication_direction: None,
4121            fields: vec![
4122                AhbFieldRule {
4123                    segment_path: "SG2/NAD/3035".to_string(),
4124                    name: "Absender".to_string(),
4125                    ahb_status: "X".to_string(),
4126                    codes: vec![AhbCodeRule {
4127                        value: "MS".to_string(),
4128                        description: "Absender".to_string(),
4129                        ahb_status: "X".to_string(),
4130                    }],
4131                    parent_group_ahb_status: None,
4132                    segment_ahb_status: None,
4133                    ..Default::default()
4134                },
4135                AhbFieldRule {
4136                    segment_path: "SG2/NAD/3035".to_string(),
4137                    name: "Empfaenger".to_string(),
4138                    ahb_status: "Muss".to_string(),
4139                    codes: vec![AhbCodeRule {
4140                        value: "MR".to_string(),
4141                        description: "Empfaenger".to_string(),
4142                        ahb_status: "X".to_string(),
4143                    }],
4144                    parent_group_ahb_status: None,
4145                    segment_ahb_status: None,
4146                    ..Default::default()
4147                },
4148            ],
4149            ub_definitions: BTreeMap::new(),
4150            package_conditions: Default::default(),
4151        };
4152
4153        let report = validator.validate_with_navigator(
4154            &segments,
4155            &workflow,
4156            &external,
4157            ValidationLevel::Conditions,
4158            &nav,
4159        );
4160
4161        let ahb_errors: Vec<_> = report
4162            .by_category(ValidationCategory::Ahb)
4163            .filter(|i| i.severity == Severity::Error)
4164            .collect();
4165        assert_eq!(
4166            ahb_errors.len(),
4167            1,
4168            "Expected AHB001 for missing NAD+MR even with navigator, got: {:?}",
4169            ahb_errors
4170        );
4171        assert!(narrate(ahb_errors[0]).contains("Empfaenger"));
4172    }
4173
4174    #[test]
4175    fn test_optional_group_variant_absent_no_error() {
4176        // SG5 is "Kann" (optional) with LOC+Z16 present but LOC+Z17 absent.
4177        // Field rules for LOC/3227 with Z17 and its children should NOT error
4178        // because the parent group is optional and the variant is absent.
4179        // Meanwhile, SG2 is "Muss" — missing NAD+MR MUST still error.
4180        use mig_types::navigator::GroupNavigator;
4181
4182        struct TestNav;
4183        impl GroupNavigator for TestNav {
4184            fn find_segments_in_group(
4185                &self,
4186                segment_id: &str,
4187                group_path: &[&str],
4188                instance_index: usize,
4189            ) -> Vec<OwnedSegment> {
4190                match (segment_id, group_path, instance_index) {
4191                    ("LOC", ["SG4", "SG5"], 0) => vec![OwnedSegment {
4192                        id: "LOC".into(),
4193                        elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
4194                        segment_number: 10,
4195                    }],
4196                    ("NAD", ["SG2"], 0) => vec![OwnedSegment {
4197                        id: "NAD".into(),
4198                        elements: vec![vec!["MS".into()]],
4199                        segment_number: 3,
4200                    }],
4201                    _ => vec![],
4202                }
4203            }
4204            fn find_segments_with_qualifier_in_group(
4205                &self,
4206                _: &str,
4207                _: usize,
4208                _: &str,
4209                _: &[&str],
4210                _: usize,
4211            ) -> Vec<OwnedSegment> {
4212                vec![]
4213            }
4214            fn group_instance_count(&self, group_path: &[&str]) -> usize {
4215                match group_path {
4216                    ["SG2"] => 1,
4217                    ["SG4"] => 1,
4218                    ["SG4", "SG5"] => 1, // only Z16 instance
4219                    _ => 0,
4220                }
4221            }
4222        }
4223
4224        let evaluator = MockEvaluator::new(vec![]);
4225        let validator = EdifactValidator::new(evaluator);
4226        let external = NoOpExternalProvider;
4227        let nav = TestNav;
4228
4229        let segments = vec![
4230            OwnedSegment {
4231                id: "NAD".into(),
4232                elements: vec![vec!["MS".into()]],
4233                segment_number: 3,
4234            },
4235            OwnedSegment {
4236                id: "LOC".into(),
4237                elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
4238                segment_number: 10,
4239            },
4240        ];
4241
4242        let workflow = AhbWorkflow {
4243            pruefidentifikator: "55001".to_string(),
4244            description: "Test".to_string(),
4245            communication_direction: None,
4246            fields: vec![
4247                // SG2 "Muss" — NAD+MS present, NAD+MR missing → should error
4248                AhbFieldRule {
4249                    segment_path: "SG2/NAD/3035".to_string(),
4250                    name: "Absender".to_string(),
4251                    ahb_status: "X".to_string(),
4252                    codes: vec![AhbCodeRule {
4253                        value: "MS".to_string(),
4254                        description: "Absender".to_string(),
4255                        ahb_status: "X".to_string(),
4256                    }],
4257                    parent_group_ahb_status: Some("Muss".to_string()),
4258                    segment_ahb_status: None,
4259                    ..Default::default()
4260                },
4261                AhbFieldRule {
4262                    segment_path: "SG2/NAD/3035".to_string(),
4263                    name: "Empfaenger".to_string(),
4264                    ahb_status: "Muss".to_string(),
4265                    codes: vec![AhbCodeRule {
4266                        value: "MR".to_string(),
4267                        description: "Empfaenger".to_string(),
4268                        ahb_status: "X".to_string(),
4269                    }],
4270                    parent_group_ahb_status: Some("Muss".to_string()),
4271                    segment_ahb_status: None,
4272                    ..Default::default()
4273                },
4274                // SG5 "Kann" — LOC+Z16 present, LOC+Z17 absent → should NOT error
4275                AhbFieldRule {
4276                    segment_path: "SG4/SG5/LOC/3227".to_string(),
4277                    name: "Ortsangabe, Qualifier (Z16)".to_string(),
4278                    ahb_status: "X".to_string(),
4279                    codes: vec![AhbCodeRule {
4280                        value: "Z16".to_string(),
4281                        description: "Marktlokation".to_string(),
4282                        ahb_status: "X".to_string(),
4283                    }],
4284                    parent_group_ahb_status: Some("Kann".to_string()),
4285                    segment_ahb_status: None,
4286                    ..Default::default()
4287                },
4288                AhbFieldRule {
4289                    segment_path: "SG4/SG5/LOC/3227".to_string(),
4290                    name: "Ortsangabe, Qualifier (Z17)".to_string(),
4291                    ahb_status: "Muss".to_string(),
4292                    codes: vec![AhbCodeRule {
4293                        value: "Z17".to_string(),
4294                        description: "Messlokation".to_string(),
4295                        ahb_status: "X".to_string(),
4296                    }],
4297                    parent_group_ahb_status: Some("Kann".to_string()),
4298                    segment_ahb_status: None,
4299                    ..Default::default()
4300                },
4301            ],
4302            ub_definitions: BTreeMap::new(),
4303            package_conditions: Default::default(),
4304        };
4305
4306        let report = validator.validate_with_navigator(
4307            &segments,
4308            &workflow,
4309            &external,
4310            ValidationLevel::Conditions,
4311            &nav,
4312        );
4313
4314        let ahb_errors: Vec<_> = report
4315            .by_category(ValidationCategory::Ahb)
4316            .filter(|i| i.severity == Severity::Error)
4317            .collect();
4318
4319        // Exactly 1 error: NAD+MR missing (mandatory group)
4320        // LOC+Z17 should NOT error (optional group variant absent)
4321        assert_eq!(
4322            ahb_errors.len(),
4323            1,
4324            "Expected only AHB001 for missing NAD+MR, got: {:?}",
4325            ahb_errors
4326        );
4327        assert!(
4328            narrate(ahb_errors[0]).contains("Empfaenger"),
4329            "Error should be for missing NAD+MR (Empfaenger)"
4330        );
4331    }
4332
4333    /// UTILMD Gas 44143: SG8 "Messdatenregistriergerätedaten" (SEQ+Z50) is
4334    /// `Kann [345]`. With [345] true the variant is allowed, not required, so its
4335    /// absence — beside a present SEQ+Z03 SG8 — must not report its `SEQ 1229`.
4336    #[test]
4337    fn test_optional_group_variant_with_true_condition_absent_no_error() {
4338        use mig_types::navigator::GroupNavigator;
4339
4340        struct TestNav;
4341        impl GroupNavigator for TestNav {
4342            fn find_segments_in_group(
4343                &self,
4344                segment_id: &str,
4345                group_path: &[&str],
4346                instance_index: usize,
4347            ) -> Vec<OwnedSegment> {
4348                if segment_id == "SEQ" && group_path == ["SG4", "SG8"] && instance_index == 0 {
4349                    vec![OwnedSegment {
4350                        id: "SEQ".into(),
4351                        elements: vec![vec!["Z03".into()]],
4352                        segment_number: 10,
4353                    }]
4354                } else {
4355                    vec![]
4356                }
4357            }
4358            fn find_segments_with_qualifier_in_group(
4359                &self,
4360                _: &str,
4361                _: usize,
4362                _: &str,
4363                _: &[&str],
4364                _: usize,
4365            ) -> Vec<OwnedSegment> {
4366                vec![]
4367            }
4368            fn group_instance_count(&self, group_path: &[&str]) -> usize {
4369                match group_path {
4370                    ["SG4"] => 1,
4371                    ["SG4", "SG8"] => 1,
4372                    _ => 0,
4373                }
4374            }
4375        }
4376
4377        let evaluator = MockEvaluator::new(vec![(345, CR::True)]);
4378        let validator = EdifactValidator::new(evaluator);
4379        let segments = vec![OwnedSegment {
4380            id: "SEQ".into(),
4381            elements: vec![vec!["Z03".into()]],
4382            segment_number: 10,
4383        }];
4384        let seq = |code: &str, group: &str| AhbFieldRule {
4385            segment_path: "SG4/SG8/SEQ/1229".to_string(),
4386            name: format!("Handlung, Code ({code})"),
4387            ahb_status: "X".to_string(),
4388            codes: vec![AhbCodeRule {
4389                value: code.to_string(),
4390                description: String::new(),
4391                ahb_status: "X".to_string(),
4392            }],
4393            parent_group_ahb_status: Some(group.to_string()),
4394            segment_ahb_status: None,
4395            ..Default::default()
4396        };
4397        let workflow = AhbWorkflow {
4398            pruefidentifikator: "44143".to_string(),
4399            description: "Test".to_string(),
4400            communication_direction: None,
4401            fields: vec![seq("Z03", "Kann"), seq("Z50", "Kann [345]")],
4402            ub_definitions: BTreeMap::new(),
4403            package_conditions: Default::default(),
4404        };
4405
4406        let report = validator.validate_with_navigator(
4407            &segments,
4408            &workflow,
4409            &NoOpExternalProvider,
4410            ValidationLevel::Conditions,
4411            &TestNav,
4412        );
4413        let missing: Vec<_> = report
4414            .issues
4415            .iter()
4416            .filter(|i| matches!(i.kind, IssueKind::MissingRequiredField { .. }))
4417            .collect();
4418        assert!(missing.is_empty(), "{missing:#?}");
4419    }
4420
4421    #[test]
4422    fn test_conditional_group_variant_absent_no_error() {
4423        // Real-world scenario: SG5 "Messlokation" has AHB_Status="Soll [165]".
4424        // Condition [165] evaluates to False → LOC+Z17 should NOT error.
4425        // SG5 "Marktlokation" has AHB_Status="Muss [2061]".
4426        // Condition [2061] evaluates to True → LOC+Z16 is present → no error.
4427        use mig_types::navigator::GroupNavigator;
4428
4429        struct TestNav;
4430        impl GroupNavigator for TestNav {
4431            fn find_segments_in_group(
4432                &self,
4433                segment_id: &str,
4434                group_path: &[&str],
4435                instance_index: usize,
4436            ) -> Vec<OwnedSegment> {
4437                if segment_id == "LOC" && group_path == ["SG4", "SG5"] && instance_index == 0 {
4438                    vec![OwnedSegment {
4439                        id: "LOC".into(),
4440                        elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
4441                        segment_number: 10,
4442                    }]
4443                } else {
4444                    vec![]
4445                }
4446            }
4447            fn find_segments_with_qualifier_in_group(
4448                &self,
4449                _: &str,
4450                _: usize,
4451                _: &str,
4452                _: &[&str],
4453                _: usize,
4454            ) -> Vec<OwnedSegment> {
4455                vec![]
4456            }
4457            fn group_instance_count(&self, group_path: &[&str]) -> usize {
4458                match group_path {
4459                    ["SG4"] => 1,
4460                    ["SG4", "SG5"] => 1, // only Z16 instance
4461                    _ => 0,
4462                }
4463            }
4464        }
4465
4466        // Condition 165 → False (Messlokation not required)
4467        // Condition 2061 → True (Marktlokation required)
4468        let evaluator = MockEvaluator::new(vec![(165, CR::False), (2061, CR::True)]);
4469        let validator = EdifactValidator::new(evaluator);
4470        let external = NoOpExternalProvider;
4471        let nav = TestNav;
4472
4473        let segments = vec![OwnedSegment {
4474            id: "LOC".into(),
4475            elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
4476            segment_number: 10,
4477        }];
4478
4479        let workflow = AhbWorkflow {
4480            pruefidentifikator: "55001".to_string(),
4481            description: "Test".to_string(),
4482            communication_direction: None,
4483            fields: vec![
4484                // SG5 "Muss [2061]" with [2061]=True → LOC+Z16 required, present → OK
4485                AhbFieldRule {
4486                    segment_path: "SG4/SG5/LOC/3227".to_string(),
4487                    name: "Ortsangabe, Qualifier (Z16)".to_string(),
4488                    ahb_status: "X".to_string(),
4489                    codes: vec![AhbCodeRule {
4490                        value: "Z16".to_string(),
4491                        description: "Marktlokation".to_string(),
4492                        ahb_status: "X".to_string(),
4493                    }],
4494                    parent_group_ahb_status: Some("Muss [2061]".to_string()),
4495                    segment_ahb_status: None,
4496                    ..Default::default()
4497                },
4498                // SG5 "Soll [165]" with [165]=False → LOC+Z17 NOT required → skip
4499                AhbFieldRule {
4500                    segment_path: "SG4/SG5/LOC/3227".to_string(),
4501                    name: "Ortsangabe, Qualifier (Z17)".to_string(),
4502                    ahb_status: "X".to_string(),
4503                    codes: vec![AhbCodeRule {
4504                        value: "Z17".to_string(),
4505                        description: "Messlokation".to_string(),
4506                        ahb_status: "X".to_string(),
4507                    }],
4508                    parent_group_ahb_status: Some("Soll [165]".to_string()),
4509                    segment_ahb_status: None,
4510                    ..Default::default()
4511                },
4512            ],
4513            ub_definitions: BTreeMap::new(),
4514            package_conditions: Default::default(),
4515        };
4516
4517        let report = validator.validate_with_navigator(
4518            &segments,
4519            &workflow,
4520            &external,
4521            ValidationLevel::Conditions,
4522            &nav,
4523        );
4524
4525        let ahb_errors: Vec<_> = report
4526            .by_category(ValidationCategory::Ahb)
4527            .filter(|i| i.severity == Severity::Error)
4528            .collect();
4529
4530        // Zero errors: Z16 is present, and Z17's group condition is False
4531        assert!(
4532            ahb_errors.is_empty(),
4533            "Expected no errors when conditional group variant [165]=False, got: {:?}",
4534            ahb_errors
4535        );
4536    }
4537
4538    #[test]
4539    fn test_conditional_group_variant_unknown_no_error() {
4540        // When a parent group condition evaluates to Unknown (unimplemented
4541        // condition), child fields should NOT produce mandatory-missing errors.
4542        // The group-level entry itself will produce an Info "condition unknown".
4543
4544        // Condition 165 is NOT in the evaluator → returns Unknown
4545        let evaluator = MockEvaluator::new(vec![]);
4546        let validator = EdifactValidator::new(evaluator);
4547        let external = NoOpExternalProvider;
4548
4549        let workflow = AhbWorkflow {
4550            pruefidentifikator: "55001".to_string(),
4551            description: "Test".to_string(),
4552            communication_direction: None,
4553            fields: vec![AhbFieldRule {
4554                segment_path: "SG4/SG5/LOC/3227".to_string(),
4555                name: "Ortsangabe, Qualifier (Z17)".to_string(),
4556                ahb_status: "X".to_string(),
4557                codes: vec![AhbCodeRule {
4558                    value: "Z17".to_string(),
4559                    description: "Messlokation".to_string(),
4560                    ahb_status: "X".to_string(),
4561                }],
4562                parent_group_ahb_status: Some("Soll [165]".to_string()),
4563                segment_ahb_status: None,
4564                ..Default::default()
4565            }],
4566            ub_definitions: BTreeMap::new(),
4567            package_conditions: Default::default(),
4568        };
4569
4570        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
4571
4572        let ahb_errors: Vec<_> = report
4573            .by_category(ValidationCategory::Ahb)
4574            .filter(|i| i.severity == Severity::Error)
4575            .collect();
4576
4577        // No errors: parent group condition [165] is Unknown → skip child fields
4578        assert!(
4579            ahb_errors.is_empty(),
4580            "Expected no errors when parent group condition is Unknown, got: {:?}",
4581            ahb_errors
4582        );
4583    }
4584
4585    #[test]
4586    fn test_segment_absent_within_present_group_no_error() {
4587        // MSCONS scenario: SG10 has QTY (entry) + optional STS segments.
4588        // SG10 is present (QTY+220 exists) but STS is absent.
4589        // Fields under STS should NOT produce AHB001 errors.
4590        use mig_types::navigator::GroupNavigator;
4591
4592        struct TestNav;
4593        impl GroupNavigator for TestNav {
4594            fn find_segments_in_group(
4595                &self,
4596                segment_id: &str,
4597                group_path: &[&str],
4598                instance_index: usize,
4599            ) -> Vec<OwnedSegment> {
4600                // SG10 has QTY but no STS
4601                if segment_id == "QTY"
4602                    && group_path == ["SG5", "SG6", "SG9", "SG10"]
4603                    && instance_index == 0
4604                {
4605                    vec![OwnedSegment {
4606                        id: "QTY".into(),
4607                        elements: vec![vec!["220".into(), "0".into()]],
4608                        segment_number: 14,
4609                    }]
4610                } else {
4611                    vec![]
4612                }
4613            }
4614            fn find_segments_with_qualifier_in_group(
4615                &self,
4616                _: &str,
4617                _: usize,
4618                _: &str,
4619                _: &[&str],
4620                _: usize,
4621            ) -> Vec<OwnedSegment> {
4622                vec![]
4623            }
4624            fn group_instance_count(&self, group_path: &[&str]) -> usize {
4625                match group_path {
4626                    ["SG5"] => 1,
4627                    ["SG5", "SG6"] => 1,
4628                    ["SG5", "SG6", "SG9"] => 1,
4629                    ["SG5", "SG6", "SG9", "SG10"] => 1,
4630                    _ => 0,
4631                }
4632            }
4633            fn has_any_segment_in_group(&self, group_path: &[&str], instance_index: usize) -> bool {
4634                // SG10 instance 0 has QTY (the entry segment)
4635                group_path == ["SG5", "SG6", "SG9", "SG10"] && instance_index == 0
4636            }
4637        }
4638
4639        let evaluator = MockEvaluator::all_true(&[]);
4640        let validator = EdifactValidator::new(evaluator);
4641        let external = NoOpExternalProvider;
4642        let nav = TestNav;
4643
4644        let segments = vec![OwnedSegment {
4645            id: "QTY".into(),
4646            elements: vec![vec!["220".into(), "0".into()]],
4647            segment_number: 14,
4648        }];
4649
4650        let workflow = AhbWorkflow {
4651            pruefidentifikator: "13017".to_string(),
4652            description: "Test".to_string(),
4653            communication_direction: None,
4654            fields: vec![
4655                // STS/C601/9015 — mandatory field under STS, but STS is absent from SG10
4656                AhbFieldRule {
4657                    segment_path: "SG5/SG6/SG9/SG10/STS/C601/9015".to_string(),
4658                    name: "Statuskategorie, Code".to_string(),
4659                    ahb_status: "X".to_string(),
4660                    codes: vec![],
4661                    parent_group_ahb_status: Some("Muss".to_string()),
4662                    segment_ahb_status: None,
4663                    ..Default::default()
4664                },
4665                // STS/C556/9013 — another field under STS
4666                AhbFieldRule {
4667                    segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4668                    name: "Statusanlaß, Code".to_string(),
4669                    ahb_status: "X [5]".to_string(),
4670                    codes: vec![],
4671                    parent_group_ahb_status: Some("Muss".to_string()),
4672                    segment_ahb_status: None,
4673                    ..Default::default()
4674                },
4675            ],
4676            ub_definitions: BTreeMap::new(),
4677            package_conditions: Default::default(),
4678        };
4679
4680        let report = validator.validate_with_navigator(
4681            &segments,
4682            &workflow,
4683            &external,
4684            ValidationLevel::Conditions,
4685            &nav,
4686        );
4687
4688        let ahb_errors: Vec<_> = report
4689            .by_category(ValidationCategory::Ahb)
4690            .filter(|i| i.severity == Severity::Error)
4691            .collect();
4692
4693        assert!(
4694            ahb_errors.is_empty(),
4695            "Expected no AHB001 errors when STS segment is absent from SG10, got: {:?}",
4696            ahb_errors
4697        );
4698    }
4699
4700    #[test]
4701    fn test_group_scoped_code_validation_with_navigator() {
4702        // With a navigator, SG2/NAD is checked against {MS, MR} only,
4703        // and SG4/SG12/NAD is checked against {Z04, Z09} only.
4704        // NAD+MT in SG2 → error with allowed [MR, MS] (not the full union).
4705        use mig_types::navigator::GroupNavigator;
4706
4707        struct TestNav;
4708        impl GroupNavigator for TestNav {
4709            fn find_segments_in_group(
4710                &self,
4711                segment_id: &str,
4712                group_path: &[&str],
4713                _instance_index: usize,
4714            ) -> Vec<OwnedSegment> {
4715                if segment_id != "NAD" {
4716                    return vec![];
4717                }
4718                match group_path {
4719                    ["SG2"] => vec![
4720                        OwnedSegment {
4721                            id: "NAD".into(),
4722                            elements: vec![vec!["MS".into()]],
4723                            segment_number: 3,
4724                        },
4725                        OwnedSegment {
4726                            id: "NAD".into(),
4727                            elements: vec![vec!["MT".into()]], // invalid in SG2
4728                            segment_number: 4,
4729                        },
4730                    ],
4731                    ["SG4", "SG12"] => vec![
4732                        OwnedSegment {
4733                            id: "NAD".into(),
4734                            elements: vec![vec!["Z04".into()]],
4735                            segment_number: 20,
4736                        },
4737                        OwnedSegment {
4738                            id: "NAD".into(),
4739                            elements: vec![vec!["Z09".into()]],
4740                            segment_number: 21,
4741                        },
4742                    ],
4743                    _ => vec![],
4744                }
4745            }
4746            fn find_segments_with_qualifier_in_group(
4747                &self,
4748                _: &str,
4749                _: usize,
4750                _: &str,
4751                _: &[&str],
4752                _: usize,
4753            ) -> Vec<OwnedSegment> {
4754                vec![]
4755            }
4756            fn group_instance_count(&self, group_path: &[&str]) -> usize {
4757                match group_path {
4758                    ["SG2"] | ["SG4", "SG12"] => 1,
4759                    _ => 0,
4760                }
4761            }
4762        }
4763
4764        let evaluator = MockEvaluator::new(vec![]);
4765        let validator = EdifactValidator::new(evaluator);
4766        let external = NoOpExternalProvider;
4767        let nav = TestNav;
4768
4769        let workflow = AhbWorkflow {
4770            pruefidentifikator: "55001".to_string(),
4771            description: "Test".to_string(),
4772            communication_direction: None,
4773            fields: vec![
4774                AhbFieldRule {
4775                    segment_path: "SG2/NAD/3035".to_string(),
4776                    name: "Absender".to_string(),
4777                    ahb_status: "X".to_string(),
4778                    codes: vec![AhbCodeRule {
4779                        value: "MS".to_string(),
4780                        description: "Absender".to_string(),
4781                        ahb_status: "X".to_string(),
4782                    }],
4783                    parent_group_ahb_status: None,
4784                    segment_ahb_status: None,
4785                    ..Default::default()
4786                },
4787                AhbFieldRule {
4788                    segment_path: "SG2/NAD/3035".to_string(),
4789                    name: "Empfaenger".to_string(),
4790                    ahb_status: "X".to_string(),
4791                    codes: vec![AhbCodeRule {
4792                        value: "MR".to_string(),
4793                        description: "Empfaenger".to_string(),
4794                        ahb_status: "X".to_string(),
4795                    }],
4796                    parent_group_ahb_status: None,
4797                    segment_ahb_status: None,
4798                    ..Default::default()
4799                },
4800                AhbFieldRule {
4801                    segment_path: "SG4/SG12/NAD/3035".to_string(),
4802                    name: "Anschlussnutzer".to_string(),
4803                    ahb_status: "X".to_string(),
4804                    codes: vec![AhbCodeRule {
4805                        value: "Z04".to_string(),
4806                        description: "Anschlussnutzer".to_string(),
4807                        ahb_status: "X".to_string(),
4808                    }],
4809                    parent_group_ahb_status: None,
4810                    segment_ahb_status: None,
4811                    ..Default::default()
4812                },
4813                AhbFieldRule {
4814                    segment_path: "SG4/SG12/NAD/3035".to_string(),
4815                    name: "Korrespondenzanschrift".to_string(),
4816                    ahb_status: "X".to_string(),
4817                    codes: vec![AhbCodeRule {
4818                        value: "Z09".to_string(),
4819                        description: "Korrespondenzanschrift".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            ],
4827            ub_definitions: BTreeMap::new(),
4828            package_conditions: Default::default(),
4829        };
4830
4831        // All segments flat (for condition evaluation), navigator provides group scope.
4832        let all_segments = vec![
4833            OwnedSegment {
4834                id: "NAD".into(),
4835                elements: vec![vec!["MS".into()]],
4836                segment_number: 3,
4837            },
4838            OwnedSegment {
4839                id: "NAD".into(),
4840                elements: vec![vec!["MT".into()]],
4841                segment_number: 4,
4842            },
4843            OwnedSegment {
4844                id: "NAD".into(),
4845                elements: vec![vec!["Z04".into()]],
4846                segment_number: 20,
4847            },
4848            OwnedSegment {
4849                id: "NAD".into(),
4850                elements: vec![vec!["Z09".into()]],
4851                segment_number: 21,
4852            },
4853        ];
4854
4855        let report = validator.validate_with_navigator(
4856            &all_segments,
4857            &workflow,
4858            &external,
4859            ValidationLevel::Conditions,
4860            &nav,
4861        );
4862
4863        let code_errors: Vec<_> = report
4864            .by_category(ValidationCategory::Code)
4865            .filter(|i| i.severity == Severity::Error)
4866            .collect();
4867
4868        // Only one error: MT in SG2 (not allowed in {MS, MR}).
4869        // Z04 and Z09 are NOT checked against {MS, MR} — group-scoped.
4870        assert_eq!(
4871            code_errors.len(),
4872            1,
4873            "Expected exactly one COD002 error for MT in SG2, got: {:?}",
4874            code_errors
4875        );
4876        assert!(narrate(code_errors[0]).contains("MT"));
4877        // Error should show only SG2's allowed codes, not the full union
4878        assert!(narrate(code_errors[0]).contains("MR"));
4879        assert!(narrate(code_errors[0]).contains("MS"));
4880        assert!(
4881            !narrate(code_errors[0]).contains("Z04"),
4882            "SG4/SG12 codes should not leak into SG2 error"
4883        );
4884        // Field path should include the group
4885        assert!(
4886            code_errors[0]
4887                .field_path
4888                .as_deref()
4889                .unwrap_or("")
4890                .contains("SG2"),
4891            "Error field_path should reference SG2, got: {:?}",
4892            code_errors[0].field_path
4893        );
4894    }
4895
4896    // === Package cardinality tests ===
4897
4898    #[test]
4899    fn test_package_cardinality_within_bounds() {
4900        // 1 code present from package [4P0..1], max=1 -> OK
4901        let evaluator = MockEvaluator::all_true(&[]);
4902        let validator = EdifactValidator::new(evaluator);
4903        let external = NoOpExternalProvider;
4904
4905        let segments = vec![OwnedSegment {
4906            id: "STS".into(),
4907            elements: vec![
4908                vec!["Z33".into()], // element[0]
4909                vec![],             // element[1]
4910                vec!["E01".into()], // element[2], component[0]
4911            ],
4912            segment_number: 5,
4913        }];
4914
4915        let workflow = AhbWorkflow {
4916            pruefidentifikator: "13017".to_string(),
4917            description: "Test".to_string(),
4918            communication_direction: None,
4919            ub_definitions: BTreeMap::new(),
4920            package_conditions: Default::default(),
4921            fields: vec![AhbFieldRule {
4922                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4923                name: "Statusanlaß, Code".to_string(),
4924                ahb_status: "X".to_string(),
4925                element_index: Some(2),
4926                component_index: Some(0),
4927                codes: vec![
4928                    AhbCodeRule {
4929                        value: "E01".into(),
4930                        description: "Code 1".into(),
4931                        ahb_status: "X [4P0..1]".into(),
4932                    },
4933                    AhbCodeRule {
4934                        value: "E02".into(),
4935                        description: "Code 2".into(),
4936                        ahb_status: "X [4P0..1]".into(),
4937                    },
4938                ],
4939                parent_group_ahb_status: Some("Muss".to_string()),
4940                segment_ahb_status: None,
4941                mig_number: None,
4942                format: None,
4943            }],
4944        };
4945
4946        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
4947        let pkg_errors: Vec<_> = report
4948            .by_category(ValidationCategory::Ahb)
4949            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4950            .collect();
4951        assert!(
4952            pkg_errors.is_empty(),
4953            "1 code within [4P0..1] bounds — no error expected, got: {:?}",
4954            pkg_errors
4955        );
4956    }
4957
4958    #[test]
4959    fn test_package_cardinality_zero_present_min_zero() {
4960        // No codes from the package present, min=0 -> OK
4961        let evaluator = MockEvaluator::all_true(&[]);
4962        let validator = EdifactValidator::new(evaluator);
4963        let external = NoOpExternalProvider;
4964
4965        let segments = vec![OwnedSegment {
4966            id: "STS".into(),
4967            elements: vec![
4968                vec!["Z33".into()],
4969                vec![],
4970                vec!["X99".into()], // X99 not in package
4971            ],
4972            segment_number: 5,
4973        }];
4974
4975        let workflow = AhbWorkflow {
4976            pruefidentifikator: "13017".to_string(),
4977            description: "Test".to_string(),
4978            communication_direction: None,
4979            ub_definitions: BTreeMap::new(),
4980            package_conditions: Default::default(),
4981            fields: vec![AhbFieldRule {
4982                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4983                name: "Statusanlaß, Code".to_string(),
4984                ahb_status: "X".to_string(),
4985                element_index: Some(2),
4986                component_index: Some(0),
4987                codes: vec![
4988                    AhbCodeRule {
4989                        value: "E01".into(),
4990                        description: "Code 1".into(),
4991                        ahb_status: "X [4P0..1]".into(),
4992                    },
4993                    AhbCodeRule {
4994                        value: "E02".into(),
4995                        description: "Code 2".into(),
4996                        ahb_status: "X [4P0..1]".into(),
4997                    },
4998                ],
4999                parent_group_ahb_status: Some("Muss".to_string()),
5000                segment_ahb_status: None,
5001                mig_number: None,
5002                format: None,
5003            }],
5004        };
5005
5006        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
5007        let pkg_errors: Vec<_> = report
5008            .by_category(ValidationCategory::Ahb)
5009            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5010            .collect();
5011        assert!(
5012            pkg_errors.is_empty(),
5013            "0 codes, min=0 — no error expected, got: {:?}",
5014            pkg_errors
5015        );
5016    }
5017
5018    #[test]
5019    fn test_package_cardinality_too_many() {
5020        // 2 codes present from package [4P0..1], max=1 -> ERROR
5021        let evaluator = MockEvaluator::all_true(&[]);
5022        let validator = EdifactValidator::new(evaluator);
5023        let external = NoOpExternalProvider;
5024
5025        // Two STS segments, each with a different package code
5026        let segments = vec![
5027            OwnedSegment {
5028                id: "STS".into(),
5029                elements: vec![vec!["Z33".into()], vec![], vec!["E01".into()]],
5030                segment_number: 5,
5031            },
5032            OwnedSegment {
5033                id: "STS".into(),
5034                elements: vec![vec!["Z33".into()], vec![], vec!["E02".into()]],
5035                segment_number: 6,
5036            },
5037        ];
5038
5039        let workflow = AhbWorkflow {
5040            pruefidentifikator: "13017".to_string(),
5041            description: "Test".to_string(),
5042            communication_direction: None,
5043            ub_definitions: BTreeMap::new(),
5044            package_conditions: Default::default(),
5045            fields: vec![AhbFieldRule {
5046                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
5047                name: "Statusanlaß, Code".to_string(),
5048                ahb_status: "X".to_string(),
5049                element_index: Some(2),
5050                component_index: Some(0),
5051                codes: vec![
5052                    AhbCodeRule {
5053                        value: "E01".into(),
5054                        description: "Code 1".into(),
5055                        ahb_status: "X [4P0..1]".into(),
5056                    },
5057                    AhbCodeRule {
5058                        value: "E02".into(),
5059                        description: "Code 2".into(),
5060                        ahb_status: "X [4P0..1]".into(),
5061                    },
5062                ],
5063                parent_group_ahb_status: Some("Muss".to_string()),
5064                segment_ahb_status: None,
5065                mig_number: None,
5066                format: None,
5067            }],
5068        };
5069
5070        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
5071        let pkg_errors: Vec<_> = report
5072            .by_category(ValidationCategory::Ahb)
5073            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5074            .collect();
5075        assert_eq!(
5076            pkg_errors.len(),
5077            1,
5078            "2 codes present, max=1 — expected 1 error, got: {:?}",
5079            pkg_errors
5080        );
5081        assert!(narrate(pkg_errors[0]).contains("[4P0..1]"));
5082        assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("2"));
5083        assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("0..1"));
5084    }
5085
5086    #[test]
5087    fn test_package_cardinality_too_few() {
5088        // 0 codes present from package [5P1..3], min=1 -> ERROR
5089        let evaluator = MockEvaluator::all_true(&[]);
5090        let validator = EdifactValidator::new(evaluator);
5091        let external = NoOpExternalProvider;
5092
5093        let segments = vec![OwnedSegment {
5094            id: "STS".into(),
5095            elements: vec![
5096                vec!["Z33".into()],
5097                vec![],
5098                vec!["X99".into()], // not in package
5099            ],
5100            segment_number: 5,
5101        }];
5102
5103        let workflow = AhbWorkflow {
5104            pruefidentifikator: "13017".to_string(),
5105            description: "Test".to_string(),
5106            communication_direction: None,
5107            ub_definitions: BTreeMap::new(),
5108            package_conditions: Default::default(),
5109            fields: vec![AhbFieldRule {
5110                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
5111                name: "Statusanlaß, Code".to_string(),
5112                ahb_status: "X".to_string(),
5113                element_index: Some(2),
5114                component_index: Some(0),
5115                codes: vec![
5116                    AhbCodeRule {
5117                        value: "E01".into(),
5118                        description: "Code 1".into(),
5119                        ahb_status: "X [5P1..3]".into(),
5120                    },
5121                    AhbCodeRule {
5122                        value: "E02".into(),
5123                        description: "Code 2".into(),
5124                        ahb_status: "X [5P1..3]".into(),
5125                    },
5126                    AhbCodeRule {
5127                        value: "E03".into(),
5128                        description: "Code 3".into(),
5129                        ahb_status: "X [5P1..3]".into(),
5130                    },
5131                ],
5132                parent_group_ahb_status: Some("Muss".to_string()),
5133                segment_ahb_status: None,
5134                mig_number: None,
5135                format: None,
5136            }],
5137        };
5138
5139        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
5140        let pkg_errors: Vec<_> = report
5141            .by_category(ValidationCategory::Ahb)
5142            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5143            .collect();
5144        assert_eq!(
5145            pkg_errors.len(),
5146            1,
5147            "0 codes present, min=1 — expected 1 error, got: {:?}",
5148            pkg_errors
5149        );
5150        assert!(narrate(pkg_errors[0]).contains("[5P1..3]"));
5151        assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("0"));
5152        assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("1..3"));
5153    }
5154
5155    #[test]
5156    fn test_package_cardinality_no_packages_in_workflow() {
5157        // Codes without package annotations -> no package errors
5158        let evaluator = MockEvaluator::all_true(&[]);
5159        let validator = EdifactValidator::new(evaluator);
5160        let external = NoOpExternalProvider;
5161
5162        let segments = vec![OwnedSegment {
5163            id: "STS".into(),
5164            elements: vec![vec!["E01".into()]],
5165            segment_number: 5,
5166        }];
5167
5168        let workflow = AhbWorkflow {
5169            pruefidentifikator: "13017".to_string(),
5170            description: "Test".to_string(),
5171            communication_direction: None,
5172            ub_definitions: BTreeMap::new(),
5173            package_conditions: Default::default(),
5174            fields: vec![AhbFieldRule {
5175                segment_path: "STS/9015".to_string(),
5176                name: "Status Code".to_string(),
5177                ahb_status: "X".to_string(),
5178                codes: vec![AhbCodeRule {
5179                    value: "E01".into(),
5180                    description: "Code 1".into(),
5181                    ahb_status: "X".into(),
5182                }],
5183                parent_group_ahb_status: Some("Muss".to_string()),
5184                segment_ahb_status: None,
5185                ..Default::default()
5186            }],
5187        };
5188
5189        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
5190        let pkg_errors: Vec<_> = report
5191            .by_category(ValidationCategory::Ahb)
5192            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5193            .collect();
5194        assert!(
5195            pkg_errors.is_empty(),
5196            "No packages in workflow — no errors expected"
5197        );
5198    }
5199
5200    #[test]
5201    fn test_package_cardinality_with_condition_and_package() {
5202        // Code status "X [901] [4P0..1]" has both condition and package
5203        let evaluator = MockEvaluator::all_true(&[901]);
5204        let validator = EdifactValidator::new(evaluator);
5205        let external = NoOpExternalProvider;
5206
5207        let segments = vec![OwnedSegment {
5208            id: "STS".into(),
5209            elements: vec![vec![], vec![], vec!["E01".into()]],
5210            segment_number: 5,
5211        }];
5212
5213        let workflow = AhbWorkflow {
5214            pruefidentifikator: "13017".to_string(),
5215            description: "Test".to_string(),
5216            communication_direction: None,
5217            ub_definitions: BTreeMap::new(),
5218            package_conditions: Default::default(),
5219            fields: vec![AhbFieldRule {
5220                segment_path: "SG10/STS/C556/9013".to_string(),
5221                name: "Code".to_string(),
5222                ahb_status: "X".to_string(),
5223                element_index: Some(2),
5224                component_index: Some(0),
5225                codes: vec![
5226                    AhbCodeRule {
5227                        value: "E01".into(),
5228                        description: "Code 1".into(),
5229                        ahb_status: "X [901] [4P0..1]".into(),
5230                    },
5231                    AhbCodeRule {
5232                        value: "E02".into(),
5233                        description: "Code 2".into(),
5234                        ahb_status: "X [901] [4P0..1]".into(),
5235                    },
5236                ],
5237                parent_group_ahb_status: Some("Muss".to_string()),
5238                segment_ahb_status: None,
5239                mig_number: None,
5240                format: None,
5241            }],
5242        };
5243
5244        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
5245        let pkg_errors: Vec<_> = report
5246            .by_category(ValidationCategory::Ahb)
5247            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5248            .collect();
5249        assert!(
5250            pkg_errors.is_empty(),
5251            "1 code within [4P0..1] bounds — no error, got: {:?}",
5252            pkg_errors
5253        );
5254    }
5255
5256    #[test]
5257    fn test_package_binds_only_where_its_condition_holds() {
5258        // `[5P1..1]` with the package condition `[902]` (the AHB's
5259        // `<Paket Nummer="[5P]">[902]</Paket>`): no code of the package is
5260        // sent, which is a violation only where [902] holds.
5261        let report_for = |package_condition: CR| {
5262            let validator =
5263                EdifactValidator::new(MockEvaluator::new(vec![(902, package_condition)]));
5264            let segments = vec![OwnedSegment {
5265                id: "STS".into(),
5266                elements: vec![vec![], vec![], vec!["E09".into()]],
5267                segment_number: 5,
5268            }];
5269            let workflow = AhbWorkflow {
5270                pruefidentifikator: "13017".to_string(),
5271                description: "Test".to_string(),
5272                communication_direction: None,
5273                ub_definitions: BTreeMap::new(),
5274                package_conditions: BTreeMap::from([(5, ConditionExpr::Ref(902))]),
5275                fields: vec![AhbFieldRule {
5276                    segment_path: "SG10/STS/C556/9013".to_string(),
5277                    name: "Code".to_string(),
5278                    ahb_status: "X".to_string(),
5279                    element_index: Some(2),
5280                    component_index: Some(0),
5281                    codes: vec![
5282                        AhbCodeRule {
5283                            value: "E01".into(),
5284                            description: "Code 1".into(),
5285                            ahb_status: "X [5P1..1]".into(),
5286                        },
5287                        AhbCodeRule {
5288                            value: "E09".into(),
5289                            description: "Code 9".into(),
5290                            ahb_status: "X".into(),
5291                        },
5292                    ],
5293                    parent_group_ahb_status: Some("Muss".to_string()),
5294                    segment_ahb_status: None,
5295                    mig_number: None,
5296                    format: None,
5297                }],
5298            };
5299            validator
5300                .validate(
5301                    &segments,
5302                    &workflow,
5303                    &NoOpExternalProvider,
5304                    ValidationLevel::Full,
5305                )
5306                .by_category(ValidationCategory::Ahb)
5307                .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5308                .count()
5309        };
5310        assert_eq!(report_for(CR::False), 0, "[902] false: [5P] does not bind");
5311        assert_eq!(report_for(CR::True), 1, "[902] true: [5P1..1] binds");
5312    }
5313
5314    #[test]
5315    fn test_package_cardinality_scoped_per_group_instance() {
5316        // Package [4P0..1] caps the number of codes per SG10 group instance at 0..1.
5317        // MSCONS PID 13025 messages contain many SG10 reps (one per interval); each
5318        // rep has its own STS+Z32++<reason> segment using one code from the package.
5319        // The old global count summed codes across reps and flagged every
5320        // multi-interval message (e.g. 96 intervals → 96 codes "allowed 0..1").
5321        // With a navigator present, package cardinality must be evaluated per
5322        // group instance, not message-wide.
5323        use mig_types::navigator::GroupNavigator;
5324
5325        struct TwoSg10s {
5326            sts_a: OwnedSegment,
5327            sts_b: OwnedSegment,
5328        }
5329        impl GroupNavigator for TwoSg10s {
5330            fn find_segments_in_group(
5331                &self,
5332                segment_id: &str,
5333                group_path: &[&str],
5334                instance_index: usize,
5335            ) -> Vec<OwnedSegment> {
5336                if group_path == ["SG5", "SG6", "SG9", "SG10"] && segment_id == "STS" {
5337                    match instance_index {
5338                        0 => vec![self.sts_a.clone()],
5339                        1 => vec![self.sts_b.clone()],
5340                        _ => vec![],
5341                    }
5342                } else {
5343                    vec![]
5344                }
5345            }
5346            fn find_segments_with_qualifier_in_group(
5347                &self,
5348                _: &str,
5349                _: usize,
5350                _: &str,
5351                _: &[&str],
5352                _: usize,
5353            ) -> Vec<OwnedSegment> {
5354                vec![]
5355            }
5356            fn group_instance_count(&self, group_path: &[&str]) -> usize {
5357                match group_path {
5358                    ["SG5"] | ["SG5", "SG6"] | ["SG5", "SG6", "SG9"] => 1,
5359                    ["SG5", "SG6", "SG9", "SG10"] => 2,
5360                    _ => 0,
5361                }
5362            }
5363        }
5364
5365        let sts_a = OwnedSegment {
5366            id: "STS".into(),
5367            elements: vec![vec!["Z32".into()], vec![], vec!["E01".into()]],
5368            segment_number: 10,
5369        };
5370        let sts_b = OwnedSegment {
5371            id: "STS".into(),
5372            elements: vec![vec!["Z32".into()], vec![], vec!["E01".into()]],
5373            segment_number: 15,
5374        };
5375        let nav = TwoSg10s {
5376            sts_a: sts_a.clone(),
5377            sts_b: sts_b.clone(),
5378        };
5379
5380        let evaluator = MockEvaluator::all_true(&[]);
5381        let validator = EdifactValidator::new(evaluator);
5382        let external = NoOpExternalProvider;
5383
5384        let workflow = AhbWorkflow {
5385            pruefidentifikator: "13025".to_string(),
5386            description: "Test".to_string(),
5387            communication_direction: None,
5388            ub_definitions: BTreeMap::new(),
5389            package_conditions: Default::default(),
5390            fields: vec![AhbFieldRule {
5391                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
5392                name: "Statusanlaß, Code".to_string(),
5393                ahb_status: "X".to_string(),
5394                element_index: Some(2),
5395                component_index: Some(0),
5396                codes: vec![
5397                    AhbCodeRule {
5398                        value: "E01".into(),
5399                        description: "Code 1".into(),
5400                        ahb_status: "X [4P0..1]".into(),
5401                    },
5402                    AhbCodeRule {
5403                        value: "E02".into(),
5404                        description: "Code 2".into(),
5405                        ahb_status: "X [4P0..1]".into(),
5406                    },
5407                ],
5408                parent_group_ahb_status: Some("Muss".to_string()),
5409                segment_ahb_status: None,
5410                mig_number: None,
5411                format: None,
5412            }],
5413        };
5414
5415        let report = validator.validate_with_navigator(
5416            &[sts_a, sts_b],
5417            &workflow,
5418            &external,
5419            ValidationLevel::Full,
5420            &nav,
5421        );
5422        let pkg_errors: Vec<_> = report
5423            .by_category(ValidationCategory::Ahb)
5424            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5425            .collect();
5426        assert!(
5427            pkg_errors.is_empty(),
5428            "Package cardinality is per-instance: 1 code per SG10 rep is within [4P0..1]. Got: {:?}",
5429            pkg_errors
5430        );
5431    }
5432
5433    fn make_segment(id: &str, elements: Vec<Vec<&str>>) -> OwnedSegment {
5434        OwnedSegment {
5435            id: id.to_string(),
5436            elements: elements
5437                .into_iter()
5438                .map(|e| e.into_iter().map(|s| s.to_string()).collect())
5439                .collect(),
5440            segment_number: 0,
5441        }
5442    }
5443
5444    /// Package cardinality must not fire against sibling group variants.
5445    ///
5446    /// PID 55218 has two SG8 variants: `sg8_z01` (SEQ mig=00115, no
5447    /// package) and `sg8_z45_z84` (SEQ mig=00171, with `[1P1..n]`
5448    /// package on Z45/Z84). The rule is scoped to mig=00171, but the
5449    /// old per-instance loop counted codes across every SG8 instance
5450    /// (including the Z01 sibling) and fired "0 codes present" on
5451    /// instances belonging to the other variant. With
5452    /// `instance_has_mig_number` the loop skips instances whose variant
5453    /// doesn't define the rule's mig_number.
5454    #[test]
5455    fn test_package_cardinality_scoped_to_rule_mig_variant() {
5456        use mig_types::navigator::GroupNavigator;
5457
5458        struct TwoSg8Variants {
5459            seq_z01: OwnedSegment,
5460            seq_z45: OwnedSegment,
5461        }
5462        impl GroupNavigator for TwoSg8Variants {
5463            fn find_segments_in_group(
5464                &self,
5465                segment_id: &str,
5466                group_path: &[&str],
5467                instance_index: usize,
5468            ) -> Vec<OwnedSegment> {
5469                if group_path == ["SG4", "SG8"] && segment_id == "SEQ" {
5470                    match instance_index {
5471                        0 => vec![self.seq_z01.clone()],
5472                        1 => vec![self.seq_z45.clone()],
5473                        _ => vec![],
5474                    }
5475                } else {
5476                    vec![]
5477                }
5478            }
5479            fn find_segments_with_qualifier_in_group(
5480                &self,
5481                _: &str,
5482                _: usize,
5483                _: &str,
5484                _: &[&str],
5485                _: usize,
5486            ) -> Vec<OwnedSegment> {
5487                vec![]
5488            }
5489            fn group_instance_count(&self, group_path: &[&str]) -> usize {
5490                match group_path {
5491                    ["SG4"] => 1,
5492                    ["SG4", "SG8"] => 2,
5493                    _ => 0,
5494                }
5495            }
5496            fn instance_has_mig_number(
5497                &self,
5498                group_path: &[&str],
5499                instance_index: usize,
5500                mig_number: &str,
5501            ) -> bool {
5502                if group_path != ["SG4", "SG8"] {
5503                    return true;
5504                }
5505                match (instance_index, mig_number) {
5506                    (0, "00115") => true,
5507                    (0, _) => false,
5508                    (1, "00171") => true,
5509                    (1, _) => false,
5510                    _ => false,
5511                }
5512            }
5513        }
5514
5515        let seq_z01 = OwnedSegment {
5516            id: "SEQ".into(),
5517            elements: vec![vec!["Z01".into()], vec!["1".into()]],
5518            segment_number: 10,
5519        };
5520        let seq_z45 = OwnedSegment {
5521            id: "SEQ".into(),
5522            elements: vec![vec!["Z45".into()], vec!["1".into()]],
5523            segment_number: 20,
5524        };
5525        let nav = TwoSg8Variants {
5526            seq_z01: seq_z01.clone(),
5527            seq_z45: seq_z45.clone(),
5528        };
5529
5530        let evaluator = MockEvaluator::all_true(&[]);
5531        let validator = EdifactValidator::new(evaluator);
5532        let external = NoOpExternalProvider;
5533
5534        let workflow = AhbWorkflow {
5535            pruefidentifikator: "55218".to_string(),
5536            description: "Test".to_string(),
5537            communication_direction: None,
5538            ub_definitions: BTreeMap::new(),
5539            package_conditions: Default::default(),
5540            fields: vec![AhbFieldRule {
5541                segment_path: "SG4/SG8/SEQ/1229".to_string(),
5542                name: "Handlung, Code".to_string(),
5543                ahb_status: "X".to_string(),
5544                element_index: Some(0),
5545                component_index: Some(0),
5546                codes: vec![
5547                    AhbCodeRule {
5548                        value: "Z45".into(),
5549                        description: "NNA".into(),
5550                        ahb_status: "X [1P1..4294967295]".into(),
5551                    },
5552                    AhbCodeRule {
5553                        value: "Z84".into(),
5554                        description: "Differenz-NNA".into(),
5555                        ahb_status: "X [1P0..4294967295]".into(),
5556                    },
5557                ],
5558                parent_group_ahb_status: Some("Muss".to_string()),
5559                segment_ahb_status: None,
5560                mig_number: Some("00171".to_string()),
5561                format: None,
5562            }],
5563        };
5564
5565        let report = validator.validate_with_navigator(
5566            &[seq_z01, seq_z45],
5567            &workflow,
5568            &external,
5569            ValidationLevel::Full,
5570            &nav,
5571        );
5572        let pkg_errors: Vec<_> = report
5573            .by_category(ValidationCategory::Ahb)
5574            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5575            .collect();
5576        assert!(
5577            pkg_errors.is_empty(),
5578            "Package rule with mig=00171 must only count the Z45 instance (which has 1 code), not the sibling Z01 variant. Got: {:?}",
5579            pkg_errors
5580        );
5581    }
5582
5583    #[test]
5584    fn test_unt_count_correct() {
5585        // UNH + BGM + DTM + UNT = 4 segments, UNT declares 4
5586        let segments = vec![
5587            make_segment("UNH", vec![vec!["001"]]),
5588            make_segment("BGM", vec![vec!["E01"]]),
5589            make_segment("DTM", vec![vec!["137", "20250401"]]),
5590            make_segment("UNT", vec![vec!["4", "001"]]),
5591        ];
5592        assert!(
5593            validate_unt_segment_count(&segments).is_none(),
5594            "Correct count should produce no issue"
5595        );
5596    }
5597
5598    #[test]
5599    fn test_unt_count_mismatch() {
5600        // UNH + BGM + UNT = 3 segments, but UNT declares 5
5601        let segments = vec![
5602            make_segment("UNH", vec![vec!["001"]]),
5603            make_segment("BGM", vec![vec!["E01"]]),
5604            make_segment("UNT", vec![vec!["5", "001"]]),
5605        ];
5606        let issue =
5607            validate_unt_segment_count(&segments).expect("Mismatch should produce an issue");
5608        assert_eq!(issue.code(), ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH);
5609        assert_eq!(issue.severity, Severity::Error);
5610        assert!(narrate(&issue).contains("declared 5"));
5611        assert!(narrate(&issue).contains("actual 3"));
5612    }
5613
5614    #[test]
5615    fn test_unt_count_excludes_envelope() {
5616        // Envelope segments (UNA, UNB, UNZ) should not be counted
5617        let segments = vec![
5618            make_segment("UNA", vec![]),
5619            make_segment("UNB", vec![vec!["UNOC", "3"]]),
5620            make_segment("UNH", vec![vec!["001"]]),
5621            make_segment("BGM", vec![vec!["E01"]]),
5622            make_segment("UNT", vec![vec!["3", "001"]]),
5623            make_segment("UNZ", vec![vec!["1"]]),
5624        ];
5625        assert!(
5626            validate_unt_segment_count(&segments).is_none(),
5627            "Envelope segments excluded — count should be 3 (UNH+BGM+UNT)"
5628        );
5629    }
5630
5631    #[test]
5632    fn test_unt_count_no_unt_returns_none() {
5633        let segments = vec![
5634            make_segment("UNH", vec![vec!["001"]]),
5635            make_segment("BGM", vec![vec!["E01"]]),
5636        ];
5637        assert!(
5638            validate_unt_segment_count(&segments).is_none(),
5639            "No UNT segment should return None (not our problem)"
5640        );
5641    }
5642
5643    #[test]
5644    fn test_unt_count_rejects_multi_message_input() {
5645        // Two messages spliced together — should return an error
5646        let segments = vec![
5647            make_segment("UNH", vec![vec!["001"]]),
5648            make_segment("BGM", vec![vec!["E01"]]),
5649            make_segment("UNT", vec![vec!["3", "001"]]),
5650            make_segment("UNH", vec![vec!["002"]]),
5651            make_segment("BGM", vec![vec!["E02"]]),
5652            make_segment("UNT", vec![vec!["3", "002"]]),
5653        ];
5654        let issue = validate_unt_segment_count(&segments)
5655            .expect("Multi-message input should produce an error");
5656        assert_eq!(issue.code(), ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH);
5657        assert!(
5658            narrate(&issue).contains("2 UNH"),
5659            "Should mention UNH count: {}",
5660            narrate(&issue)
5661        );
5662    }
5663
5664    #[test]
5665    fn test_code_validation_accepts_multi_code_variant_qualifier() {
5666        // PID 55035 SG4/SG8 has many RFF variants — one per mig_number. The
5667        // variant with mig=00075 has TWO required codes (Z31, Z39): both are
5668        // valid qualifier values for that variant. Prior behavior only treated
5669        // buckets with exactly one required code as qualifier buckets, so
5670        // segments with Z31/Z39 fell through and were compared against the
5671        // union of *other* variants' codes — producing a false COD002.
5672        let evaluator = MockEvaluator::new(vec![]);
5673        let validator = EdifactValidator::new(evaluator);
5674        let external = NoOpExternalProvider;
5675
5676        let rff_z39 = OwnedSegment {
5677            id: "RFF".to_string(),
5678            elements: vec![
5679                vec!["RFF".to_string()],
5680                vec!["Z39".to_string(), "REF1".to_string()],
5681            ],
5682            segment_number: 1,
5683        };
5684
5685        let workflow = AhbWorkflow {
5686            pruefidentifikator: "55035".to_string(),
5687            description: "Test".to_string(),
5688            communication_direction: None,
5689            fields: vec![
5690                // Mig 00075 variant: qualifier is Z31 or Z39 (multi-code bucket)
5691                AhbFieldRule {
5692                    segment_path: "SG4/SG8/RFF/C506/1153".to_string(),
5693                    name: "Referenznummer Qualifier".to_string(),
5694                    ahb_status: "Muss".to_string(),
5695                    codes: vec![
5696                        AhbCodeRule {
5697                            value: "Z31".to_string(),
5698                            description: "".to_string(),
5699                            ahb_status: "X".to_string(),
5700                        },
5701                        AhbCodeRule {
5702                            value: "Z39".to_string(),
5703                            description: "".to_string(),
5704                            ahb_status: "X".to_string(),
5705                        },
5706                    ],
5707                    parent_group_ahb_status: None,
5708                    segment_ahb_status: None,
5709                    element_index: Some(1),
5710                    component_index: Some(0),
5711                    mig_number: Some("00075".to_string()),
5712                    format: None,
5713                },
5714                // Mig 00078 variant: single-code qualifier Z33
5715                AhbFieldRule {
5716                    segment_path: "SG4/SG8/RFF/C506/1153".to_string(),
5717                    name: "Referenznummer Qualifier".to_string(),
5718                    ahb_status: "Muss".to_string(),
5719                    codes: vec![AhbCodeRule {
5720                        value: "Z33".to_string(),
5721                        description: "".to_string(),
5722                        ahb_status: "X".to_string(),
5723                    }],
5724                    parent_group_ahb_status: None,
5725                    segment_ahb_status: None,
5726                    element_index: Some(1),
5727                    component_index: Some(0),
5728                    mig_number: Some("00078".to_string()),
5729                    format: None,
5730                },
5731            ],
5732            ub_definitions: BTreeMap::new(),
5733            package_conditions: Default::default(),
5734        };
5735
5736        let report = validator.validate(
5737            &[rff_z39],
5738            &workflow,
5739            &external,
5740            ValidationLevel::Conditions,
5741        );
5742
5743        let code_errors: Vec<_> = report
5744            .by_category(ValidationCategory::Code)
5745            .filter(|i| {
5746                i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
5747            })
5748            .collect();
5749        assert!(
5750            code_errors.is_empty(),
5751            "RFF+Z39 must be accepted (Z39 is valid for mig=00075). Got: {:?}",
5752            code_errors
5753        );
5754    }
5755
5756    #[test]
5757    fn test_code_validation_disambiguates_migs_by_full_code_profile() {
5758        // PID 55035 SG4/SG8 PIA variants all share element 0 code '5' as
5759        // qualifier. They differ only at composite C212/7143 (one variant
5760        // allows Z12, another allows SRW). Matching on the first single-code
5761        // field alone picks an arbitrary bucket and then rejects the other
5762        // variant's composite value — a false COD002.
5763        let evaluator = MockEvaluator::new(vec![]);
5764        let validator = EdifactValidator::new(evaluator);
5765        let external = NoOpExternalProvider;
5766
5767        // Include BOTH variants so the current bug is triggered deterministically:
5768        // match_segment_to_mig picks one bucket by HashMap iteration order, so
5769        // exactly one of {Z12, SRW} is rejected under the current logic.
5770        let pia_5_z12 = OwnedSegment {
5771            id: "PIA".to_string(),
5772            elements: vec![vec!["5".to_string()], vec!["Z12".to_string()]],
5773            segment_number: 1,
5774        };
5775        let pia_5_srw = OwnedSegment {
5776            id: "PIA".to_string(),
5777            elements: vec![vec!["5".to_string()], vec!["SRW".to_string()]],
5778            segment_number: 2,
5779        };
5780
5781        let make_rules = |mig: &str, composite_code: &str| {
5782            vec![
5783                AhbFieldRule {
5784                    segment_path: "SG4/SG8/PIA/4347".to_string(),
5785                    name: "Produkt-ID-Funktion".to_string(),
5786                    ahb_status: "Muss".to_string(),
5787                    codes: vec![AhbCodeRule {
5788                        value: "5".to_string(),
5789                        description: "".to_string(),
5790                        ahb_status: "X".to_string(),
5791                    }],
5792                    parent_group_ahb_status: None,
5793                    segment_ahb_status: None,
5794                    element_index: Some(0),
5795                    component_index: Some(0),
5796                    mig_number: Some(mig.to_string()),
5797                    format: None,
5798                },
5799                AhbFieldRule {
5800                    segment_path: "SG4/SG8/PIA/C212/7143".to_string(),
5801                    name: "Artikel/Dienstleistung-Identifikator".to_string(),
5802                    ahb_status: "Muss".to_string(),
5803                    codes: vec![AhbCodeRule {
5804                        value: composite_code.to_string(),
5805                        description: "".to_string(),
5806                        ahb_status: "X".to_string(),
5807                    }],
5808                    parent_group_ahb_status: None,
5809                    segment_ahb_status: None,
5810                    element_index: Some(1),
5811                    component_index: Some(0),
5812                    mig_number: Some(mig.to_string()),
5813                    format: None,
5814                },
5815            ]
5816        };
5817
5818        let mut fields = make_rules("00108", "Z12");
5819        fields.extend(make_rules("00197", "SRW"));
5820
5821        let workflow = AhbWorkflow {
5822            pruefidentifikator: "55035".to_string(),
5823            description: "Test".to_string(),
5824            communication_direction: None,
5825            fields,
5826            ub_definitions: BTreeMap::new(),
5827            package_conditions: Default::default(),
5828        };
5829
5830        let report = validator.validate(
5831            &[pia_5_z12, pia_5_srw],
5832            &workflow,
5833            &external,
5834            ValidationLevel::Conditions,
5835        );
5836
5837        let code_errors: Vec<_> = report
5838            .by_category(ValidationCategory::Code)
5839            .filter(|i| {
5840                i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
5841            })
5842            .collect();
5843        assert!(
5844            code_errors.is_empty(),
5845            "Both PIA+5+Z12 (mig=00108) and PIA+5+SRW (mig=00197) must be accepted. Got: {:?}",
5846            code_errors
5847        );
5848    }
5849}