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