Skip to main content

automapper_validation/validator/
validate.rs

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