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