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