Skip to main content

automapper_validation/validator/
validate.rs

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