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