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