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