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