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