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