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