Skip to main content

automapper_validation/validator/
validate.rs

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