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.
1339fn governing_status<'a>(field: &'a AhbFieldRule, value: Option<&str>) -> &'a str {
1340    let derived_from_codes = field
1341        .codes
1342        .first()
1343        .is_some_and(|c| c.ahb_status == field.ahb_status);
1344    if !derived_from_codes {
1345        return &field.ahb_status;
1346    }
1347    value
1348        .and_then(|v| field.codes.iter().find(|c| c.value == v))
1349        .map_or(&field.ahb_status, |c| &c.ahb_status)
1350}
1351
1352/// Check if the parent group's conditional status evaluates to False or Unknown.
1353///
1354/// Returns `true` if the field should be skipped (parent group condition not met).
1355/// Only evaluates when `parent_group_ahb_status` contains condition brackets `[`.
1356/// Simple statuses like "Kann" or "Muss" pass through (return `false`).
1357fn should_skip_for_parent_group<E: ConditionEvaluator>(
1358    field: &AhbFieldRule,
1359    expr_eval: &ConditionExprEvaluator<E>,
1360    ctx: &EvaluationContext,
1361    ub_definitions: &BTreeMap<String, ConditionExpr>,
1362) -> bool {
1363    if let Some(ref group_status) = field.parent_group_ahb_status {
1364        if group_status.contains('[') {
1365            let result = expr_eval.evaluate_status_with_ub(group_status, ctx, ub_definitions);
1366            return matches!(result, ConditionResult::False | ConditionResult::Unknown);
1367        }
1368    }
1369    false
1370}
1371
1372/// Whether the field's parent group is required here: the status that applies
1373/// to it (the strictest of its lines that holds) is `Muss`/`X`.
1374///
1375/// `should_skip_for_parent_group` has already dropped fields whose group does
1376/// not apply at all; this tells a required group from a recommended one, which
1377/// for `Soll [47] ∧ … / Muss [46] ∧ …` depends on which line holds.
1378fn is_parent_group_required<E: ConditionEvaluator>(
1379    field: &AhbFieldRule,
1380    expr_eval: &ConditionExprEvaluator<E>,
1381    ctx: &EvaluationContext,
1382    ub_definitions: &BTreeMap<String, ConditionExpr>,
1383) -> bool {
1384    field
1385        .parent_group_ahb_status
1386        .as_deref()
1387        .is_some_and(|status| {
1388            let status = expr_eval.resolve_status(status, ctx, ub_definitions);
1389            status.result == ConditionResult::True && status.kind == Some(StatusKind::Muss)
1390        })
1391}
1392
1393/// Check if a field's required segment instance is present.
1394///
1395/// For fields with qualifier codes (e.g., `SG2/NAD/3035` with `[MR]`),
1396/// checks that a segment with one of those qualifier values exists.
1397/// Otherwise just checks that any segment with the tag is present.
1398///
1399/// This prevents false negatives where `NAD+MS` is present but the
1400/// validator incorrectly says NAD "exists" when `NAD+MR` is missing.
1401fn is_field_present(ctx: &EvaluationContext, field: &AhbFieldRule) -> bool {
1402    let segment_id = extract_segment_id(&field.segment_path);
1403
1404    // When a field has known codes, check for a segment containing one of those
1405    // specific values at the right element/component position. This applies to both
1406    // simple qualifier paths (NAD/3035) and composite paths (CCI/C240/7037) —
1407    // without this, a CCI segment from a different group variant (e.g., CCI+Z61)
1408    // would falsely satisfy the check for CCI with C240/7037 codes Z15/Z18.
1409    if !field.codes.is_empty() {
1410        if let (Some(el_idx), Some(comp_idx)) = (field.element_index, field.component_index) {
1411            let required_codes: Vec<&str> = field.codes.iter().map(|c| c.value.as_str()).collect();
1412            let matching = ctx.find_segments(&segment_id);
1413            return matching.iter().any(|seg| {
1414                seg.elements
1415                    .get(el_idx)
1416                    .and_then(|e| e.get(comp_idx))
1417                    .is_some_and(|v| required_codes.contains(&v.as_str()))
1418            });
1419        }
1420        // Fallback for fields with codes but no element/component indices:
1421        // use simple qualifier check if it's a qualifier-style path.
1422        if is_qualifier_field(&field.segment_path) {
1423            let required_codes: Vec<&str> = field.codes.iter().map(|c| c.value.as_str()).collect();
1424            let el_idx = field.element_index.unwrap_or(0);
1425            let comp_idx = field.component_index.unwrap_or(0);
1426            let matching = ctx.find_segments(&segment_id);
1427            return matching.iter().any(|seg| {
1428                seg.elements
1429                    .get(el_idx)
1430                    .and_then(|e| e.get(comp_idx))
1431                    .is_some_and(|v| required_codes.contains(&v.as_str()))
1432            });
1433        }
1434    }
1435
1436    ctx.has_segment(&segment_id)
1437}
1438
1439/// Check if the group variant for a field is absent from the message.
1440///
1441/// Uses tree-based logic:
1442/// 1. **Group entirely absent**: If the group path has 0 instances, the
1443///    whole group is absent and its children aren't required.
1444/// 2. **Optional group variant absent**: If the parent group is optional
1445///    ("Kann") and the specific qualifier variant isn't present, the variant
1446///    is absent. E.g., SG5 "Kann" with LOC+Z17 — if no SG5 instance has
1447///    LOC+Z17, all fields under that variant are skipped.
1448///
1449/// Returns `false` (not absent) when:
1450/// - The field has no group prefix (e.g., `NAD/3035`)
1451/// - No navigator is available (can't determine group presence)
1452/// - The parent group is mandatory and has instances
1453fn is_group_variant_absent(
1454    ctx: &EvaluationContext,
1455    field: &AhbFieldRule,
1456    is_group_mandatory: bool,
1457) -> bool {
1458    let group_path: Vec<&str> = field
1459        .segment_path
1460        .split('/')
1461        .take_while(|p| p.starts_with("SG"))
1462        .collect();
1463
1464    if group_path.is_empty() {
1465        return false;
1466    }
1467
1468    let nav = match ctx.navigator {
1469        Some(nav) => nav,
1470        None => return false,
1471    };
1472
1473    let instance_count = nav.group_instance_count(&group_path);
1474
1475    // Case 1: group entirely absent — only skip if the group is NOT mandatory.
1476    // When a mandatory group (parent_group_ahb_status contains "Muss" or "X")
1477    // has 0 instances, the group's absence is itself the error, so we should
1478    // NOT skip field validation — let the field-level check report the missing field.
1479    if instance_count == 0 {
1480        if !is_group_mandatory {
1481            return true;
1482        }
1483        // Mandatory group with 0 instances → don't suppress field errors
1484        return false;
1485    }
1486
1487    // Case 2: group has instances, but the specific qualifier variant may be absent.
1488    // Only applies when the parent group is not required here ("Kann", "Soll",
1489    // or "Kann [345]" whose condition holds — allowed, never demanded):
1490    // required groups ("Muss", "X") require all their qualifier variants.
1491    if field.parent_group_ahb_status.is_some() && !is_group_mandatory {
1492        // Check if the field's qualifier variant is present in any instance.
1493        if !field.codes.is_empty() && is_qualifier_field(&field.segment_path) {
1494            let segment_id = extract_segment_id(&field.segment_path);
1495            let required_codes: Vec<&str> = field.codes.iter().map(|c| c.value.as_str()).collect();
1496
1497            let any_instance_has_qualifier = (0..instance_count).any(|i| {
1498                nav.find_segments_in_group(&segment_id, &group_path, i)
1499                    .iter()
1500                    .any(|seg| {
1501                        seg.elements
1502                            .first()
1503                            .and_then(|e| e.first())
1504                            .is_some_and(|v| required_codes.contains(&v.as_str()))
1505                    })
1506            });
1507
1508            if !any_instance_has_qualifier {
1509                return true; // optional group variant absent
1510            }
1511        }
1512    }
1513
1514    // Case 3: non-entry segment absent from all group instances.
1515    // E.g., SG10 has QTY (entry) + optional STS segments. If no STS appears
1516    // in any SG10 instance but the entry segment (QTY) is present, fields
1517    // under STS are not required.
1518    //
1519    // We check `has_any_segment_in_group` to confirm the group instance is
1520    // genuinely populated (proving our target segment is a non-entry optional
1521    // one) vs a navigator that simply can't resolve segments.
1522    let segment_id = extract_segment_id(&field.segment_path);
1523    let segment_absent_from_all = (0..instance_count).all(|i| {
1524        nav.find_segments_in_group(&segment_id, &group_path, i)
1525            .is_empty()
1526    });
1527    if segment_absent_from_all {
1528        let group_has_other_segments =
1529            (0..instance_count).any(|i| nav.has_any_segment_in_group(&group_path, i));
1530        if group_has_other_segments {
1531            return true;
1532        }
1533    }
1534
1535    false
1536}
1537
1538/// Recursively collect all AhbNodes from a group node in depth-first order.
1539fn collect_nodes_depth_first<'a, 'b>(group: &'b AhbGroupNode<'a>, out: &mut Vec<&'b AhbNode<'a>>) {
1540    out.extend(group.fields.iter());
1541    for child in &group.children {
1542        collect_nodes_depth_first(child, out);
1543    }
1544}
1545
1546/// Evaluate a single `AhbNode` with the given (possibly scoped) context,
1547/// emitting AHB001/AHB003/AHB005 issues as appropriate. When `instance_index`
1548/// is `Some`, errors are tagged with it via `with_instance_index`.
1549#[allow(clippy::too_many_arguments)]
1550fn evaluate_node<E: ConditionEvaluator>(
1551    node: &AhbNode,
1552    ctx: &EvaluationContext,
1553    expr_eval: &ConditionExprEvaluator<E>,
1554    evaluator: &E,
1555    ub_definitions: &BTreeMap<String, ConditionExpr>,
1556    tag_migs: &HashMap<String, HashSet<&str>>,
1557    instance_index: Option<usize>,
1558    slot_holds: bool,
1559    report: &mut ValidationReport,
1560) {
1561    let field = node.rule;
1562
1563    // Create a field-scoped context with resolved value from the tree node.
1564    let node_ctx = ctx.with_resolved(node.value, node.segment_elements);
1565
1566    // A present value no longer than its MIG format allows (`an..512`: FTX
1567    // C108/4440). Checked whatever the condition says — the format binds
1568    // wherever the field is sent,
1569    // so before the group condition decides whether the rest applies.
1570    if let (Some(value), Some(max)) = (node.value, field.format.as_deref().and_then(max_length)) {
1571        let length = value_length(field.format.as_deref().unwrap_or_default(), value);
1572        if length > max {
1573            let mut issue = ValidationIssue::new(
1574                Severity::Error,
1575                IssueKind::ValueTooLong {
1576                    field_name: field.name.clone(),
1577                    length,
1578                    max,
1579                },
1580            )
1581            .with_field_path(&field.segment_path)
1582            .with_actual(value)
1583            .with_expected(field.format.clone().unwrap_or_default());
1584            if let Some(idx) = instance_index {
1585                issue = issue.with_instance_index(idx);
1586            }
1587            if let Some(num) = node.matched_segment_number {
1588                issue = issue.with_position(crate::SegmentPosition {
1589                    segment_number: num,
1590                    byte_offset: 0,
1591                    message_number: 1,
1592                });
1593            }
1594            report.add_issue(attach_field_position(issue, field));
1595        }
1596    }
1597
1598    // A value of a numeric format that is no number (`n..10`: RFF+Z60
1599    // `O5D2DXBG1A`). A code element is judged by its code list.
1600    if let (Some(value), Some(format)) = (node.value, field.format.as_deref()) {
1601        if slot_holds
1602            && format.trim().starts_with('n')
1603            && field.codes.is_empty()
1604            && !value.is_empty()
1605            && !crate::eval::format_validators::is_edifact_number(value)
1606        {
1607            let mut issue = ValidationIssue::new(
1608                Severity::Error,
1609                IssueKind::ValueNotNumeric {
1610                    field_name: field.name.clone(),
1611                },
1612            )
1613            .with_field_path(&field.segment_path)
1614            .with_actual(value)
1615            .with_expected(format);
1616            if let Some(idx) = instance_index {
1617                issue = issue.with_instance_index(idx);
1618            }
1619            if let Some(num) = node.matched_segment_number {
1620                issue = issue.with_position(crate::SegmentPosition {
1621                    segment_number: num,
1622                    byte_offset: 0,
1623                    message_number: 1,
1624                });
1625            }
1626            report.add_issue(attach_field_position(issue, field));
1627        }
1628    }
1629
1630    // Skip if parent group condition is not met (False or Unknown).
1631    if should_skip_for_parent_group(field, expr_eval, ctx, ub_definitions) {
1632        return;
1633    }
1634
1635    // Evaluate the field's AHB status condition using the node context
1636    // (WITH resolved_value set). A present code is judged by its own status.
1637    let status = governing_status(field, node.value);
1638    let StatusResolution {
1639        result: condition_result,
1640        kind,
1641        unknown_ids,
1642    } = expr_eval.resolve_status(status, &node_ctx, ub_definitions);
1643    let mandatory = kind == Some(StatusKind::Muss);
1644
1645    match condition_result {
1646        ConditionResult::True => {
1647            // Condition is met -- field is required/applicable.
1648            //
1649            // Normally rely on the tree's resolved value. The exception:
1650            // if assembly didn't claim the segment at all (value AND
1651            // segment_elements both absent) AND the tag has exactly one
1652            // mig variant in this PID, a flat-segment hit means the
1653            // segment IS present and assembly just failed to pick it up.
1654            // Don't double-report — structure diagnostics already warn.
1655            if mandatory && node.value.is_none() {
1656                let tag = extract_segment_id(&field.segment_path);
1657                let single_variant_present = node.segment_elements.is_none()
1658                    && tag_migs.get(&tag).is_some_and(|ms| ms.len() == 1)
1659                    && is_field_present(ctx, field);
1660                // Suppress missing-field errors when the containing segment
1661                // is optional (Kann/Soll) and absent from the instance.
1662                // "X" on a Kann segment's sub-element means "required IF
1663                // segment present"; the segment's absence is legal.
1664                let segment_optional_and_absent = node.segment_elements.is_none()
1665                    && field
1666                        .segment_ahb_status
1667                        .as_deref()
1668                        .is_some_and(is_optional_segment_status);
1669                // Likewise when the absent segment is required only under a
1670                // condition that does not hold here: a position without an
1671                // Eichgültigkeit (QUOTES 15001 `DTM+Z04`, `Muss [5] ⊻ [3] ⊻
1672                // [22]`), a position that is not the SMGW (ORDERS 17001
1673                // `FTX+Z30`, `Muss [185] ∧ [593]`).
1674                let segment_not_required_and_absent = node.segment_elements.is_none()
1675                    && field.segment_ahb_status.as_deref().is_some_and(|st| {
1676                        expr_eval
1677                            .resolve_status(st, &node_ctx, ub_definitions)
1678                            .result
1679                            != ConditionResult::True
1680                    });
1681                if !single_variant_present
1682                    && !segment_optional_and_absent
1683                    && !segment_not_required_and_absent
1684                {
1685                    let mut issue = ValidationIssue::new(
1686                        Severity::Error,
1687                        IssueKind::MissingRequiredField {
1688                            field_name: field.name.clone(),
1689                        },
1690                    )
1691                    .with_field_path(&field.segment_path)
1692                    .with_rule(&field.ahb_status);
1693                    if let Some(first_code) = field.codes.first() {
1694                        issue.expected_value = Some(first_code.value.clone());
1695                    }
1696                    if let Some(idx) = instance_index {
1697                        issue = issue.with_instance_index(idx);
1698                    }
1699                    if let Some(num) = node.matched_segment_number {
1700                        issue = issue.with_position(crate::SegmentPosition {
1701                            segment_number: num,
1702                            byte_offset: 0,
1703                            message_number: 1,
1704                        });
1705                    }
1706                    issue = attach_field_position(issue, field);
1707                    report.add_issue(issue);
1708                }
1709            }
1710        }
1711        ConditionResult::False => {
1712            // Condition not met. If mandatory AND value is present,
1713            // the value violates the condition constraint.
1714            if mandatory && node.value.is_some() {
1715                let mut issue = ValidationIssue::new(
1716                    Severity::Error,
1717                    IssueKind::FieldConditionNotSatisfied {
1718                        field_name: field.name.clone(),
1719                    },
1720                )
1721                .with_field_path(&field.segment_path)
1722                .with_rule(&field.ahb_status);
1723                if let Some(idx) = instance_index {
1724                    issue = issue.with_instance_index(idx);
1725                }
1726                if let Some(num) = node.matched_segment_number {
1727                    issue = issue.with_position(crate::SegmentPosition {
1728                        segment_number: num,
1729                        byte_offset: 0,
1730                        message_number: 1,
1731                    });
1732                }
1733                issue = attach_field_position(issue, field);
1734                report.add_issue(issue);
1735            }
1736        }
1737        ConditionResult::Unknown => {
1738            // Partition unknown IDs into external / undetermined / missing.
1739            let mut external_ids = Vec::new();
1740            let mut undetermined_ids = Vec::new();
1741            let mut missing_ids = Vec::new();
1742            for id in unknown_ids {
1743                if evaluator.is_external(id) {
1744                    external_ids.push(id);
1745                } else if evaluator.is_known(id) {
1746                    undetermined_ids.push(id);
1747                } else {
1748                    missing_ids.push(id);
1749                }
1750            }
1751
1752            let mut issue = ValidationIssue::new(
1753                Severity::Info,
1754                IssueKind::ConditionUnknown {
1755                    field_name: field.name.clone(),
1756                    unresolved: UnresolvedConditions {
1757                        external: external_ids,
1758                        undetermined: undetermined_ids,
1759                        missing: missing_ids,
1760                    },
1761                },
1762            )
1763            .with_field_path(&field.segment_path)
1764            .with_rule(&field.ahb_status);
1765            if let Some(idx) = instance_index {
1766                issue = issue.with_instance_index(idx);
1767            }
1768            if let Some(num) = node.matched_segment_number {
1769                issue = issue.with_position(crate::SegmentPosition {
1770                    segment_number: num,
1771                    byte_offset: 0,
1772                    message_number: 1,
1773                });
1774            }
1775            issue = attach_field_position(issue, field);
1776            report.add_issue(issue);
1777        }
1778    }
1779}
1780
1781/// Attach UCD `S011` position info derived from the AHB field rule's
1782/// `element_index` / `component_index`. Both are 0-based array indices
1783/// in the parsed segment; CONTRL DE0098 / DE0104 are 1-based and
1784/// DE0098 counts the segment identifier as position 1.
1785fn attach_field_position(issue: ValidationIssue, field: &AhbFieldRule) -> ValidationIssue {
1786    match field.element_index {
1787        Some(el) => {
1788            let element_pos = (el as u32) + 2;
1789            let component_pos = field.component_index.map(|c| (c as u32) + 1);
1790            issue.with_field_position(element_pos, component_pos)
1791        }
1792        None => issue,
1793    }
1794}
1795
1796/// The groups and segments of one transaction that its AHB allows once per
1797/// transaction (`[2061]`, see [`crate::eval::binds_once`]) but that it sends
1798/// more often: two SG8 SEQ+Z98 "Informative Daten der Marktlokation", two
1799/// STS+E01. Group variants are told apart by their entry segment's MIG number,
1800/// segments by the qualifier their slot's rule names; a segment whose status is
1801/// its group's is counted with the group.
1802fn report_repeated_in_transaction(
1803    transaction: &AhbGroupNode,
1804    transaction_index: usize,
1805    once: u32,
1806    ctx: &EvaluationContext,
1807    report: &mut ValidationReport,
1808) {
1809    // (path, variant) -> (count, status)
1810    let mut counts: BTreeMap<(String, String), (usize, String)> = BTreeMap::new();
1811    fn visit<'a>(
1812        node: &'a AhbGroupNode<'a>,
1813        path: &mut Vec<(&'a str, usize)>,
1814        once: u32,
1815        ctx: &EvaluationContext,
1816        counts: &mut BTreeMap<(String, String), (usize, String)>,
1817    ) {
1818        let ids: Vec<&str> = path.iter().map(|(g, _)| *g).collect();
1819        let mut seen: HashSet<Option<&str>> = HashSet::new();
1820        for n in &node.fields {
1821            let rule = n.rule;
1822            let Some(status) = rule.segment_ahb_status.as_deref() else {
1823                continue;
1824            };
1825            if Some(status) == node.ahb_status
1826                || rule.element_index.unwrap_or(0) != 0
1827                || rule.component_index.unwrap_or(0) != 0
1828                || rule.codes.is_empty()
1829                || !crate::eval::binds_once(status, once)
1830                || !seen.insert(rule.mig_number.as_deref())
1831            {
1832                continue;
1833            }
1834            let tag = extract_segment_id(&rule.segment_path);
1835            let sent = ctx.navigator().map_or(0, |nav| {
1836                nav.find_segments_in_instance(&tag, path)
1837                    .iter()
1838                    .filter(|s| rule.codes.iter().any(|c| c.value == s.get_component(0, 0)))
1839                    .count()
1840            });
1841            let entry = counts
1842                .entry((
1843                    format!("{}/{tag}", ids.join("/")),
1844                    rule.mig_number.clone().unwrap_or_default(),
1845                ))
1846                .or_insert((0, status.to_string()));
1847            entry.0 += sent;
1848        }
1849        for child in &node.children {
1850            if let Some(status) = child
1851                .ahb_status
1852                .filter(|s| crate::eval::binds_once(s, once))
1853            {
1854                let variant = child
1855                    .fields
1856                    .first()
1857                    .and_then(|n| n.rule.mig_number.clone())
1858                    .unwrap_or_default();
1859                let entry = counts
1860                    .entry((format!("{}/{}", ids.join("/"), child.group_id), variant))
1861                    .or_insert((0, status.to_string()));
1862                entry.0 += 1;
1863            }
1864            path.push((child.group_id, child.instance_index));
1865            visit(child, path, once, ctx, counts);
1866            path.pop();
1867        }
1868    }
1869    let mut path = vec![(transaction.group_id, transaction_index)];
1870    visit(transaction, &mut path, once, ctx, &mut counts);
1871    for ((path, _), (count, status)) in counts {
1872        if count > 1 {
1873            report.add_issue(
1874                ValidationIssue::new(
1875                    Severity::Error,
1876                    IssueKind::RepeatedInTransaction {
1877                        path: path.clone(),
1878                        count,
1879                    },
1880                )
1881                .with_field_path(path)
1882                .with_rule(status)
1883                .with_instance_index(transaction_index),
1884            );
1885        }
1886    }
1887}
1888
1889/// The segments of `group` sent although their own condition does not hold
1890/// (#258): `CAV+Z56` (Wärmenutzung, `Muss [239]`) without CAV+Z65 in its SG10.
1891/// A segment counts when the qualifier rule of its slot finds its code there
1892/// — that identifies the slot among same-tag segments — and is reported once. A
1893/// segment whose status is the group's own is the group check's business.
1894fn refuse_segments<E: ConditionEvaluator>(
1895    group: &AhbGroupNode,
1896    path: &[(&str, usize)],
1897    scoped_ctx: &EvaluationContext,
1898    evaluator: &E,
1899    ub_definitions: &BTreeMap<String, ConditionExpr>,
1900    instance_index: usize,
1901    report: &mut ValidationReport,
1902) {
1903    let mut seen: Vec<u32> = Vec::new();
1904    for node in &group.fields {
1905        let rule = node.rule;
1906        let (Some(status), Some(value), Some(number)) = (
1907            rule.segment_ahb_status.as_deref(),
1908            node.value,
1909            node.matched_segment_number,
1910        ) else {
1911            continue;
1912        };
1913        // Only the qualifier (element 0, component 0) names the slot: a
1914        // format code such as DTM's `303` is shared by DTM+92 and DTM+93.
1915        let qualifier =
1916            rule.element_index.unwrap_or(0) == 0 && rule.component_index.unwrap_or(0) == 0;
1917        if !qualifier
1918            || seen.contains(&number)
1919            || Some(status) == group.ahb_status
1920            || !rule.codes.iter().any(|c| c.value == value)
1921        {
1922            continue;
1923        }
1924        seen.push(number);
1925        if !crate::eval::refuses_presence(status, evaluator, scoped_ctx, ub_definitions) {
1926            continue;
1927        }
1928        let mut segment: Vec<&str> = path.iter().map(|(g, _)| *g).collect();
1929        segment.extend(rule.segment_path.split('/').find(|p| !p.starts_with("SG")));
1930        report.add_issue(
1931            ValidationIssue::new(
1932                Severity::Warning,
1933                IssueKind::SegmentNotAllowed {
1934                    segment: segment.join("/"),
1935                },
1936            )
1937            .with_field_path(segment.join("/"))
1938            .with_rule(status)
1939            .with_instance_index(instance_index)
1940            .with_position(crate::SegmentPosition {
1941                segment_number: number,
1942                byte_offset: 0,
1943                message_number: 1,
1944            }),
1945        );
1946    }
1947}
1948
1949/// Recursively walk a group instance (and its children) evaluating every
1950/// `AhbNode` in the scope of the instance it belongs to.
1951///
1952/// `path` addresses `group` by its repetition at every level from the root
1953/// (`[("SG4", 1), ("SG8", 2)]`); each child instance is walked with the path
1954/// one level deeper, so "in dieser SG8" means this SG8, not every SG8 of the
1955/// first SG4. `instance_index` stays the top-level repetition, which is what
1956/// issues report.
1957#[allow(clippy::too_many_arguments)]
1958fn walk_group_instance<E: ConditionEvaluator>(
1959    group: &AhbGroupNode,
1960    path: &[(&str, usize)],
1961    scoped_ctx: &EvaluationContext,
1962    expr_eval: &ConditionExprEvaluator<E>,
1963    evaluator: &E,
1964    ub_definitions: &BTreeMap<String, ConditionExpr>,
1965    tag_migs: &HashMap<String, HashSet<&str>>,
1966    instance_index: usize,
1967    report_segments: bool,
1968    report: &mut ValidationReport,
1969) {
1970    // A group sent although its condition does not hold (a warning while the
1971    // check proves itself). Its fields are then skipped as before.
1972    let mut group_refused = false;
1973    if let Some(status) = group.ahb_status {
1974        if crate::eval::refuses_presence(status, evaluator, scoped_ctx, ub_definitions) {
1975            group_refused = true;
1976            let group_path: Vec<&str> = path.iter().map(|(g, _)| *g).collect();
1977            let mut issue = ValidationIssue::new(
1978                Severity::Warning,
1979                IssueKind::GroupNotAllowed {
1980                    group: group_path.join("/"),
1981                },
1982            )
1983            .with_field_path(group_path.join("/"))
1984            .with_rule(status)
1985            .with_instance_index(instance_index);
1986            if let Some(num) = group.fields.iter().find_map(|n| n.matched_segment_number) {
1987                issue = issue.with_position(crate::SegmentPosition {
1988                    segment_number: num,
1989                    byte_offset: 0,
1990                    message_number: 1,
1991                });
1992            }
1993            report.add_issue(issue);
1994        }
1995    }
1996    if report_segments && !group_refused {
1997        refuse_segments(
1998            group,
1999            path,
2000            scoped_ctx,
2001            evaluator,
2002            ub_definitions,
2003            instance_index,
2004            report,
2005        );
2006    }
2007    // Nodes bound to a segment their slot's qualifier rule does not name: a
2008    // repeated CAV+Z30 taken for the CAV+MIW after it (55043). What that slot
2009    // says about its values does not apply to them.
2010    let slot_codes: HashMap<Option<&str>, &[AhbCodeRule]> = group
2011        .fields
2012        .iter()
2013        .filter(|n| {
2014            n.rule.element_index.unwrap_or(0) == 0
2015                && n.rule.component_index.unwrap_or(0) == 0
2016                && !n.rule.codes.is_empty()
2017        })
2018        .map(|n| (n.rule.mig_number.as_deref(), n.rule.codes.as_slice()))
2019        .collect();
2020    let holds = |node: &AhbNode| {
2021        let (Some(codes), Some(elements)) = (
2022            slot_codes.get(&node.rule.mig_number.as_deref()),
2023            node.segment_elements,
2024        ) else {
2025            return true;
2026        };
2027        let qualifier = elements
2028            .first()
2029            .and_then(|e| e.first())
2030            .map(String::as_str)
2031            .unwrap_or_default();
2032        codes.iter().any(|c| c.value == qualifier)
2033    };
2034    for node in &group.fields {
2035        evaluate_node(
2036            node,
2037            scoped_ctx,
2038            expr_eval,
2039            evaluator,
2040            ub_definitions,
2041            tag_migs,
2042            Some(instance_index),
2043            holds(node),
2044            report,
2045        );
2046    }
2047    for child in &group.children {
2048        let mut child_path = path.to_vec();
2049        child_path.push((child.group_id, child.instance_index));
2050        let child_ctx = scoped_ctx.with_scope(GroupScope { path: &child_path });
2051        walk_group_instance(
2052            child,
2053            &child_path,
2054            &child_ctx,
2055            expr_eval,
2056            evaluator,
2057            ub_definitions,
2058            tag_migs,
2059            instance_index,
2060            report_segments,
2061            report,
2062        );
2063    }
2064}
2065
2066/// Recursively collect all Package nodes from a condition expression tree.
2067fn collect_packages(expr: &ConditionExpr, out: &mut Vec<(u32, u32, u32)>) {
2068    match expr {
2069        ConditionExpr::Package { id, min, max } => {
2070            out.push((*id, *min, *max));
2071        }
2072        ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
2073            for e in exprs {
2074                collect_packages(e, out);
2075            }
2076        }
2077        ConditionExpr::Xor(left, right) => {
2078            collect_packages(left, out);
2079            collect_packages(right, out);
2080        }
2081        ConditionExpr::Not(inner) => {
2082            collect_packages(inner, out);
2083        }
2084        ConditionExpr::Ref(_) => {}
2085    }
2086}
2087
2088/// Check if a segment-level AHB status indicates an optional segment.
2089///
2090/// "Kann" / "Kann [x]" — always optional: absence is legal.
2091/// "Soll" / "Soll [x]" — should-be: absence is tolerated (not an error).
2092/// "Muss" (with or without conditions) — required; absence must be reported.
2093///
2094/// Used to suppress AHB001 missing-field errors on sub-elements of a segment
2095/// that is both optional at segment-level and absent from the assembled
2096/// instance: "X" on such a sub-element means "required IF segment present".
2097fn is_optional_segment_status(status: &str) -> bool {
2098    let trimmed = status.trim();
2099    trimmed.starts_with("Kann") || trimmed.starts_with("Soll")
2100}
2101
2102/// Check if a field path points to a simple qualifier element (element[0] of the segment).
2103///
2104/// Returns `true` for paths like `[SG/]*/SEG/ELEMENT` where the data element is
2105/// directly under the segment (no composite wrapper). These fields have their code
2106/// in `element[0][0]` and can be validated.
2107///
2108/// Also accepts composite paths like `SEG/COMPOSITE/ELEMENT` (e.g., `STS/C556/9013`).
2109/// The `element_index` and `component_index` on the field rule carry the exact
2110/// position, so composites can be checked just like simple elements.
2111fn is_qualifier_field(path: &str) -> bool {
2112    let parts: Vec<&str> = path.split('/').filter(|p| !p.starts_with("SG")).collect();
2113    // [SEG, ELEMENT] for simple data elements; [SEG, COMPOSITE, ELEMENT] for composites.
2114    matches!(parts.len(), 2 | 3)
2115}
2116
2117/// Per-mig code constraints at one (group_key, tag) location.
2118///
2119/// A bucket represents one mig_number (or the `None` bucket when rules lack
2120/// mig numbers). Each bucket tracks its qualifier position and the set of
2121/// valid qualifier values there, so multiple single-code rules under the same
2122/// mig key (e.g. two distinct NAD qualifiers both under mig=None in a test
2123/// fixture) all contribute as valid qualifier matches.
2124#[derive(Default)]
2125struct MigCodeBucket<'a> {
2126    /// The position (element_index, component_index) treated as the discriminator
2127    /// for this bucket — the first single-code required field seen in the workflow.
2128    qualifier_position: Option<(usize, usize)>,
2129    /// Valid qualifier values at `qualifier_position`. Empty if no single-code
2130    /// required field exists for this bucket.
2131    qualifier_values: HashSet<&'a str>,
2132    /// Allowed codes for this bucket, keyed by `(element_index, component_index)`.
2133    /// Includes the qualifier position itself.
2134    codes: HashMap<(usize, usize), HashSet<&'a str>>,
2135}
2136
2137/// Partition workflow code rules by `(group_key, tag, mig_number)`. Without this
2138/// split, codes from multiple migs sharing a tag (e.g. STS+7 mig 00035 and
2139/// STS+E01 mig 00036 in PID 55018) get unioned and every segment is checked
2140/// against codes that only apply to one mig.
2141fn partition_codes_by_mig(
2142    workflow: &AhbWorkflow,
2143) -> HashMap<(String, String), HashMap<Option<String>, MigCodeBucket<'_>>> {
2144    let mut out: HashMap<(String, String), HashMap<Option<String>, MigCodeBucket>> = HashMap::new();
2145    for field in &workflow.fields {
2146        if field.codes.is_empty() || !is_qualifier_field(&field.segment_path) {
2147            continue;
2148        }
2149        let tag = extract_segment_id(&field.segment_path);
2150        let group_key = extract_group_path_key(&field.segment_path);
2151        let mig = field.mig_number.clone();
2152        let el = field.element_index.unwrap_or(0);
2153        let c = field.component_index.unwrap_or(0);
2154
2155        let bucket = out
2156            .entry((group_key, tag))
2157            .or_default()
2158            .entry(mig)
2159            .or_default();
2160
2161        // A code is "allowed for this PID" whenever its AHB status declares it
2162        // usable — whether unconditionally (`X`, `Muss`) or under a condition
2163        // (`X [35]`, `X [35] ∨ ([32] ∧ [77])`). Matching only the exact string
2164        // `"X"` drops every conditionally-allowed code, causing COD002 false
2165        // positives on fields like MSCONS QTY 6063 (220 unconditional, 67/Z18
2166        // conditional).
2167        let required: Vec<&str> = field
2168            .codes
2169            .iter()
2170            .filter(|code| code.ahb_status.starts_with('X') || code.ahb_status.starts_with("Muss"))
2171            .map(|code| code.value.as_str())
2172            .collect();
2173
2174        // The first code-bearing field locks in the qualifier position; any
2175        // further required codes at that position contribute additional valid
2176        // qualifier values. A bucket whose entry qualifier permits multiple
2177        // codes (e.g. PID 55035 SG8/RFF mig=00075 allows {Z31, Z39}) must be
2178        // matchable by any of them — without this, segments with Z39 fall
2179        // through to the union-of-other-migs path and produce a false COD002.
2180        if !required.is_empty() {
2181            if bucket.qualifier_position.is_none() {
2182                bucket.qualifier_position = Some((el, c));
2183            }
2184            if bucket.qualifier_position == Some((el, c)) {
2185                bucket.qualifier_values.extend(required.iter().copied());
2186            }
2187        }
2188
2189        for v in required {
2190            bucket.codes.entry((el, c)).or_default().insert(v);
2191        }
2192    }
2193    out
2194}
2195
2196/// For each tag, collect every qualifier value used anywhere in the workflow,
2197/// keyed by qualifier position. This lets code validation distinguish "segment
2198/// has an unknown qualifier value" (truly invalid — report) from "segment has
2199/// a valid qualifier used at a different location than the current bucket"
2200/// (belongs elsewhere — skip at this location).
2201fn global_qualifiers_by_tag(
2202    by_loc: &HashMap<(String, String), HashMap<Option<String>, MigCodeBucket<'_>>>,
2203) -> HashMap<String, HashMap<(usize, usize), HashSet<String>>> {
2204    let mut out: HashMap<String, HashMap<(usize, usize), HashSet<String>>> = HashMap::new();
2205    for ((_group, tag), migs) in by_loc {
2206        let tag_entry = out.entry(tag.clone()).or_default();
2207        for bucket in migs.values() {
2208            if let Some(pos) = bucket.qualifier_position {
2209                tag_entry
2210                    .entry(pos)
2211                    .or_default()
2212                    .extend(bucket.qualifier_values.iter().map(|s| s.to_string()));
2213            }
2214        }
2215    }
2216    out
2217}
2218
2219/// Find the bucket that best matches this segment.
2220///
2221/// A bucket is a candidate if its `qualifier_values` contain the segment's
2222/// value at `qualifier_position`. Among candidates, the one with the most
2223/// additional code positions matching the segment wins. This disambiguates
2224/// migs that share the same primary qualifier but differ on a secondary
2225/// code position (e.g. PID 55035 SG8/PIA variants all use 4347='5' but
2226/// differ on C212/7143={Z12} vs {SRW}).
2227///
2228/// Returns `None` if no bucket's qualifier matches the segment.
2229fn match_segment_to_mig<'a, 'b>(
2230    seg: &OwnedSegment,
2231    migs: &'a HashMap<Option<String>, MigCodeBucket<'b>>,
2232) -> Option<&'a MigCodeBucket<'b>> {
2233    let actual_at = |el: usize, c: usize| -> &str {
2234        seg.elements
2235            .get(el)
2236            .and_then(|e| e.get(c))
2237            .map(|s| s.as_str())
2238            .unwrap_or("")
2239    };
2240
2241    let mut best: Option<&MigCodeBucket> = None;
2242    let mut best_matches = 0usize;
2243
2244    for bucket in migs.values() {
2245        let Some((el, c)) = bucket.qualifier_position else {
2246            continue;
2247        };
2248        if !bucket.qualifier_values.contains(actual_at(el, c)) {
2249            continue;
2250        }
2251        let extra_matches = bucket
2252            .codes
2253            .iter()
2254            .filter(|(pos, _)| **pos != (el, c))
2255            .filter(|((e, k), allowed)| {
2256                let v = actual_at(*e, *k);
2257                !v.is_empty() && allowed.contains(v)
2258            })
2259            .count();
2260        if best.is_none() || extra_matches > best_matches {
2261            best = Some(bucket);
2262            best_matches = extra_matches;
2263        }
2264    }
2265    best
2266}
2267
2268/// Extract the group path prefix from a field path.
2269///
2270/// `"SG2/NAD/3035"` → `"SG2"`, `"SG4/SG12/NAD/3035"` → `"SG4/SG12"`,
2271/// `"NAD/3035"` → `""` (no group prefix).
2272fn extract_group_path_key(path: &str) -> String {
2273    let sg_parts: Vec<&str> = path
2274        .split('/')
2275        .take_while(|p| p.starts_with("SG"))
2276        .collect();
2277    sg_parts.join("/")
2278}
2279
2280/// Extract the segment ID from a field path like "SG2/NAD/C082/3039" -> "NAD".
2281fn extract_segment_id(path: &str) -> String {
2282    for part in path.split('/') {
2283        // Skip segment group identifiers and composite/element identifiers
2284        if part.starts_with("SG") || part.starts_with("C_") || part.starts_with("D_") {
2285            continue;
2286        }
2287        // Return first 3-letter uppercase segment identifier
2288        if part.len() >= 3
2289            && part
2290                .chars()
2291                .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
2292        {
2293            return part.to_string();
2294        }
2295    }
2296    // Fallback: return the last part
2297    path.split('/').next_back().unwrap_or(path).to_string()
2298}
2299
2300/// Validate that the UNT segment count matches the actual number of segments.
2301///
2302/// The UNT segment's first data element (D0074) declares the number of segments
2303/// in the message, counting from UNH to UNT inclusive. This function compares
2304/// that declared value against the actual segment count.
2305///
2306/// Pass per-message segments (from [`MessageChunk::message_segments()`] or
2307/// [`MessageChunk::all_segments()`]), not the full interchange. For multi-message
2308/// interchanges, call once per message.
2309///
2310/// Returns `Some(ValidationIssue)` if there's a mismatch, `None` if correct.
2311pub fn validate_unt_segment_count(segments: &[OwnedSegment]) -> Option<ValidationIssue> {
2312    // Reject multi-message input — counting would be incorrect.
2313    let unh_count = segments.iter().filter(|s| s.id == "UNH").count();
2314    if unh_count > 1 {
2315        return Some(ValidationIssue::new(
2316            Severity::Error,
2317            IssueKind::UntCountNotVerifiable { unh_count },
2318        ));
2319    }
2320
2321    // Find the UNT segment
2322    let unt = segments.iter().rfind(|s| s.id == "UNT")?;
2323    let declared: usize = unt.get_element(0).parse().ok()?;
2324
2325    // Count segments from UNH to UNT inclusive
2326    let actual = segments
2327        .iter()
2328        .filter(|s| s.id != "UNA" && s.id != "UNB" && s.id != "UNZ")
2329        .count();
2330
2331    if declared != actual {
2332        Some(
2333            ValidationIssue::new(
2334                Severity::Error,
2335                IssueKind::UntSegmentCountMismatch { declared, actual },
2336            )
2337            .with_field_path("UNT/0074")
2338            .with_expected(actual.to_string())
2339            .with_actual(declared.to_string()),
2340        )
2341    } else {
2342        None
2343    }
2344}
2345
2346#[cfg(test)]
2347mod tests {
2348
2349    #[test]
2350    fn a_numeric_length_counts_digits_only() {
2351        assert_eq!(value_length("n..15", "-12.50"), 4);
2352        assert_eq!(value_length("n..6", "JMMHAYJXGIJT"), 12);
2353        assert_eq!(value_length("n5", "55001"), 5);
2354        assert_eq!(value_length("an..3", "Z.1"), 3);
2355        assert_eq!(value_length("an..35", "Müller"), 6);
2356    }
2357    use super::super::codes::ErrorCodes;
2358    use super::super::issue::ValidationCategory;
2359    use super::*;
2360    use crate::display::{EdifactView, IssueNarrator, IssueView, TechnicalNarrator};
2361    use crate::eval::{ConditionResult as CR, NoOpExternalProvider};
2362    use std::collections::HashMap;
2363
2364    /// Narrate an issue the way the EDIFACT view would, for assertions that
2365    /// used to read `.message` before Task 5 removed it.
2366    fn narrate(issue: &ValidationIssue) -> String {
2367        TechnicalNarrator.describe(issue, EdifactView.location(issue).as_deref())
2368    }
2369
2370    /// Mock evaluator for testing the validator.
2371    struct MockEvaluator {
2372        results: HashMap<u32, CR>,
2373    }
2374
2375    impl MockEvaluator {
2376        fn new(results: Vec<(u32, CR)>) -> Self {
2377            Self {
2378                results: results.into_iter().collect(),
2379            }
2380        }
2381
2382        fn all_true(ids: &[u32]) -> Self {
2383            Self::new(ids.iter().map(|&id| (id, CR::True)).collect())
2384        }
2385    }
2386
2387    impl ConditionEvaluator for MockEvaluator {
2388        fn evaluate(&self, condition: u32, _ctx: &EvaluationContext) -> CR {
2389            self.results.get(&condition).copied().unwrap_or(CR::Unknown)
2390        }
2391        fn is_external(&self, _condition: u32) -> bool {
2392            false
2393        }
2394        fn message_type(&self) -> &str {
2395            "UTILMD"
2396        }
2397        fn format_version(&self) -> &str {
2398            "FV2510"
2399        }
2400    }
2401
2402    // === Helper function tests ===
2403
2404    #[test]
2405    fn a_present_code_is_judged_by_its_own_status() {
2406        // 55010 STS Transaktionsgrund: the schema gives the element E01's status.
2407        let code = |value: &str, status: &str| AhbCodeRule {
2408            value: value.to_string(),
2409            description: String::new(),
2410            ahb_status: status.to_string(),
2411        };
2412        let field = AhbFieldRule {
2413            segment_path: "SG4/STS/C556/9013".to_string(),
2414            ahb_status: "X [192]".to_string(),
2415            codes: vec![code("E01", "X [192]"), code("E03", "X")],
2416            ..Default::default()
2417        };
2418        assert_eq!(governing_status(&field, Some("E03")), "X");
2419        assert_eq!(governing_status(&field, Some("E01")), "X [192]");
2420        assert_eq!(governing_status(&field, None), "X [192]");
2421        assert_eq!(governing_status(&field, Some("ZZZ")), "X [192]");
2422
2423        // An element with a condition of its own keeps it.
2424        let own = AhbFieldRule {
2425            ahb_status: "X [931]".to_string(),
2426            codes: vec![code("303", "X")],
2427            ..Default::default()
2428        };
2429        assert_eq!(governing_status(&own, Some("303")), "X [931]");
2430    }
2431
2432    #[test]
2433    fn test_extract_segment_id_simple() {
2434        assert_eq!(extract_segment_id("NAD"), "NAD");
2435    }
2436
2437    #[test]
2438    fn test_extract_segment_id_with_sg_prefix() {
2439        assert_eq!(extract_segment_id("SG2/NAD/C082/3039"), "NAD");
2440    }
2441
2442    #[test]
2443    fn test_extract_segment_id_nested_sg() {
2444        assert_eq!(extract_segment_id("SG4/SG8/SEQ/C286/6350"), "SEQ");
2445    }
2446
2447    // === Validator tests with mock data ===
2448
2449    #[test]
2450    fn test_validate_missing_mandatory_field() {
2451        let evaluator = MockEvaluator::all_true(&[182, 152]);
2452        let validator = EdifactValidator::new(evaluator);
2453        let external = NoOpExternalProvider;
2454
2455        let workflow = AhbWorkflow {
2456            pruefidentifikator: "11001".to_string(),
2457            description: "Test".to_string(),
2458            communication_direction: None,
2459            fields: vec![AhbFieldRule {
2460                segment_path: "SG2/NAD/C082/3039".to_string(),
2461                name: "MP-ID des MSB".to_string(),
2462                ahb_status: "Muss [182] ∧ [152]".to_string(),
2463                codes: vec![],
2464                parent_group_ahb_status: None,
2465                segment_ahb_status: None,
2466                ..Default::default()
2467            }],
2468            ub_definitions: BTreeMap::new(),
2469            package_conditions: Default::default(),
2470        };
2471
2472        // Validate with no segments
2473        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2474
2475        // Should have an error for missing mandatory field
2476        assert!(!report.is_valid());
2477        let errors: Vec<_> = report.errors().collect();
2478        assert_eq!(errors.len(), 1);
2479        assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
2480        assert!(narrate(errors[0]).contains("MP-ID des MSB"));
2481    }
2482
2483    #[test]
2484    fn test_validate_condition_false_no_error() {
2485        // When condition evaluates to False, field is not required
2486        let evaluator = MockEvaluator::new(vec![(182, CR::True), (152, CR::False)]);
2487        let validator = EdifactValidator::new(evaluator);
2488        let external = NoOpExternalProvider;
2489
2490        let workflow = AhbWorkflow {
2491            pruefidentifikator: "11001".to_string(),
2492            description: "Test".to_string(),
2493            communication_direction: None,
2494            fields: vec![AhbFieldRule {
2495                segment_path: "NAD".to_string(),
2496                name: "Partnerrolle".to_string(),
2497                ahb_status: "Muss [182] ∧ [152]".to_string(),
2498                codes: vec![],
2499                parent_group_ahb_status: None,
2500                segment_ahb_status: None,
2501                ..Default::default()
2502            }],
2503            ub_definitions: BTreeMap::new(),
2504            package_conditions: Default::default(),
2505        };
2506
2507        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2508
2509        // Condition is false, so field is not required - no error
2510        assert!(report.is_valid());
2511    }
2512
2513    /// Validate an absent field with `status`, conditions set as given.
2514    fn validate_absent_field(status: &str, conditions: Vec<(u32, CR)>) -> ValidationReport {
2515        let validator = EdifactValidator::new(MockEvaluator::new(conditions));
2516        let workflow = AhbWorkflow {
2517            pruefidentifikator: "11001".to_string(),
2518            description: "Test".to_string(),
2519            communication_direction: None,
2520            fields: vec![AhbFieldRule {
2521                segment_path: "SG2/NAD/C082/3039".to_string(),
2522                name: "MP-ID".to_string(),
2523                ahb_status: status.to_string(),
2524                ..Default::default()
2525            }],
2526            ub_definitions: BTreeMap::new(),
2527            package_conditions: Default::default(),
2528        };
2529        validator.validate(
2530            &[],
2531            &workflow,
2532            &NoOpExternalProvider,
2533            ValidationLevel::Conditions,
2534        )
2535    }
2536
2537    #[test]
2538    fn a_later_soll_line_alone_does_not_require_the_field() {
2539        // Muss [2119] / Soll [130], only [130] holds: recommended, not required.
2540        let report = validate_absent_field(
2541            "Muss [2119] \r\nSoll [130]",
2542            vec![(2119, CR::False), (130, CR::True)],
2543        );
2544        assert!(
2545            report.is_valid(),
2546            "{:?}",
2547            report.errors().collect::<Vec<_>>()
2548        );
2549    }
2550
2551    #[test]
2552    fn a_later_muss_line_requires_the_field() {
2553        // Soll [47] / Muss [46], only [46] holds: required.
2554        let report = validate_absent_field(
2555            "Soll [47]\r\nMuss [46]",
2556            vec![(47, CR::False), (46, CR::True)],
2557        );
2558        let errors: Vec<_> = report.errors().collect();
2559        assert_eq!(errors.len(), 1);
2560        assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
2561    }
2562
2563    #[test]
2564    fn a_weaker_unknown_line_does_not_stop_a_muss() {
2565        // [130] cannot make it any stricter than the Muss that holds.
2566        let report = validate_absent_field("Muss [2119]\nSoll [130]", vec![(2119, CR::True)]);
2567        let errors: Vec<_> = report.errors().collect();
2568        assert_eq!(errors.len(), 1);
2569        assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
2570    }
2571
2572    #[test]
2573    fn a_stricter_unknown_line_leaves_the_status_undecided() {
2574        let report = validate_absent_field("Muss [2119]\nSoll [130]", vec![(130, CR::True)]);
2575        assert!(report.is_valid());
2576        let infos: Vec<_> = report.infos().collect();
2577        assert_eq!(infos.len(), 1, "{infos:?}");
2578        match &infos[0].kind {
2579            IssueKind::ConditionUnknown { unresolved, .. } => {
2580                assert_eq!(unresolved.missing, vec![2119]);
2581            }
2582            other => panic!("expected ConditionUnknown, got {other:?}"),
2583        }
2584    }
2585
2586    #[test]
2587    fn test_validate_condition_unknown_adds_info() {
2588        // When condition is Unknown, add an info-level note
2589        let evaluator = MockEvaluator::new(vec![(182, CR::True)]);
2590        // 152 is not registered -> Unknown
2591        let validator = EdifactValidator::new(evaluator);
2592        let external = NoOpExternalProvider;
2593
2594        let workflow = AhbWorkflow {
2595            pruefidentifikator: "11001".to_string(),
2596            description: "Test".to_string(),
2597            communication_direction: None,
2598            fields: vec![AhbFieldRule {
2599                segment_path: "NAD".to_string(),
2600                name: "Partnerrolle".to_string(),
2601                ahb_status: "Muss [182] ∧ [152]".to_string(),
2602                codes: vec![],
2603                parent_group_ahb_status: None,
2604                segment_ahb_status: None,
2605                ..Default::default()
2606            }],
2607            ub_definitions: BTreeMap::new(),
2608            package_conditions: Default::default(),
2609        };
2610
2611        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2612
2613        // Should be valid (Unknown is not an error) but have an info issue
2614        assert!(report.is_valid());
2615        let infos: Vec<_> = report.infos().collect();
2616        assert_eq!(infos.len(), 1);
2617        assert_eq!(infos[0].code(), ErrorCodes::CONDITION_UNKNOWN);
2618    }
2619
2620    #[test]
2621    fn test_validate_structure_level_skips_conditions() {
2622        let evaluator = MockEvaluator::all_true(&[182, 152]);
2623        let validator = EdifactValidator::new(evaluator);
2624        let external = NoOpExternalProvider;
2625
2626        let workflow = AhbWorkflow {
2627            pruefidentifikator: "11001".to_string(),
2628            description: "Test".to_string(),
2629            communication_direction: None,
2630            fields: vec![AhbFieldRule {
2631                segment_path: "NAD".to_string(),
2632                name: "Partnerrolle".to_string(),
2633                ahb_status: "Muss [182] ∧ [152]".to_string(),
2634                codes: vec![],
2635                parent_group_ahb_status: None,
2636                segment_ahb_status: None,
2637                ..Default::default()
2638            }],
2639            ub_definitions: BTreeMap::new(),
2640            package_conditions: Default::default(),
2641        };
2642
2643        // With Structure level, conditions are not checked
2644        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Structure);
2645
2646        // No AHB errors because conditions were not evaluated
2647        assert!(report.is_valid());
2648        assert_eq!(report.by_category(ValidationCategory::Ahb).count(), 0);
2649    }
2650
2651    #[test]
2652    fn test_validate_empty_workflow_no_condition_errors() {
2653        let evaluator = MockEvaluator::all_true(&[]);
2654        let validator = EdifactValidator::new(evaluator);
2655        let external = NoOpExternalProvider;
2656
2657        let empty_workflow = AhbWorkflow {
2658            pruefidentifikator: String::new(),
2659            description: String::new(),
2660            communication_direction: None,
2661            fields: vec![],
2662            ub_definitions: BTreeMap::new(),
2663            package_conditions: Default::default(),
2664        };
2665
2666        let report = validator.validate(&[], &empty_workflow, &external, ValidationLevel::Full);
2667
2668        assert!(report.is_valid());
2669    }
2670
2671    #[test]
2672    fn test_validate_bare_muss_always_required() {
2673        let evaluator = MockEvaluator::new(vec![]);
2674        let validator = EdifactValidator::new(evaluator);
2675        let external = NoOpExternalProvider;
2676
2677        let workflow = AhbWorkflow {
2678            pruefidentifikator: "55001".to_string(),
2679            description: "Test".to_string(),
2680            communication_direction: Some("NB an LF".to_string()),
2681            fields: vec![AhbFieldRule {
2682                segment_path: "SG2/NAD/3035".to_string(),
2683                name: "Partnerrolle".to_string(),
2684                ahb_status: "Muss".to_string(), // No conditions
2685                codes: vec![],
2686                parent_group_ahb_status: None,
2687                segment_ahb_status: None,
2688                ..Default::default()
2689            }],
2690            ub_definitions: BTreeMap::new(),
2691            package_conditions: Default::default(),
2692        };
2693
2694        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2695
2696        // Bare "Muss" with no conditions -> unconditionally required -> missing = error
2697        assert!(!report.is_valid());
2698        assert_eq!(report.error_count(), 1);
2699    }
2700
2701    #[test]
2702    fn test_validate_x_status_is_mandatory() {
2703        let evaluator = MockEvaluator::new(vec![]);
2704        let validator = EdifactValidator::new(evaluator);
2705        let external = NoOpExternalProvider;
2706
2707        let workflow = AhbWorkflow {
2708            pruefidentifikator: "55001".to_string(),
2709            description: "Test".to_string(),
2710            communication_direction: None,
2711            fields: vec![AhbFieldRule {
2712                segment_path: "DTM".to_string(),
2713                name: "Datum".to_string(),
2714                ahb_status: "X".to_string(),
2715                codes: vec![],
2716                parent_group_ahb_status: None,
2717                segment_ahb_status: None,
2718                ..Default::default()
2719            }],
2720            ub_definitions: BTreeMap::new(),
2721            package_conditions: Default::default(),
2722        };
2723
2724        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2725
2726        assert!(!report.is_valid());
2727        let errors: Vec<_> = report.errors().collect();
2728        assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
2729    }
2730
2731    #[test]
2732    fn test_validate_soll_not_mandatory() {
2733        let evaluator = MockEvaluator::new(vec![]);
2734        let validator = EdifactValidator::new(evaluator);
2735        let external = NoOpExternalProvider;
2736
2737        let workflow = AhbWorkflow {
2738            pruefidentifikator: "55001".to_string(),
2739            description: "Test".to_string(),
2740            communication_direction: None,
2741            fields: vec![AhbFieldRule {
2742                segment_path: "DTM".to_string(),
2743                name: "Datum".to_string(),
2744                ahb_status: "Soll".to_string(),
2745                codes: vec![],
2746                parent_group_ahb_status: None,
2747                segment_ahb_status: None,
2748                ..Default::default()
2749            }],
2750            ub_definitions: BTreeMap::new(),
2751            package_conditions: Default::default(),
2752        };
2753
2754        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2755
2756        // Soll is not mandatory, so missing is not an error
2757        assert!(report.is_valid());
2758    }
2759
2760    #[test]
2761    fn test_report_includes_metadata() {
2762        let evaluator = MockEvaluator::new(vec![]);
2763        let validator = EdifactValidator::new(evaluator);
2764        let external = NoOpExternalProvider;
2765
2766        let workflow = AhbWorkflow {
2767            pruefidentifikator: "55001".to_string(),
2768            description: String::new(),
2769            communication_direction: None,
2770            fields: vec![],
2771            ub_definitions: BTreeMap::new(),
2772            package_conditions: Default::default(),
2773        };
2774
2775        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Full);
2776
2777        assert_eq!(report.format_version.as_deref(), Some("FV2510"));
2778        assert_eq!(report.level, ValidationLevel::Full);
2779        assert_eq!(report.message_type, "UTILMD");
2780        assert_eq!(report.pruefidentifikator.as_deref(), Some("55001"));
2781    }
2782
2783    #[test]
2784    fn test_validate_with_navigator_returns_report() {
2785        let evaluator = MockEvaluator::all_true(&[]);
2786        let validator = EdifactValidator::new(evaluator);
2787        let external = NoOpExternalProvider;
2788        let nav = crate::eval::NoOpGroupNavigator;
2789
2790        let workflow = AhbWorkflow {
2791            pruefidentifikator: "55001".to_string(),
2792            description: "Test".to_string(),
2793            communication_direction: None,
2794            fields: vec![],
2795            ub_definitions: BTreeMap::new(),
2796            package_conditions: Default::default(),
2797        };
2798
2799        let report = validator.validate_with_navigator(
2800            &[],
2801            &workflow,
2802            &external,
2803            ValidationLevel::Full,
2804            &nav,
2805        );
2806        assert!(report.is_valid());
2807    }
2808
2809    #[test]
2810    fn test_code_validation_composite_paths_valid_codes() {
2811        // UNH/S009/0065 and UNH/S009/0052 carry codes inside composite S009 at
2812        // element_index=1, component sub_index 0 and 1. Validation should match
2813        // the actual composite positions and find no errors for matching values.
2814        let evaluator = MockEvaluator::new(vec![]);
2815        let validator = EdifactValidator::new(evaluator);
2816        let external = NoOpExternalProvider;
2817
2818        let unh_segment = OwnedSegment {
2819            id: "UNH".to_string(),
2820            elements: vec![
2821                vec!["ALEXANDE951842".to_string()],
2822                vec![
2823                    "UTILMD".to_string(),
2824                    "D".to_string(),
2825                    "11A".to_string(),
2826                    "UN".to_string(),
2827                    "S2.1".to_string(),
2828                ],
2829            ],
2830            segment_number: 1,
2831        };
2832
2833        let workflow = AhbWorkflow {
2834            pruefidentifikator: "55001".to_string(),
2835            description: "Test".to_string(),
2836            communication_direction: None,
2837            fields: vec![
2838                AhbFieldRule {
2839                    segment_path: "UNH/S009/0065".to_string(),
2840                    name: "Nachrichtentyp".to_string(),
2841                    ahb_status: "X".to_string(),
2842                    codes: vec![AhbCodeRule {
2843                        value: "UTILMD".to_string(),
2844                        description: "Stammdaten".to_string(),
2845                        ahb_status: "X".to_string(),
2846                    }],
2847                    parent_group_ahb_status: None,
2848                    segment_ahb_status: None,
2849                    element_index: Some(1),
2850                    component_index: Some(0),
2851                    ..Default::default()
2852                },
2853                AhbFieldRule {
2854                    segment_path: "UNH/S009/0052".to_string(),
2855                    name: "Version".to_string(),
2856                    ahb_status: "X".to_string(),
2857                    codes: vec![AhbCodeRule {
2858                        value: "D".to_string(),
2859                        description: "Draft".to_string(),
2860                        ahb_status: "X".to_string(),
2861                    }],
2862                    parent_group_ahb_status: None,
2863                    segment_ahb_status: None,
2864                    element_index: Some(1),
2865                    component_index: Some(1),
2866                    ..Default::default()
2867                },
2868            ],
2869            ub_definitions: BTreeMap::new(),
2870            package_conditions: Default::default(),
2871        };
2872
2873        let report = validator.validate(
2874            &[unh_segment],
2875            &workflow,
2876            &external,
2877            ValidationLevel::Conditions,
2878        );
2879
2880        let code_errors: Vec<_> = report
2881            .by_category(ValidationCategory::Code)
2882            .filter(|i| i.severity == Severity::Error)
2883            .collect();
2884        assert!(
2885            code_errors.is_empty(),
2886            "Expected no code errors when composite values match allowed codes, got: {:?}",
2887            code_errors
2888        );
2889    }
2890
2891    #[test]
2892    fn test_code_validation_partitions_by_mig_number() {
2893        // PID 55018 has two STS migs: 00035 (Statuskategorie=7) constrains
2894        // element 2 to {E03}; 00036 (Statuskategorie=E01) has no constraint there.
2895        // Unioning codes across migs would flag A99 in STS+E01 — it must not.
2896        let evaluator = MockEvaluator::new(vec![]);
2897        let validator = EdifactValidator::new(evaluator);
2898        let external = NoOpExternalProvider;
2899
2900        let sts_7 = OwnedSegment {
2901            id: "STS".to_string(),
2902            elements: vec![
2903                vec!["7".to_string()],
2904                vec![String::new()],
2905                vec!["GH02".to_string()],
2906                vec!["ZW4".to_string()],
2907            ],
2908            segment_number: 1,
2909        };
2910        let sts_e01 = OwnedSegment {
2911            id: "STS".to_string(),
2912            elements: vec![
2913                vec!["E01".to_string()],
2914                vec![String::new()],
2915                vec!["A99".to_string(), "E_0614".to_string()],
2916            ],
2917            segment_number: 2,
2918        };
2919
2920        let workflow = AhbWorkflow {
2921            pruefidentifikator: "55018".to_string(),
2922            description: "Test".to_string(),
2923            communication_direction: None,
2924            fields: vec![
2925                // Mig 00035: qualifier 7 at element 0, E03 required at element 2.
2926                AhbFieldRule {
2927                    segment_path: "SG4/STS/C601/9015".to_string(),
2928                    name: "Statuskategorie".to_string(),
2929                    ahb_status: "X".to_string(),
2930                    codes: vec![AhbCodeRule {
2931                        value: "7".to_string(),
2932                        description: "Transaktionsgrund".to_string(),
2933                        ahb_status: "X".to_string(),
2934                    }],
2935                    parent_group_ahb_status: None,
2936                    segment_ahb_status: None,
2937                    element_index: Some(0),
2938                    component_index: Some(0),
2939                    mig_number: Some("00035".to_string()),
2940                    format: None,
2941                },
2942                AhbFieldRule {
2943                    segment_path: "SG4/STS/C556/9013".to_string(),
2944                    name: "Statusanlaß".to_string(),
2945                    ahb_status: "X".to_string(),
2946                    codes: vec![AhbCodeRule {
2947                        value: "E03".to_string(),
2948                        description: "Transaktionsgrund".to_string(),
2949                        ahb_status: "X".to_string(),
2950                    }],
2951                    parent_group_ahb_status: None,
2952                    segment_ahb_status: None,
2953                    element_index: Some(2),
2954                    component_index: Some(0),
2955                    mig_number: Some("00035".to_string()),
2956                    format: None,
2957                },
2958                // Mig 00036: qualifier E01 at element 0, no codes at element 2.
2959                AhbFieldRule {
2960                    segment_path: "SG4/STS/C601/9015".to_string(),
2961                    name: "Statuskategorie".to_string(),
2962                    ahb_status: "X".to_string(),
2963                    codes: vec![AhbCodeRule {
2964                        value: "E01".to_string(),
2965                        description: "Antwort".to_string(),
2966                        ahb_status: "X".to_string(),
2967                    }],
2968                    parent_group_ahb_status: None,
2969                    segment_ahb_status: None,
2970                    element_index: Some(0),
2971                    component_index: Some(0),
2972                    mig_number: Some("00036".to_string()),
2973                    format: None,
2974                },
2975            ],
2976            ub_definitions: BTreeMap::new(),
2977            package_conditions: Default::default(),
2978        };
2979
2980        let report = validator.validate(
2981            &[sts_7, sts_e01],
2982            &workflow,
2983            &external,
2984            ValidationLevel::Conditions,
2985        );
2986
2987        let code_errors: Vec<_> = report
2988            .by_category(ValidationCategory::Code)
2989            .filter(|i| {
2990                i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
2991            })
2992            .collect();
2993        assert_eq!(
2994            code_errors.len(),
2995            1,
2996            "Expected one COD002 (for GH02 only), got: {:?}",
2997            code_errors
2998        );
2999        assert_eq!(code_errors[0].actual_value.as_deref(), Some("GH02"));
3000    }
3001
3002    #[test]
3003    fn test_code_validation_composite_paths_detects_invalid_code() {
3004        // Mirror of the STS+7 case that motivated composite validation: a bad code
3005        // at element_index=2, component_index=0 must produce COD002.
3006        let evaluator = MockEvaluator::new(vec![]);
3007        let validator = EdifactValidator::new(evaluator);
3008        let external = NoOpExternalProvider;
3009
3010        let sts_segment = OwnedSegment {
3011            id: "STS".to_string(),
3012            elements: vec![
3013                vec!["7".to_string()],
3014                vec![String::new()],
3015                vec!["GH02".to_string()],
3016                vec!["ZW4".to_string()],
3017            ],
3018            segment_number: 1,
3019        };
3020
3021        let workflow = AhbWorkflow {
3022            pruefidentifikator: "55018".to_string(),
3023            description: "Test".to_string(),
3024            communication_direction: None,
3025            fields: vec![AhbFieldRule {
3026                segment_path: "SG4/STS/C556/9013".to_string(),
3027                name: "Statusanlaß".to_string(),
3028                ahb_status: "X".to_string(),
3029                codes: vec![AhbCodeRule {
3030                    value: "E03".to_string(),
3031                    description: "Transaktionsgrund".to_string(),
3032                    ahb_status: "X".to_string(),
3033                }],
3034                parent_group_ahb_status: None,
3035                segment_ahb_status: None,
3036                element_index: Some(2),
3037                component_index: Some(0),
3038                ..Default::default()
3039            }],
3040            ub_definitions: BTreeMap::new(),
3041            package_conditions: Default::default(),
3042        };
3043
3044        let report = validator.validate(
3045            &[sts_segment],
3046            &workflow,
3047            &external,
3048            ValidationLevel::Conditions,
3049        );
3050
3051        let code_errors: Vec<_> = report
3052            .by_category(ValidationCategory::Code)
3053            .filter(|i| {
3054                i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
3055            })
3056            .collect();
3057        assert_eq!(
3058            code_errors.len(),
3059            1,
3060            "Expected COD002 for GH02, got: {:?}",
3061            code_errors
3062        );
3063        assert_eq!(code_errors[0].actual_value.as_deref(), Some("GH02"));
3064    }
3065
3066    #[test]
3067    fn test_cross_field_code_validation_valid_qualifiers() {
3068        // NAD/3035 has separate field rules: [MS] for sender, [MR] for receiver.
3069        // Cross-field validation unions them → {MS, MR}. Both segments are valid.
3070        let evaluator = MockEvaluator::new(vec![]);
3071        let validator = EdifactValidator::new(evaluator);
3072        let external = NoOpExternalProvider;
3073
3074        let nad_ms = OwnedSegment {
3075            id: "NAD".to_string(),
3076            elements: vec![vec!["MS".to_string()]],
3077            segment_number: 4,
3078        };
3079        let nad_mr = OwnedSegment {
3080            id: "NAD".to_string(),
3081            elements: vec![vec!["MR".to_string()]],
3082            segment_number: 5,
3083        };
3084
3085        let workflow = AhbWorkflow {
3086            pruefidentifikator: "55001".to_string(),
3087            description: "Test".to_string(),
3088            communication_direction: None,
3089            fields: vec![
3090                AhbFieldRule {
3091                    segment_path: "SG2/NAD/3035".to_string(),
3092                    name: "Absender".to_string(),
3093                    ahb_status: "X".to_string(),
3094                    codes: vec![AhbCodeRule {
3095                        value: "MS".to_string(),
3096                        description: "Absender".to_string(),
3097                        ahb_status: "X".to_string(),
3098                    }],
3099                    parent_group_ahb_status: None,
3100                    segment_ahb_status: None,
3101                    ..Default::default()
3102                },
3103                AhbFieldRule {
3104                    segment_path: "SG2/NAD/3035".to_string(),
3105                    name: "Empfaenger".to_string(),
3106                    ahb_status: "X".to_string(),
3107                    codes: vec![AhbCodeRule {
3108                        value: "MR".to_string(),
3109                        description: "Empfaenger".to_string(),
3110                        ahb_status: "X".to_string(),
3111                    }],
3112                    parent_group_ahb_status: None,
3113                    segment_ahb_status: None,
3114                    ..Default::default()
3115                },
3116            ],
3117            ub_definitions: BTreeMap::new(),
3118            package_conditions: Default::default(),
3119        };
3120
3121        let report = validator.validate(
3122            &[nad_ms, nad_mr],
3123            &workflow,
3124            &external,
3125            ValidationLevel::Conditions,
3126        );
3127
3128        let code_errors: Vec<_> = report
3129            .by_category(ValidationCategory::Code)
3130            .filter(|i| i.severity == Severity::Error)
3131            .collect();
3132        assert!(
3133            code_errors.is_empty(),
3134            "Expected no code errors for valid qualifiers, got: {:?}",
3135            code_errors
3136        );
3137    }
3138
3139    #[test]
3140    fn test_cross_field_code_validation_catches_invalid_qualifier() {
3141        // NAD+MT is not in the allowed set {MS, MR} → should produce COD002.
3142        let evaluator = MockEvaluator::new(vec![]);
3143        let validator = EdifactValidator::new(evaluator);
3144        let external = NoOpExternalProvider;
3145
3146        let nad_ms = OwnedSegment {
3147            id: "NAD".to_string(),
3148            elements: vec![vec!["MS".to_string()]],
3149            segment_number: 4,
3150        };
3151        let nad_mt = OwnedSegment {
3152            id: "NAD".to_string(),
3153            elements: vec![vec!["MT".to_string()]], // invalid
3154            segment_number: 5,
3155        };
3156
3157        let workflow = AhbWorkflow {
3158            pruefidentifikator: "55001".to_string(),
3159            description: "Test".to_string(),
3160            communication_direction: None,
3161            fields: vec![
3162                AhbFieldRule {
3163                    segment_path: "SG2/NAD/3035".to_string(),
3164                    name: "Absender".to_string(),
3165                    ahb_status: "X".to_string(),
3166                    codes: vec![AhbCodeRule {
3167                        value: "MS".to_string(),
3168                        description: "Absender".to_string(),
3169                        ahb_status: "X".to_string(),
3170                    }],
3171                    parent_group_ahb_status: None,
3172                    segment_ahb_status: None,
3173                    ..Default::default()
3174                },
3175                AhbFieldRule {
3176                    segment_path: "SG2/NAD/3035".to_string(),
3177                    name: "Empfaenger".to_string(),
3178                    ahb_status: "X".to_string(),
3179                    codes: vec![AhbCodeRule {
3180                        value: "MR".to_string(),
3181                        description: "Empfaenger".to_string(),
3182                        ahb_status: "X".to_string(),
3183                    }],
3184                    parent_group_ahb_status: None,
3185                    segment_ahb_status: None,
3186                    ..Default::default()
3187                },
3188            ],
3189            ub_definitions: BTreeMap::new(),
3190            package_conditions: Default::default(),
3191        };
3192
3193        let report = validator.validate(
3194            &[nad_ms, nad_mt],
3195            &workflow,
3196            &external,
3197            ValidationLevel::Conditions,
3198        );
3199
3200        let code_errors: Vec<_> = report
3201            .by_category(ValidationCategory::Code)
3202            .filter(|i| i.severity == Severity::Error)
3203            .collect();
3204        assert_eq!(code_errors.len(), 1, "Expected one COD002 error for MT");
3205        assert!(narrate(code_errors[0]).contains("MT"));
3206        assert!(narrate(code_errors[0]).contains("MR"));
3207        assert!(narrate(code_errors[0]).contains("MS"));
3208    }
3209
3210    #[test]
3211    fn test_cross_field_code_validation_unions_across_groups() {
3212        // SG2/NAD/3035 allows {MS, MR}, SG4/SG12/NAD/3035 allows {Z04, Z09}.
3213        // Since find_segments("NAD") returns all NADs, codes must be unioned
3214        // by tag: {MS, MR, Z04, Z09}. NAD+MT should be caught, all others pass.
3215        let evaluator = MockEvaluator::new(vec![]);
3216        let validator = EdifactValidator::new(evaluator);
3217        let external = NoOpExternalProvider;
3218
3219        let segments = vec![
3220            OwnedSegment {
3221                id: "NAD".to_string(),
3222                elements: vec![vec!["MS".to_string()]],
3223                segment_number: 3,
3224            },
3225            OwnedSegment {
3226                id: "NAD".to_string(),
3227                elements: vec![vec!["MR".to_string()]],
3228                segment_number: 4,
3229            },
3230            OwnedSegment {
3231                id: "NAD".to_string(),
3232                elements: vec![vec!["Z04".to_string()]],
3233                segment_number: 20,
3234            },
3235            OwnedSegment {
3236                id: "NAD".to_string(),
3237                elements: vec![vec!["Z09".to_string()]],
3238                segment_number: 21,
3239            },
3240            OwnedSegment {
3241                id: "NAD".to_string(),
3242                elements: vec![vec!["MT".to_string()]], // invalid
3243                segment_number: 22,
3244            },
3245        ];
3246
3247        let workflow = AhbWorkflow {
3248            pruefidentifikator: "55001".to_string(),
3249            description: "Test".to_string(),
3250            communication_direction: None,
3251            fields: vec![
3252                AhbFieldRule {
3253                    segment_path: "SG2/NAD/3035".to_string(),
3254                    name: "Absender".to_string(),
3255                    ahb_status: "X".to_string(),
3256                    codes: vec![AhbCodeRule {
3257                        value: "MS".to_string(),
3258                        description: "Absender".to_string(),
3259                        ahb_status: "X".to_string(),
3260                    }],
3261                    parent_group_ahb_status: None,
3262                    segment_ahb_status: None,
3263                    ..Default::default()
3264                },
3265                AhbFieldRule {
3266                    segment_path: "SG2/NAD/3035".to_string(),
3267                    name: "Empfaenger".to_string(),
3268                    ahb_status: "X".to_string(),
3269                    codes: vec![AhbCodeRule {
3270                        value: "MR".to_string(),
3271                        description: "Empfaenger".to_string(),
3272                        ahb_status: "X".to_string(),
3273                    }],
3274                    parent_group_ahb_status: None,
3275                    segment_ahb_status: None,
3276                    ..Default::default()
3277                },
3278                AhbFieldRule {
3279                    segment_path: "SG4/SG12/NAD/3035".to_string(),
3280                    name: "Anschlussnutzer".to_string(),
3281                    ahb_status: "X".to_string(),
3282                    codes: vec![AhbCodeRule {
3283                        value: "Z04".to_string(),
3284                        description: "Anschlussnutzer".to_string(),
3285                        ahb_status: "X".to_string(),
3286                    }],
3287                    parent_group_ahb_status: None,
3288                    segment_ahb_status: None,
3289                    ..Default::default()
3290                },
3291                AhbFieldRule {
3292                    segment_path: "SG4/SG12/NAD/3035".to_string(),
3293                    name: "Korrespondenzanschrift".to_string(),
3294                    ahb_status: "X".to_string(),
3295                    codes: vec![AhbCodeRule {
3296                        value: "Z09".to_string(),
3297                        description: "Korrespondenzanschrift".to_string(),
3298                        ahb_status: "X".to_string(),
3299                    }],
3300                    parent_group_ahb_status: None,
3301                    segment_ahb_status: None,
3302                    ..Default::default()
3303                },
3304            ],
3305            ub_definitions: BTreeMap::new(),
3306            package_conditions: Default::default(),
3307        };
3308
3309        let report =
3310            validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
3311
3312        let code_errors: Vec<_> = report
3313            .by_category(ValidationCategory::Code)
3314            .filter(|i| i.severity == Severity::Error)
3315            .collect();
3316        assert_eq!(
3317            code_errors.len(),
3318            1,
3319            "Expected exactly one COD002 error for MT, got: {:?}",
3320            code_errors
3321        );
3322        assert!(narrate(code_errors[0]).contains("MT"));
3323    }
3324
3325    #[test]
3326    fn test_cross_field_code_validation_accepts_conditionally_allowed_codes() {
3327        // QTY 6063 in MSCONS PID 13025 has three AHB-allowed codes:
3328        //   220  ahb_status "X"                        (unconditional)
3329        //   67   ahb_status "X [35] ∨ ([32] ∧ [77])"   (conditional)
3330        //   Z18  ahb_status "X [35]"                    (conditional)
3331        // All three are "allowed codes for this PID" — they differ only in when
3332        // they may be used. The COD002 "code not allowed for this PID" check
3333        // must accept the conditional ones too, otherwise real-world load-profile
3334        // messages using QTY+67 (Ersatzwert) are incorrectly rejected.
3335        let evaluator = MockEvaluator::new(vec![]);
3336        let validator = EdifactValidator::new(evaluator);
3337        let external = NoOpExternalProvider;
3338
3339        let qty_67 = OwnedSegment {
3340            id: "QTY".to_string(),
3341            elements: vec![vec!["67".to_string(), "0.185".to_string()]],
3342            segment_number: 10,
3343        };
3344
3345        let workflow = AhbWorkflow {
3346            pruefidentifikator: "13025".to_string(),
3347            description: "Test".to_string(),
3348            communication_direction: None,
3349            fields: vec![AhbFieldRule {
3350                segment_path: "SG5/SG6/SG9/SG10/QTY/qualifier".to_string(),
3351                name: "Menge, Qualifier".to_string(),
3352                ahb_status: "X".to_string(),
3353                codes: vec![
3354                    AhbCodeRule {
3355                        value: "220".to_string(),
3356                        description: "Wahrer Wert".to_string(),
3357                        ahb_status: "X".to_string(),
3358                    },
3359                    AhbCodeRule {
3360                        value: "67".to_string(),
3361                        description: "Ersatzwert".to_string(),
3362                        ahb_status: "X [35] ∨ ([32] ∧ [77])".to_string(),
3363                    },
3364                    AhbCodeRule {
3365                        value: "Z18".to_string(),
3366                        description: "Vorläufiger Wert".to_string(),
3367                        ahb_status: "X [35]".to_string(),
3368                    },
3369                ],
3370                parent_group_ahb_status: None,
3371                segment_ahb_status: None,
3372                element_index: Some(0),
3373                component_index: Some(0),
3374                ..Default::default()
3375            }],
3376            ub_definitions: BTreeMap::new(),
3377            package_conditions: Default::default(),
3378        };
3379
3380        let report =
3381            validator.validate(&[qty_67], &workflow, &external, ValidationLevel::Conditions);
3382
3383        let code_errors: Vec<_> = report
3384            .by_category(ValidationCategory::Code)
3385            .filter(|i| i.severity == Severity::Error)
3386            .collect();
3387        assert!(
3388            code_errors.is_empty(),
3389            "QTY+67 should be accepted because code '67' is conditionally allowed for this PID (X [35] ∨ ([32] ∧ [77])). Got errors: {:?}",
3390            code_errors
3391        );
3392    }
3393
3394    #[test]
3395    fn test_is_qualifier_field_simple_paths() {
3396        assert!(is_qualifier_field("NAD/3035"));
3397        assert!(is_qualifier_field("SG2/NAD/3035"));
3398        assert!(is_qualifier_field("SG4/SG8/SEQ/6350"));
3399        assert!(is_qualifier_field("LOC/3227"));
3400    }
3401
3402    #[test]
3403    fn test_is_qualifier_field_composite_paths() {
3404        // Composite paths (3 parts after SG stripping) are accepted so composite-level
3405        // codes like STS/C556/9013 get validated. The field rule's element_index and
3406        // component_index carry the exact position.
3407        assert!(is_qualifier_field("UNH/S009/0065"));
3408        assert!(is_qualifier_field("NAD/C082/3039"));
3409        assert!(is_qualifier_field("SG2/NAD/C082/3039"));
3410        assert!(is_qualifier_field("SG4/STS/C556/9013"));
3411    }
3412
3413    #[test]
3414    fn test_is_qualifier_field_bare_segment() {
3415        assert!(!is_qualifier_field("NAD"));
3416        assert!(!is_qualifier_field("SG2/NAD"));
3417    }
3418
3419    #[test]
3420    fn test_is_qualifier_field_rejects_deep_paths() {
3421        // 4+ parts after SG stripping are malformed — reject.
3422        assert!(!is_qualifier_field("SEG/A/B/C/D"));
3423    }
3424
3425    #[test]
3426    fn test_missing_qualifier_instance_is_detected() {
3427        // NAD+MS is present but NAD+MR is missing.
3428        // The Empfaenger field requires [MR] → should produce AHB001.
3429        let evaluator = MockEvaluator::new(vec![]);
3430        let validator = EdifactValidator::new(evaluator);
3431        let external = NoOpExternalProvider;
3432
3433        let nad_ms = OwnedSegment {
3434            id: "NAD".to_string(),
3435            elements: vec![vec!["MS".to_string()]],
3436            segment_number: 3,
3437        };
3438
3439        let workflow = AhbWorkflow {
3440            pruefidentifikator: "55001".to_string(),
3441            description: "Test".to_string(),
3442            communication_direction: None,
3443            fields: vec![
3444                AhbFieldRule {
3445                    segment_path: "SG2/NAD/3035".to_string(),
3446                    name: "Absender".to_string(),
3447                    ahb_status: "X".to_string(),
3448                    codes: vec![AhbCodeRule {
3449                        value: "MS".to_string(),
3450                        description: "Absender".to_string(),
3451                        ahb_status: "X".to_string(),
3452                    }],
3453                    parent_group_ahb_status: None,
3454                    segment_ahb_status: None,
3455                    ..Default::default()
3456                },
3457                AhbFieldRule {
3458                    segment_path: "SG2/NAD/3035".to_string(),
3459                    name: "Empfaenger".to_string(),
3460                    ahb_status: "Muss".to_string(),
3461                    codes: vec![AhbCodeRule {
3462                        value: "MR".to_string(),
3463                        description: "Empfaenger".to_string(),
3464                        ahb_status: "X".to_string(),
3465                    }],
3466                    parent_group_ahb_status: None,
3467                    segment_ahb_status: None,
3468                    ..Default::default()
3469                },
3470            ],
3471            ub_definitions: BTreeMap::new(),
3472            package_conditions: Default::default(),
3473        };
3474
3475        let report =
3476            validator.validate(&[nad_ms], &workflow, &external, ValidationLevel::Conditions);
3477
3478        let ahb_errors: Vec<_> = report
3479            .by_category(ValidationCategory::Ahb)
3480            .filter(|i| i.severity == Severity::Error)
3481            .collect();
3482        assert_eq!(
3483            ahb_errors.len(),
3484            1,
3485            "Expected AHB001 for missing NAD+MR, got: {:?}",
3486            ahb_errors
3487        );
3488        assert!(narrate(ahb_errors[0]).contains("Empfaenger"));
3489    }
3490
3491    #[test]
3492    fn test_present_qualifier_instance_no_error() {
3493        // Both NAD+MS and NAD+MR present → no AHB001 for either.
3494        let evaluator = MockEvaluator::new(vec![]);
3495        let validator = EdifactValidator::new(evaluator);
3496        let external = NoOpExternalProvider;
3497
3498        let segments = vec![
3499            OwnedSegment {
3500                id: "NAD".to_string(),
3501                elements: vec![vec!["MS".to_string()]],
3502                segment_number: 3,
3503            },
3504            OwnedSegment {
3505                id: "NAD".to_string(),
3506                elements: vec![vec!["MR".to_string()]],
3507                segment_number: 4,
3508            },
3509        ];
3510
3511        let workflow = AhbWorkflow {
3512            pruefidentifikator: "55001".to_string(),
3513            description: "Test".to_string(),
3514            communication_direction: None,
3515            fields: vec![
3516                AhbFieldRule {
3517                    segment_path: "SG2/NAD/3035".to_string(),
3518                    name: "Absender".to_string(),
3519                    ahb_status: "Muss".to_string(),
3520                    codes: vec![AhbCodeRule {
3521                        value: "MS".to_string(),
3522                        description: "Absender".to_string(),
3523                        ahb_status: "X".to_string(),
3524                    }],
3525                    parent_group_ahb_status: None,
3526                    segment_ahb_status: None,
3527                    ..Default::default()
3528                },
3529                AhbFieldRule {
3530                    segment_path: "SG2/NAD/3035".to_string(),
3531                    name: "Empfaenger".to_string(),
3532                    ahb_status: "Muss".to_string(),
3533                    codes: vec![AhbCodeRule {
3534                        value: "MR".to_string(),
3535                        description: "Empfaenger".to_string(),
3536                        ahb_status: "X".to_string(),
3537                    }],
3538                    parent_group_ahb_status: None,
3539                    segment_ahb_status: None,
3540                    ..Default::default()
3541                },
3542            ],
3543            ub_definitions: BTreeMap::new(),
3544            package_conditions: Default::default(),
3545        };
3546
3547        let report =
3548            validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
3549
3550        let ahb_errors: Vec<_> = report
3551            .by_category(ValidationCategory::Ahb)
3552            .filter(|i| i.severity == Severity::Error)
3553            .collect();
3554        assert!(
3555            ahb_errors.is_empty(),
3556            "Expected no AHB001 errors, got: {:?}",
3557            ahb_errors
3558        );
3559    }
3560
3561    #[test]
3562    fn test_extract_group_path_key() {
3563        assert_eq!(extract_group_path_key("SG2/NAD/3035"), "SG2");
3564        assert_eq!(extract_group_path_key("SG4/SG12/NAD/3035"), "SG4/SG12");
3565        assert_eq!(extract_group_path_key("NAD/3035"), "");
3566        assert_eq!(extract_group_path_key("SG4/SG8/SEQ/6350"), "SG4/SG8");
3567    }
3568
3569    /// Validate a field of an absent SG9 whose group status is `group_status`.
3570    fn validate_field_of_absent_group(
3571        group_status: &str,
3572        conditions: Vec<(u32, CR)>,
3573    ) -> ValidationReport {
3574        use mig_types::navigator::GroupNavigator;
3575
3576        struct NoSg9;
3577        impl GroupNavigator for NoSg9 {
3578            fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
3579                vec![]
3580            }
3581            fn find_segments_with_qualifier_in_group(
3582                &self,
3583                _: &str,
3584                _: usize,
3585                _: &str,
3586                _: &[&str],
3587                _: usize,
3588            ) -> Vec<OwnedSegment> {
3589                vec![]
3590            }
3591            fn group_instance_count(&self, _: &[&str]) -> usize {
3592                0
3593            }
3594        }
3595
3596        let validator = EdifactValidator::new(MockEvaluator::new(conditions));
3597        let workflow = AhbWorkflow {
3598            pruefidentifikator: "44001".to_string(),
3599            description: "Test".to_string(),
3600            communication_direction: None,
3601            fields: vec![AhbFieldRule {
3602                segment_path: "SG9/QTY/C186/6060".to_string(),
3603                name: "Menge".to_string(),
3604                ahb_status: "X".to_string(),
3605                parent_group_ahb_status: Some(group_status.to_string()),
3606                ..Default::default()
3607            }],
3608            ub_definitions: BTreeMap::new(),
3609            package_conditions: Default::default(),
3610        };
3611        validator.validate_with_navigator(
3612            &[],
3613            &workflow,
3614            &NoOpExternalProvider,
3615            ValidationLevel::Conditions,
3616            &NoSg9,
3617        )
3618    }
3619
3620    #[test]
3621    fn an_absent_group_whose_holding_line_is_soll_is_not_missing() {
3622        // UTILMD Gas SG9 "TUM Kundenwert": Soll [47] ∧ … / Muss [46] ∧ …
3623        let report = validate_field_of_absent_group(
3624            "Soll [47]\r\nMuss [46]",
3625            vec![(47, CR::True), (46, CR::False)],
3626        );
3627        assert!(
3628            report.is_valid(),
3629            "{:?}",
3630            report.errors().collect::<Vec<_>>()
3631        );
3632    }
3633
3634    #[test]
3635    fn an_absent_group_whose_holding_line_is_muss_is_missing() {
3636        let report = validate_field_of_absent_group(
3637            "Soll [47]\r\nMuss [46]",
3638            vec![(47, CR::False), (46, CR::True)],
3639        );
3640        let errors: Vec<_> = report.errors().collect();
3641        assert_eq!(errors.len(), 1, "{errors:?}");
3642        assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
3643    }
3644
3645    #[test]
3646    fn test_absent_optional_group_no_missing_field_error() {
3647        // SG3 is optional ("Kann"). If SG3 is absent, its children CTA/3139
3648        // and CTA/C056/3412 should NOT produce AHB001 errors.
3649        use mig_types::navigator::GroupNavigator;
3650
3651        struct NavWithoutSG3;
3652        impl GroupNavigator for NavWithoutSG3 {
3653            fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
3654                vec![]
3655            }
3656            fn find_segments_with_qualifier_in_group(
3657                &self,
3658                _: &str,
3659                _: usize,
3660                _: &str,
3661                _: &[&str],
3662                _: usize,
3663            ) -> Vec<OwnedSegment> {
3664                vec![]
3665            }
3666            fn group_instance_count(&self, group_path: &[&str]) -> usize {
3667                match group_path {
3668                    ["SG2"] => 2,        // two NAD groups present
3669                    ["SG2", "SG3"] => 0, // SG3 is absent
3670                    _ => 0,
3671                }
3672            }
3673        }
3674
3675        let evaluator = MockEvaluator::new(vec![]);
3676        let validator = EdifactValidator::new(evaluator);
3677        let external = NoOpExternalProvider;
3678        let nav = NavWithoutSG3;
3679
3680        // Only NAD segments present, no CTA
3681        let segments = vec![
3682            OwnedSegment {
3683                id: "NAD".into(),
3684                elements: vec![vec!["MS".into()]],
3685                segment_number: 3,
3686            },
3687            OwnedSegment {
3688                id: "NAD".into(),
3689                elements: vec![vec!["MR".into()]],
3690                segment_number: 4,
3691            },
3692        ];
3693
3694        let workflow = AhbWorkflow {
3695            pruefidentifikator: "55001".to_string(),
3696            description: "Test".to_string(),
3697            communication_direction: None,
3698            fields: vec![
3699                AhbFieldRule {
3700                    segment_path: "SG2/SG3/CTA/3139".to_string(),
3701                    name: "Funktion des Ansprechpartners, Code".to_string(),
3702                    ahb_status: "Muss".to_string(),
3703                    codes: vec![],
3704                    parent_group_ahb_status: None,
3705                    segment_ahb_status: None,
3706                    ..Default::default()
3707                },
3708                AhbFieldRule {
3709                    segment_path: "SG2/SG3/CTA/C056/3412".to_string(),
3710                    name: "Name vom Ansprechpartner".to_string(),
3711                    ahb_status: "X".to_string(),
3712                    codes: vec![],
3713                    parent_group_ahb_status: None,
3714                    segment_ahb_status: None,
3715                    ..Default::default()
3716                },
3717            ],
3718            ub_definitions: BTreeMap::new(),
3719            package_conditions: Default::default(),
3720        };
3721
3722        let report = validator.validate_with_navigator(
3723            &segments,
3724            &workflow,
3725            &external,
3726            ValidationLevel::Conditions,
3727            &nav,
3728        );
3729
3730        let ahb_errors: Vec<_> = report
3731            .by_category(ValidationCategory::Ahb)
3732            .filter(|i| i.severity == Severity::Error)
3733            .collect();
3734        assert!(
3735            ahb_errors.is_empty(),
3736            "Expected no AHB001 errors when SG3 is absent, got: {:?}",
3737            ahb_errors
3738        );
3739    }
3740
3741    #[test]
3742    fn test_present_group_still_checks_mandatory_fields() {
3743        // If SG3 IS present but CTA is missing within it → AHB001 error.
3744        use mig_types::navigator::GroupNavigator;
3745
3746        struct NavWithSG3;
3747        impl GroupNavigator for NavWithSG3 {
3748            fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
3749                vec![]
3750            }
3751            fn find_segments_with_qualifier_in_group(
3752                &self,
3753                _: &str,
3754                _: usize,
3755                _: &str,
3756                _: &[&str],
3757                _: usize,
3758            ) -> Vec<OwnedSegment> {
3759                vec![]
3760            }
3761            fn group_instance_count(&self, group_path: &[&str]) -> usize {
3762                match group_path {
3763                    ["SG2"] => 1,
3764                    ["SG2", "SG3"] => 1, // SG3 is present
3765                    _ => 0,
3766                }
3767            }
3768        }
3769
3770        let evaluator = MockEvaluator::new(vec![]);
3771        let validator = EdifactValidator::new(evaluator);
3772        let external = NoOpExternalProvider;
3773        let nav = NavWithSG3;
3774
3775        // SG3 is present (nav says 1 instance) but CTA is not in flat segments
3776        let segments = vec![OwnedSegment {
3777            id: "NAD".into(),
3778            elements: vec![vec!["MS".into()]],
3779            segment_number: 3,
3780        }];
3781
3782        let workflow = AhbWorkflow {
3783            pruefidentifikator: "55001".to_string(),
3784            description: "Test".to_string(),
3785            communication_direction: None,
3786            fields: vec![AhbFieldRule {
3787                segment_path: "SG2/SG3/CTA/3139".to_string(),
3788                name: "Funktion des Ansprechpartners, Code".to_string(),
3789                ahb_status: "Muss".to_string(),
3790                codes: vec![],
3791                parent_group_ahb_status: None,
3792                segment_ahb_status: None,
3793                ..Default::default()
3794            }],
3795            ub_definitions: BTreeMap::new(),
3796            package_conditions: Default::default(),
3797        };
3798
3799        let report = validator.validate_with_navigator(
3800            &segments,
3801            &workflow,
3802            &external,
3803            ValidationLevel::Conditions,
3804            &nav,
3805        );
3806
3807        let ahb_errors: Vec<_> = report
3808            .by_category(ValidationCategory::Ahb)
3809            .filter(|i| i.severity == Severity::Error)
3810            .collect();
3811        assert_eq!(
3812            ahb_errors.len(),
3813            1,
3814            "Expected AHB001 error when SG3 is present but CTA missing"
3815        );
3816        assert!(narrate(ahb_errors[0]).contains("CTA"));
3817    }
3818
3819    #[test]
3820    fn test_missing_qualifier_with_navigator_is_detected() {
3821        // NAD+MS is in SG2 but NAD+MR is missing. With a navigator that
3822        // reports SG2 has 1 instance, the missing MR must still be flagged.
3823        use mig_types::navigator::GroupNavigator;
3824
3825        struct NavWithSG2;
3826        impl GroupNavigator for NavWithSG2 {
3827            fn find_segments_in_group(
3828                &self,
3829                segment_id: &str,
3830                group_path: &[&str],
3831                instance_index: usize,
3832            ) -> Vec<OwnedSegment> {
3833                if segment_id == "NAD" && group_path == ["SG2"] && instance_index == 0 {
3834                    vec![OwnedSegment {
3835                        id: "NAD".into(),
3836                        elements: vec![vec!["MS".into()]],
3837                        segment_number: 3,
3838                    }]
3839                } else {
3840                    vec![]
3841                }
3842            }
3843            fn find_segments_with_qualifier_in_group(
3844                &self,
3845                _: &str,
3846                _: usize,
3847                _: &str,
3848                _: &[&str],
3849                _: usize,
3850            ) -> Vec<OwnedSegment> {
3851                vec![]
3852            }
3853            fn group_instance_count(&self, group_path: &[&str]) -> usize {
3854                match group_path {
3855                    ["SG2"] => 1,
3856                    _ => 0,
3857                }
3858            }
3859        }
3860
3861        let evaluator = MockEvaluator::new(vec![]);
3862        let validator = EdifactValidator::new(evaluator);
3863        let external = NoOpExternalProvider;
3864        let nav = NavWithSG2;
3865
3866        let segments = vec![OwnedSegment {
3867            id: "NAD".into(),
3868            elements: vec![vec!["MS".into()]],
3869            segment_number: 3,
3870        }];
3871
3872        let workflow = AhbWorkflow {
3873            pruefidentifikator: "55001".to_string(),
3874            description: "Test".to_string(),
3875            communication_direction: None,
3876            fields: vec![
3877                AhbFieldRule {
3878                    segment_path: "SG2/NAD/3035".to_string(),
3879                    name: "Absender".to_string(),
3880                    ahb_status: "X".to_string(),
3881                    codes: vec![AhbCodeRule {
3882                        value: "MS".to_string(),
3883                        description: "Absender".to_string(),
3884                        ahb_status: "X".to_string(),
3885                    }],
3886                    parent_group_ahb_status: None,
3887                    segment_ahb_status: None,
3888                    ..Default::default()
3889                },
3890                AhbFieldRule {
3891                    segment_path: "SG2/NAD/3035".to_string(),
3892                    name: "Empfaenger".to_string(),
3893                    ahb_status: "Muss".to_string(),
3894                    codes: vec![AhbCodeRule {
3895                        value: "MR".to_string(),
3896                        description: "Empfaenger".to_string(),
3897                        ahb_status: "X".to_string(),
3898                    }],
3899                    parent_group_ahb_status: None,
3900                    segment_ahb_status: None,
3901                    ..Default::default()
3902                },
3903            ],
3904            ub_definitions: BTreeMap::new(),
3905            package_conditions: Default::default(),
3906        };
3907
3908        let report = validator.validate_with_navigator(
3909            &segments,
3910            &workflow,
3911            &external,
3912            ValidationLevel::Conditions,
3913            &nav,
3914        );
3915
3916        let ahb_errors: Vec<_> = report
3917            .by_category(ValidationCategory::Ahb)
3918            .filter(|i| i.severity == Severity::Error)
3919            .collect();
3920        assert_eq!(
3921            ahb_errors.len(),
3922            1,
3923            "Expected AHB001 for missing NAD+MR even with navigator, got: {:?}",
3924            ahb_errors
3925        );
3926        assert!(narrate(ahb_errors[0]).contains("Empfaenger"));
3927    }
3928
3929    #[test]
3930    fn test_optional_group_variant_absent_no_error() {
3931        // SG5 is "Kann" (optional) with LOC+Z16 present but LOC+Z17 absent.
3932        // Field rules for LOC/3227 with Z17 and its children should NOT error
3933        // because the parent group is optional and the variant is absent.
3934        // Meanwhile, SG2 is "Muss" — missing NAD+MR MUST still error.
3935        use mig_types::navigator::GroupNavigator;
3936
3937        struct TestNav;
3938        impl GroupNavigator for TestNav {
3939            fn find_segments_in_group(
3940                &self,
3941                segment_id: &str,
3942                group_path: &[&str],
3943                instance_index: usize,
3944            ) -> Vec<OwnedSegment> {
3945                match (segment_id, group_path, instance_index) {
3946                    ("LOC", ["SG4", "SG5"], 0) => vec![OwnedSegment {
3947                        id: "LOC".into(),
3948                        elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
3949                        segment_number: 10,
3950                    }],
3951                    ("NAD", ["SG2"], 0) => vec![OwnedSegment {
3952                        id: "NAD".into(),
3953                        elements: vec![vec!["MS".into()]],
3954                        segment_number: 3,
3955                    }],
3956                    _ => vec![],
3957                }
3958            }
3959            fn find_segments_with_qualifier_in_group(
3960                &self,
3961                _: &str,
3962                _: usize,
3963                _: &str,
3964                _: &[&str],
3965                _: usize,
3966            ) -> Vec<OwnedSegment> {
3967                vec![]
3968            }
3969            fn group_instance_count(&self, group_path: &[&str]) -> usize {
3970                match group_path {
3971                    ["SG2"] => 1,
3972                    ["SG4"] => 1,
3973                    ["SG4", "SG5"] => 1, // only Z16 instance
3974                    _ => 0,
3975                }
3976            }
3977        }
3978
3979        let evaluator = MockEvaluator::new(vec![]);
3980        let validator = EdifactValidator::new(evaluator);
3981        let external = NoOpExternalProvider;
3982        let nav = TestNav;
3983
3984        let segments = vec![
3985            OwnedSegment {
3986                id: "NAD".into(),
3987                elements: vec![vec!["MS".into()]],
3988                segment_number: 3,
3989            },
3990            OwnedSegment {
3991                id: "LOC".into(),
3992                elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
3993                segment_number: 10,
3994            },
3995        ];
3996
3997        let workflow = AhbWorkflow {
3998            pruefidentifikator: "55001".to_string(),
3999            description: "Test".to_string(),
4000            communication_direction: None,
4001            fields: vec![
4002                // SG2 "Muss" — NAD+MS present, NAD+MR missing → should error
4003                AhbFieldRule {
4004                    segment_path: "SG2/NAD/3035".to_string(),
4005                    name: "Absender".to_string(),
4006                    ahb_status: "X".to_string(),
4007                    codes: vec![AhbCodeRule {
4008                        value: "MS".to_string(),
4009                        description: "Absender".to_string(),
4010                        ahb_status: "X".to_string(),
4011                    }],
4012                    parent_group_ahb_status: Some("Muss".to_string()),
4013                    segment_ahb_status: None,
4014                    ..Default::default()
4015                },
4016                AhbFieldRule {
4017                    segment_path: "SG2/NAD/3035".to_string(),
4018                    name: "Empfaenger".to_string(),
4019                    ahb_status: "Muss".to_string(),
4020                    codes: vec![AhbCodeRule {
4021                        value: "MR".to_string(),
4022                        description: "Empfaenger".to_string(),
4023                        ahb_status: "X".to_string(),
4024                    }],
4025                    parent_group_ahb_status: Some("Muss".to_string()),
4026                    segment_ahb_status: None,
4027                    ..Default::default()
4028                },
4029                // SG5 "Kann" — LOC+Z16 present, LOC+Z17 absent → should NOT error
4030                AhbFieldRule {
4031                    segment_path: "SG4/SG5/LOC/3227".to_string(),
4032                    name: "Ortsangabe, Qualifier (Z16)".to_string(),
4033                    ahb_status: "X".to_string(),
4034                    codes: vec![AhbCodeRule {
4035                        value: "Z16".to_string(),
4036                        description: "Marktlokation".to_string(),
4037                        ahb_status: "X".to_string(),
4038                    }],
4039                    parent_group_ahb_status: Some("Kann".to_string()),
4040                    segment_ahb_status: None,
4041                    ..Default::default()
4042                },
4043                AhbFieldRule {
4044                    segment_path: "SG4/SG5/LOC/3227".to_string(),
4045                    name: "Ortsangabe, Qualifier (Z17)".to_string(),
4046                    ahb_status: "Muss".to_string(),
4047                    codes: vec![AhbCodeRule {
4048                        value: "Z17".to_string(),
4049                        description: "Messlokation".to_string(),
4050                        ahb_status: "X".to_string(),
4051                    }],
4052                    parent_group_ahb_status: Some("Kann".to_string()),
4053                    segment_ahb_status: None,
4054                    ..Default::default()
4055                },
4056            ],
4057            ub_definitions: BTreeMap::new(),
4058            package_conditions: Default::default(),
4059        };
4060
4061        let report = validator.validate_with_navigator(
4062            &segments,
4063            &workflow,
4064            &external,
4065            ValidationLevel::Conditions,
4066            &nav,
4067        );
4068
4069        let ahb_errors: Vec<_> = report
4070            .by_category(ValidationCategory::Ahb)
4071            .filter(|i| i.severity == Severity::Error)
4072            .collect();
4073
4074        // Exactly 1 error: NAD+MR missing (mandatory group)
4075        // LOC+Z17 should NOT error (optional group variant absent)
4076        assert_eq!(
4077            ahb_errors.len(),
4078            1,
4079            "Expected only AHB001 for missing NAD+MR, got: {:?}",
4080            ahb_errors
4081        );
4082        assert!(
4083            narrate(ahb_errors[0]).contains("Empfaenger"),
4084            "Error should be for missing NAD+MR (Empfaenger)"
4085        );
4086    }
4087
4088    /// UTILMD Gas 44143: SG8 "Messdatenregistriergerätedaten" (SEQ+Z50) is
4089    /// `Kann [345]`. With [345] true the variant is allowed, not required, so its
4090    /// absence — beside a present SEQ+Z03 SG8 — must not report its `SEQ 1229`.
4091    #[test]
4092    fn test_optional_group_variant_with_true_condition_absent_no_error() {
4093        use mig_types::navigator::GroupNavigator;
4094
4095        struct TestNav;
4096        impl GroupNavigator for TestNav {
4097            fn find_segments_in_group(
4098                &self,
4099                segment_id: &str,
4100                group_path: &[&str],
4101                instance_index: usize,
4102            ) -> Vec<OwnedSegment> {
4103                if segment_id == "SEQ" && group_path == ["SG4", "SG8"] && instance_index == 0 {
4104                    vec![OwnedSegment {
4105                        id: "SEQ".into(),
4106                        elements: vec![vec!["Z03".into()]],
4107                        segment_number: 10,
4108                    }]
4109                } else {
4110                    vec![]
4111                }
4112            }
4113            fn find_segments_with_qualifier_in_group(
4114                &self,
4115                _: &str,
4116                _: usize,
4117                _: &str,
4118                _: &[&str],
4119                _: usize,
4120            ) -> Vec<OwnedSegment> {
4121                vec![]
4122            }
4123            fn group_instance_count(&self, group_path: &[&str]) -> usize {
4124                match group_path {
4125                    ["SG4"] => 1,
4126                    ["SG4", "SG8"] => 1,
4127                    _ => 0,
4128                }
4129            }
4130        }
4131
4132        let evaluator = MockEvaluator::new(vec![(345, CR::True)]);
4133        let validator = EdifactValidator::new(evaluator);
4134        let segments = vec![OwnedSegment {
4135            id: "SEQ".into(),
4136            elements: vec![vec!["Z03".into()]],
4137            segment_number: 10,
4138        }];
4139        let seq = |code: &str, group: &str| AhbFieldRule {
4140            segment_path: "SG4/SG8/SEQ/1229".to_string(),
4141            name: format!("Handlung, Code ({code})"),
4142            ahb_status: "X".to_string(),
4143            codes: vec![AhbCodeRule {
4144                value: code.to_string(),
4145                description: String::new(),
4146                ahb_status: "X".to_string(),
4147            }],
4148            parent_group_ahb_status: Some(group.to_string()),
4149            segment_ahb_status: None,
4150            ..Default::default()
4151        };
4152        let workflow = AhbWorkflow {
4153            pruefidentifikator: "44143".to_string(),
4154            description: "Test".to_string(),
4155            communication_direction: None,
4156            fields: vec![seq("Z03", "Kann"), seq("Z50", "Kann [345]")],
4157            ub_definitions: BTreeMap::new(),
4158            package_conditions: Default::default(),
4159        };
4160
4161        let report = validator.validate_with_navigator(
4162            &segments,
4163            &workflow,
4164            &NoOpExternalProvider,
4165            ValidationLevel::Conditions,
4166            &TestNav,
4167        );
4168        let missing: Vec<_> = report
4169            .issues
4170            .iter()
4171            .filter(|i| matches!(i.kind, IssueKind::MissingRequiredField { .. }))
4172            .collect();
4173        assert!(missing.is_empty(), "{missing:#?}");
4174    }
4175
4176    #[test]
4177    fn test_conditional_group_variant_absent_no_error() {
4178        // Real-world scenario: SG5 "Messlokation" has AHB_Status="Soll [165]".
4179        // Condition [165] evaluates to False → LOC+Z17 should NOT error.
4180        // SG5 "Marktlokation" has AHB_Status="Muss [2061]".
4181        // Condition [2061] evaluates to True → LOC+Z16 is present → no error.
4182        use mig_types::navigator::GroupNavigator;
4183
4184        struct TestNav;
4185        impl GroupNavigator for TestNav {
4186            fn find_segments_in_group(
4187                &self,
4188                segment_id: &str,
4189                group_path: &[&str],
4190                instance_index: usize,
4191            ) -> Vec<OwnedSegment> {
4192                if segment_id == "LOC" && group_path == ["SG4", "SG5"] && instance_index == 0 {
4193                    vec![OwnedSegment {
4194                        id: "LOC".into(),
4195                        elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
4196                        segment_number: 10,
4197                    }]
4198                } else {
4199                    vec![]
4200                }
4201            }
4202            fn find_segments_with_qualifier_in_group(
4203                &self,
4204                _: &str,
4205                _: usize,
4206                _: &str,
4207                _: &[&str],
4208                _: usize,
4209            ) -> Vec<OwnedSegment> {
4210                vec![]
4211            }
4212            fn group_instance_count(&self, group_path: &[&str]) -> usize {
4213                match group_path {
4214                    ["SG4"] => 1,
4215                    ["SG4", "SG5"] => 1, // only Z16 instance
4216                    _ => 0,
4217                }
4218            }
4219        }
4220
4221        // Condition 165 → False (Messlokation not required)
4222        // Condition 2061 → True (Marktlokation required)
4223        let evaluator = MockEvaluator::new(vec![(165, CR::False), (2061, CR::True)]);
4224        let validator = EdifactValidator::new(evaluator);
4225        let external = NoOpExternalProvider;
4226        let nav = TestNav;
4227
4228        let segments = vec![OwnedSegment {
4229            id: "LOC".into(),
4230            elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
4231            segment_number: 10,
4232        }];
4233
4234        let workflow = AhbWorkflow {
4235            pruefidentifikator: "55001".to_string(),
4236            description: "Test".to_string(),
4237            communication_direction: None,
4238            fields: vec![
4239                // SG5 "Muss [2061]" with [2061]=True → LOC+Z16 required, present → OK
4240                AhbFieldRule {
4241                    segment_path: "SG4/SG5/LOC/3227".to_string(),
4242                    name: "Ortsangabe, Qualifier (Z16)".to_string(),
4243                    ahb_status: "X".to_string(),
4244                    codes: vec![AhbCodeRule {
4245                        value: "Z16".to_string(),
4246                        description: "Marktlokation".to_string(),
4247                        ahb_status: "X".to_string(),
4248                    }],
4249                    parent_group_ahb_status: Some("Muss [2061]".to_string()),
4250                    segment_ahb_status: None,
4251                    ..Default::default()
4252                },
4253                // SG5 "Soll [165]" with [165]=False → LOC+Z17 NOT required → skip
4254                AhbFieldRule {
4255                    segment_path: "SG4/SG5/LOC/3227".to_string(),
4256                    name: "Ortsangabe, Qualifier (Z17)".to_string(),
4257                    ahb_status: "X".to_string(),
4258                    codes: vec![AhbCodeRule {
4259                        value: "Z17".to_string(),
4260                        description: "Messlokation".to_string(),
4261                        ahb_status: "X".to_string(),
4262                    }],
4263                    parent_group_ahb_status: Some("Soll [165]".to_string()),
4264                    segment_ahb_status: None,
4265                    ..Default::default()
4266                },
4267            ],
4268            ub_definitions: BTreeMap::new(),
4269            package_conditions: Default::default(),
4270        };
4271
4272        let report = validator.validate_with_navigator(
4273            &segments,
4274            &workflow,
4275            &external,
4276            ValidationLevel::Conditions,
4277            &nav,
4278        );
4279
4280        let ahb_errors: Vec<_> = report
4281            .by_category(ValidationCategory::Ahb)
4282            .filter(|i| i.severity == Severity::Error)
4283            .collect();
4284
4285        // Zero errors: Z16 is present, and Z17's group condition is False
4286        assert!(
4287            ahb_errors.is_empty(),
4288            "Expected no errors when conditional group variant [165]=False, got: {:?}",
4289            ahb_errors
4290        );
4291    }
4292
4293    #[test]
4294    fn test_conditional_group_variant_unknown_no_error() {
4295        // When a parent group condition evaluates to Unknown (unimplemented
4296        // condition), child fields should NOT produce mandatory-missing errors.
4297        // The group-level entry itself will produce an Info "condition unknown".
4298
4299        // Condition 165 is NOT in the evaluator → returns Unknown
4300        let evaluator = MockEvaluator::new(vec![]);
4301        let validator = EdifactValidator::new(evaluator);
4302        let external = NoOpExternalProvider;
4303
4304        let workflow = AhbWorkflow {
4305            pruefidentifikator: "55001".to_string(),
4306            description: "Test".to_string(),
4307            communication_direction: None,
4308            fields: vec![AhbFieldRule {
4309                segment_path: "SG4/SG5/LOC/3227".to_string(),
4310                name: "Ortsangabe, Qualifier (Z17)".to_string(),
4311                ahb_status: "X".to_string(),
4312                codes: vec![AhbCodeRule {
4313                    value: "Z17".to_string(),
4314                    description: "Messlokation".to_string(),
4315                    ahb_status: "X".to_string(),
4316                }],
4317                parent_group_ahb_status: Some("Soll [165]".to_string()),
4318                segment_ahb_status: None,
4319                ..Default::default()
4320            }],
4321            ub_definitions: BTreeMap::new(),
4322            package_conditions: Default::default(),
4323        };
4324
4325        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
4326
4327        let ahb_errors: Vec<_> = report
4328            .by_category(ValidationCategory::Ahb)
4329            .filter(|i| i.severity == Severity::Error)
4330            .collect();
4331
4332        // No errors: parent group condition [165] is Unknown → skip child fields
4333        assert!(
4334            ahb_errors.is_empty(),
4335            "Expected no errors when parent group condition is Unknown, got: {:?}",
4336            ahb_errors
4337        );
4338    }
4339
4340    #[test]
4341    fn test_segment_absent_within_present_group_no_error() {
4342        // MSCONS scenario: SG10 has QTY (entry) + optional STS segments.
4343        // SG10 is present (QTY+220 exists) but STS is absent.
4344        // Fields under STS should NOT produce AHB001 errors.
4345        use mig_types::navigator::GroupNavigator;
4346
4347        struct TestNav;
4348        impl GroupNavigator for TestNav {
4349            fn find_segments_in_group(
4350                &self,
4351                segment_id: &str,
4352                group_path: &[&str],
4353                instance_index: usize,
4354            ) -> Vec<OwnedSegment> {
4355                // SG10 has QTY but no STS
4356                if segment_id == "QTY"
4357                    && group_path == ["SG5", "SG6", "SG9", "SG10"]
4358                    && instance_index == 0
4359                {
4360                    vec![OwnedSegment {
4361                        id: "QTY".into(),
4362                        elements: vec![vec!["220".into(), "0".into()]],
4363                        segment_number: 14,
4364                    }]
4365                } else {
4366                    vec![]
4367                }
4368            }
4369            fn find_segments_with_qualifier_in_group(
4370                &self,
4371                _: &str,
4372                _: usize,
4373                _: &str,
4374                _: &[&str],
4375                _: usize,
4376            ) -> Vec<OwnedSegment> {
4377                vec![]
4378            }
4379            fn group_instance_count(&self, group_path: &[&str]) -> usize {
4380                match group_path {
4381                    ["SG5"] => 1,
4382                    ["SG5", "SG6"] => 1,
4383                    ["SG5", "SG6", "SG9"] => 1,
4384                    ["SG5", "SG6", "SG9", "SG10"] => 1,
4385                    _ => 0,
4386                }
4387            }
4388            fn has_any_segment_in_group(&self, group_path: &[&str], instance_index: usize) -> bool {
4389                // SG10 instance 0 has QTY (the entry segment)
4390                group_path == ["SG5", "SG6", "SG9", "SG10"] && instance_index == 0
4391            }
4392        }
4393
4394        let evaluator = MockEvaluator::all_true(&[]);
4395        let validator = EdifactValidator::new(evaluator);
4396        let external = NoOpExternalProvider;
4397        let nav = TestNav;
4398
4399        let segments = vec![OwnedSegment {
4400            id: "QTY".into(),
4401            elements: vec![vec!["220".into(), "0".into()]],
4402            segment_number: 14,
4403        }];
4404
4405        let workflow = AhbWorkflow {
4406            pruefidentifikator: "13017".to_string(),
4407            description: "Test".to_string(),
4408            communication_direction: None,
4409            fields: vec![
4410                // STS/C601/9015 — mandatory field under STS, but STS is absent from SG10
4411                AhbFieldRule {
4412                    segment_path: "SG5/SG6/SG9/SG10/STS/C601/9015".to_string(),
4413                    name: "Statuskategorie, Code".to_string(),
4414                    ahb_status: "X".to_string(),
4415                    codes: vec![],
4416                    parent_group_ahb_status: Some("Muss".to_string()),
4417                    segment_ahb_status: None,
4418                    ..Default::default()
4419                },
4420                // STS/C556/9013 — another field under STS
4421                AhbFieldRule {
4422                    segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4423                    name: "Statusanlaß, Code".to_string(),
4424                    ahb_status: "X [5]".to_string(),
4425                    codes: vec![],
4426                    parent_group_ahb_status: Some("Muss".to_string()),
4427                    segment_ahb_status: None,
4428                    ..Default::default()
4429                },
4430            ],
4431            ub_definitions: BTreeMap::new(),
4432            package_conditions: Default::default(),
4433        };
4434
4435        let report = validator.validate_with_navigator(
4436            &segments,
4437            &workflow,
4438            &external,
4439            ValidationLevel::Conditions,
4440            &nav,
4441        );
4442
4443        let ahb_errors: Vec<_> = report
4444            .by_category(ValidationCategory::Ahb)
4445            .filter(|i| i.severity == Severity::Error)
4446            .collect();
4447
4448        assert!(
4449            ahb_errors.is_empty(),
4450            "Expected no AHB001 errors when STS segment is absent from SG10, got: {:?}",
4451            ahb_errors
4452        );
4453    }
4454
4455    #[test]
4456    fn test_group_scoped_code_validation_with_navigator() {
4457        // With a navigator, SG2/NAD is checked against {MS, MR} only,
4458        // and SG4/SG12/NAD is checked against {Z04, Z09} only.
4459        // NAD+MT in SG2 → error with allowed [MR, MS] (not the full union).
4460        use mig_types::navigator::GroupNavigator;
4461
4462        struct TestNav;
4463        impl GroupNavigator for TestNav {
4464            fn find_segments_in_group(
4465                &self,
4466                segment_id: &str,
4467                group_path: &[&str],
4468                _instance_index: usize,
4469            ) -> Vec<OwnedSegment> {
4470                if segment_id != "NAD" {
4471                    return vec![];
4472                }
4473                match group_path {
4474                    ["SG2"] => vec![
4475                        OwnedSegment {
4476                            id: "NAD".into(),
4477                            elements: vec![vec!["MS".into()]],
4478                            segment_number: 3,
4479                        },
4480                        OwnedSegment {
4481                            id: "NAD".into(),
4482                            elements: vec![vec!["MT".into()]], // invalid in SG2
4483                            segment_number: 4,
4484                        },
4485                    ],
4486                    ["SG4", "SG12"] => vec![
4487                        OwnedSegment {
4488                            id: "NAD".into(),
4489                            elements: vec![vec!["Z04".into()]],
4490                            segment_number: 20,
4491                        },
4492                        OwnedSegment {
4493                            id: "NAD".into(),
4494                            elements: vec![vec!["Z09".into()]],
4495                            segment_number: 21,
4496                        },
4497                    ],
4498                    _ => vec![],
4499                }
4500            }
4501            fn find_segments_with_qualifier_in_group(
4502                &self,
4503                _: &str,
4504                _: usize,
4505                _: &str,
4506                _: &[&str],
4507                _: usize,
4508            ) -> Vec<OwnedSegment> {
4509                vec![]
4510            }
4511            fn group_instance_count(&self, group_path: &[&str]) -> usize {
4512                match group_path {
4513                    ["SG2"] | ["SG4", "SG12"] => 1,
4514                    _ => 0,
4515                }
4516            }
4517        }
4518
4519        let evaluator = MockEvaluator::new(vec![]);
4520        let validator = EdifactValidator::new(evaluator);
4521        let external = NoOpExternalProvider;
4522        let nav = TestNav;
4523
4524        let workflow = AhbWorkflow {
4525            pruefidentifikator: "55001".to_string(),
4526            description: "Test".to_string(),
4527            communication_direction: None,
4528            fields: vec![
4529                AhbFieldRule {
4530                    segment_path: "SG2/NAD/3035".to_string(),
4531                    name: "Absender".to_string(),
4532                    ahb_status: "X".to_string(),
4533                    codes: vec![AhbCodeRule {
4534                        value: "MS".to_string(),
4535                        description: "Absender".to_string(),
4536                        ahb_status: "X".to_string(),
4537                    }],
4538                    parent_group_ahb_status: None,
4539                    segment_ahb_status: None,
4540                    ..Default::default()
4541                },
4542                AhbFieldRule {
4543                    segment_path: "SG2/NAD/3035".to_string(),
4544                    name: "Empfaenger".to_string(),
4545                    ahb_status: "X".to_string(),
4546                    codes: vec![AhbCodeRule {
4547                        value: "MR".to_string(),
4548                        description: "Empfaenger".to_string(),
4549                        ahb_status: "X".to_string(),
4550                    }],
4551                    parent_group_ahb_status: None,
4552                    segment_ahb_status: None,
4553                    ..Default::default()
4554                },
4555                AhbFieldRule {
4556                    segment_path: "SG4/SG12/NAD/3035".to_string(),
4557                    name: "Anschlussnutzer".to_string(),
4558                    ahb_status: "X".to_string(),
4559                    codes: vec![AhbCodeRule {
4560                        value: "Z04".to_string(),
4561                        description: "Anschlussnutzer".to_string(),
4562                        ahb_status: "X".to_string(),
4563                    }],
4564                    parent_group_ahb_status: None,
4565                    segment_ahb_status: None,
4566                    ..Default::default()
4567                },
4568                AhbFieldRule {
4569                    segment_path: "SG4/SG12/NAD/3035".to_string(),
4570                    name: "Korrespondenzanschrift".to_string(),
4571                    ahb_status: "X".to_string(),
4572                    codes: vec![AhbCodeRule {
4573                        value: "Z09".to_string(),
4574                        description: "Korrespondenzanschrift".to_string(),
4575                        ahb_status: "X".to_string(),
4576                    }],
4577                    parent_group_ahb_status: None,
4578                    segment_ahb_status: None,
4579                    ..Default::default()
4580                },
4581            ],
4582            ub_definitions: BTreeMap::new(),
4583            package_conditions: Default::default(),
4584        };
4585
4586        // All segments flat (for condition evaluation), navigator provides group scope.
4587        let all_segments = vec![
4588            OwnedSegment {
4589                id: "NAD".into(),
4590                elements: vec![vec!["MS".into()]],
4591                segment_number: 3,
4592            },
4593            OwnedSegment {
4594                id: "NAD".into(),
4595                elements: vec![vec!["MT".into()]],
4596                segment_number: 4,
4597            },
4598            OwnedSegment {
4599                id: "NAD".into(),
4600                elements: vec![vec!["Z04".into()]],
4601                segment_number: 20,
4602            },
4603            OwnedSegment {
4604                id: "NAD".into(),
4605                elements: vec![vec!["Z09".into()]],
4606                segment_number: 21,
4607            },
4608        ];
4609
4610        let report = validator.validate_with_navigator(
4611            &all_segments,
4612            &workflow,
4613            &external,
4614            ValidationLevel::Conditions,
4615            &nav,
4616        );
4617
4618        let code_errors: Vec<_> = report
4619            .by_category(ValidationCategory::Code)
4620            .filter(|i| i.severity == Severity::Error)
4621            .collect();
4622
4623        // Only one error: MT in SG2 (not allowed in {MS, MR}).
4624        // Z04 and Z09 are NOT checked against {MS, MR} — group-scoped.
4625        assert_eq!(
4626            code_errors.len(),
4627            1,
4628            "Expected exactly one COD002 error for MT in SG2, got: {:?}",
4629            code_errors
4630        );
4631        assert!(narrate(code_errors[0]).contains("MT"));
4632        // Error should show only SG2's allowed codes, not the full union
4633        assert!(narrate(code_errors[0]).contains("MR"));
4634        assert!(narrate(code_errors[0]).contains("MS"));
4635        assert!(
4636            !narrate(code_errors[0]).contains("Z04"),
4637            "SG4/SG12 codes should not leak into SG2 error"
4638        );
4639        // Field path should include the group
4640        assert!(
4641            code_errors[0]
4642                .field_path
4643                .as_deref()
4644                .unwrap_or("")
4645                .contains("SG2"),
4646            "Error field_path should reference SG2, got: {:?}",
4647            code_errors[0].field_path
4648        );
4649    }
4650
4651    // === Package cardinality tests ===
4652
4653    #[test]
4654    fn test_package_cardinality_within_bounds() {
4655        // 1 code present from package [4P0..1], max=1 -> OK
4656        let evaluator = MockEvaluator::all_true(&[]);
4657        let validator = EdifactValidator::new(evaluator);
4658        let external = NoOpExternalProvider;
4659
4660        let segments = vec![OwnedSegment {
4661            id: "STS".into(),
4662            elements: vec![
4663                vec!["Z33".into()], // element[0]
4664                vec![],             // element[1]
4665                vec!["E01".into()], // element[2], component[0]
4666            ],
4667            segment_number: 5,
4668        }];
4669
4670        let workflow = AhbWorkflow {
4671            pruefidentifikator: "13017".to_string(),
4672            description: "Test".to_string(),
4673            communication_direction: None,
4674            ub_definitions: BTreeMap::new(),
4675            package_conditions: Default::default(),
4676            fields: vec![AhbFieldRule {
4677                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4678                name: "Statusanlaß, Code".to_string(),
4679                ahb_status: "X".to_string(),
4680                element_index: Some(2),
4681                component_index: Some(0),
4682                codes: vec![
4683                    AhbCodeRule {
4684                        value: "E01".into(),
4685                        description: "Code 1".into(),
4686                        ahb_status: "X [4P0..1]".into(),
4687                    },
4688                    AhbCodeRule {
4689                        value: "E02".into(),
4690                        description: "Code 2".into(),
4691                        ahb_status: "X [4P0..1]".into(),
4692                    },
4693                ],
4694                parent_group_ahb_status: Some("Muss".to_string()),
4695                segment_ahb_status: None,
4696                mig_number: None,
4697                format: None,
4698            }],
4699        };
4700
4701        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
4702        let pkg_errors: Vec<_> = report
4703            .by_category(ValidationCategory::Ahb)
4704            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4705            .collect();
4706        assert!(
4707            pkg_errors.is_empty(),
4708            "1 code within [4P0..1] bounds — no error expected, got: {:?}",
4709            pkg_errors
4710        );
4711    }
4712
4713    #[test]
4714    fn test_package_cardinality_zero_present_min_zero() {
4715        // No codes from the package present, min=0 -> OK
4716        let evaluator = MockEvaluator::all_true(&[]);
4717        let validator = EdifactValidator::new(evaluator);
4718        let external = NoOpExternalProvider;
4719
4720        let segments = vec![OwnedSegment {
4721            id: "STS".into(),
4722            elements: vec![
4723                vec!["Z33".into()],
4724                vec![],
4725                vec!["X99".into()], // X99 not in package
4726            ],
4727            segment_number: 5,
4728        }];
4729
4730        let workflow = AhbWorkflow {
4731            pruefidentifikator: "13017".to_string(),
4732            description: "Test".to_string(),
4733            communication_direction: None,
4734            ub_definitions: BTreeMap::new(),
4735            package_conditions: Default::default(),
4736            fields: vec![AhbFieldRule {
4737                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4738                name: "Statusanlaß, Code".to_string(),
4739                ahb_status: "X".to_string(),
4740                element_index: Some(2),
4741                component_index: Some(0),
4742                codes: vec![
4743                    AhbCodeRule {
4744                        value: "E01".into(),
4745                        description: "Code 1".into(),
4746                        ahb_status: "X [4P0..1]".into(),
4747                    },
4748                    AhbCodeRule {
4749                        value: "E02".into(),
4750                        description: "Code 2".into(),
4751                        ahb_status: "X [4P0..1]".into(),
4752                    },
4753                ],
4754                parent_group_ahb_status: Some("Muss".to_string()),
4755                segment_ahb_status: None,
4756                mig_number: None,
4757                format: None,
4758            }],
4759        };
4760
4761        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
4762        let pkg_errors: Vec<_> = report
4763            .by_category(ValidationCategory::Ahb)
4764            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4765            .collect();
4766        assert!(
4767            pkg_errors.is_empty(),
4768            "0 codes, min=0 — no error expected, got: {:?}",
4769            pkg_errors
4770        );
4771    }
4772
4773    #[test]
4774    fn test_package_cardinality_too_many() {
4775        // 2 codes present from package [4P0..1], max=1 -> ERROR
4776        let evaluator = MockEvaluator::all_true(&[]);
4777        let validator = EdifactValidator::new(evaluator);
4778        let external = NoOpExternalProvider;
4779
4780        // Two STS segments, each with a different package code
4781        let segments = vec![
4782            OwnedSegment {
4783                id: "STS".into(),
4784                elements: vec![vec!["Z33".into()], vec![], vec!["E01".into()]],
4785                segment_number: 5,
4786            },
4787            OwnedSegment {
4788                id: "STS".into(),
4789                elements: vec![vec!["Z33".into()], vec![], vec!["E02".into()]],
4790                segment_number: 6,
4791            },
4792        ];
4793
4794        let workflow = AhbWorkflow {
4795            pruefidentifikator: "13017".to_string(),
4796            description: "Test".to_string(),
4797            communication_direction: None,
4798            ub_definitions: BTreeMap::new(),
4799            package_conditions: Default::default(),
4800            fields: vec![AhbFieldRule {
4801                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4802                name: "Statusanlaß, Code".to_string(),
4803                ahb_status: "X".to_string(),
4804                element_index: Some(2),
4805                component_index: Some(0),
4806                codes: vec![
4807                    AhbCodeRule {
4808                        value: "E01".into(),
4809                        description: "Code 1".into(),
4810                        ahb_status: "X [4P0..1]".into(),
4811                    },
4812                    AhbCodeRule {
4813                        value: "E02".into(),
4814                        description: "Code 2".into(),
4815                        ahb_status: "X [4P0..1]".into(),
4816                    },
4817                ],
4818                parent_group_ahb_status: Some("Muss".to_string()),
4819                segment_ahb_status: None,
4820                mig_number: None,
4821                format: None,
4822            }],
4823        };
4824
4825        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
4826        let pkg_errors: Vec<_> = report
4827            .by_category(ValidationCategory::Ahb)
4828            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4829            .collect();
4830        assert_eq!(
4831            pkg_errors.len(),
4832            1,
4833            "2 codes present, max=1 — expected 1 error, got: {:?}",
4834            pkg_errors
4835        );
4836        assert!(narrate(pkg_errors[0]).contains("[4P0..1]"));
4837        assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("2"));
4838        assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("0..1"));
4839    }
4840
4841    #[test]
4842    fn test_package_cardinality_too_few() {
4843        // 0 codes present from package [5P1..3], min=1 -> ERROR
4844        let evaluator = MockEvaluator::all_true(&[]);
4845        let validator = EdifactValidator::new(evaluator);
4846        let external = NoOpExternalProvider;
4847
4848        let segments = vec![OwnedSegment {
4849            id: "STS".into(),
4850            elements: vec![
4851                vec!["Z33".into()],
4852                vec![],
4853                vec!["X99".into()], // not in package
4854            ],
4855            segment_number: 5,
4856        }];
4857
4858        let workflow = AhbWorkflow {
4859            pruefidentifikator: "13017".to_string(),
4860            description: "Test".to_string(),
4861            communication_direction: None,
4862            ub_definitions: BTreeMap::new(),
4863            package_conditions: Default::default(),
4864            fields: vec![AhbFieldRule {
4865                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4866                name: "Statusanlaß, Code".to_string(),
4867                ahb_status: "X".to_string(),
4868                element_index: Some(2),
4869                component_index: Some(0),
4870                codes: vec![
4871                    AhbCodeRule {
4872                        value: "E01".into(),
4873                        description: "Code 1".into(),
4874                        ahb_status: "X [5P1..3]".into(),
4875                    },
4876                    AhbCodeRule {
4877                        value: "E02".into(),
4878                        description: "Code 2".into(),
4879                        ahb_status: "X [5P1..3]".into(),
4880                    },
4881                    AhbCodeRule {
4882                        value: "E03".into(),
4883                        description: "Code 3".into(),
4884                        ahb_status: "X [5P1..3]".into(),
4885                    },
4886                ],
4887                parent_group_ahb_status: Some("Muss".to_string()),
4888                segment_ahb_status: None,
4889                mig_number: None,
4890                format: None,
4891            }],
4892        };
4893
4894        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
4895        let pkg_errors: Vec<_> = report
4896            .by_category(ValidationCategory::Ahb)
4897            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4898            .collect();
4899        assert_eq!(
4900            pkg_errors.len(),
4901            1,
4902            "0 codes present, min=1 — expected 1 error, got: {:?}",
4903            pkg_errors
4904        );
4905        assert!(narrate(pkg_errors[0]).contains("[5P1..3]"));
4906        assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("0"));
4907        assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("1..3"));
4908    }
4909
4910    #[test]
4911    fn test_package_cardinality_no_packages_in_workflow() {
4912        // Codes without package annotations -> no package errors
4913        let evaluator = MockEvaluator::all_true(&[]);
4914        let validator = EdifactValidator::new(evaluator);
4915        let external = NoOpExternalProvider;
4916
4917        let segments = vec![OwnedSegment {
4918            id: "STS".into(),
4919            elements: vec![vec!["E01".into()]],
4920            segment_number: 5,
4921        }];
4922
4923        let workflow = AhbWorkflow {
4924            pruefidentifikator: "13017".to_string(),
4925            description: "Test".to_string(),
4926            communication_direction: None,
4927            ub_definitions: BTreeMap::new(),
4928            package_conditions: Default::default(),
4929            fields: vec![AhbFieldRule {
4930                segment_path: "STS/9015".to_string(),
4931                name: "Status Code".to_string(),
4932                ahb_status: "X".to_string(),
4933                codes: vec![AhbCodeRule {
4934                    value: "E01".into(),
4935                    description: "Code 1".into(),
4936                    ahb_status: "X".into(),
4937                }],
4938                parent_group_ahb_status: Some("Muss".to_string()),
4939                segment_ahb_status: None,
4940                ..Default::default()
4941            }],
4942        };
4943
4944        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
4945        let pkg_errors: Vec<_> = report
4946            .by_category(ValidationCategory::Ahb)
4947            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4948            .collect();
4949        assert!(
4950            pkg_errors.is_empty(),
4951            "No packages in workflow — no errors expected"
4952        );
4953    }
4954
4955    #[test]
4956    fn test_package_cardinality_with_condition_and_package() {
4957        // Code status "X [901] [4P0..1]" has both condition and package
4958        let evaluator = MockEvaluator::all_true(&[901]);
4959        let validator = EdifactValidator::new(evaluator);
4960        let external = NoOpExternalProvider;
4961
4962        let segments = vec![OwnedSegment {
4963            id: "STS".into(),
4964            elements: vec![vec![], vec![], vec!["E01".into()]],
4965            segment_number: 5,
4966        }];
4967
4968        let workflow = AhbWorkflow {
4969            pruefidentifikator: "13017".to_string(),
4970            description: "Test".to_string(),
4971            communication_direction: None,
4972            ub_definitions: BTreeMap::new(),
4973            package_conditions: Default::default(),
4974            fields: vec![AhbFieldRule {
4975                segment_path: "SG10/STS/C556/9013".to_string(),
4976                name: "Code".to_string(),
4977                ahb_status: "X".to_string(),
4978                element_index: Some(2),
4979                component_index: Some(0),
4980                codes: vec![
4981                    AhbCodeRule {
4982                        value: "E01".into(),
4983                        description: "Code 1".into(),
4984                        ahb_status: "X [901] [4P0..1]".into(),
4985                    },
4986                    AhbCodeRule {
4987                        value: "E02".into(),
4988                        description: "Code 2".into(),
4989                        ahb_status: "X [901] [4P0..1]".into(),
4990                    },
4991                ],
4992                parent_group_ahb_status: Some("Muss".to_string()),
4993                segment_ahb_status: None,
4994                mig_number: None,
4995                format: None,
4996            }],
4997        };
4998
4999        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
5000        let pkg_errors: Vec<_> = report
5001            .by_category(ValidationCategory::Ahb)
5002            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5003            .collect();
5004        assert!(
5005            pkg_errors.is_empty(),
5006            "1 code within [4P0..1] bounds — no error, got: {:?}",
5007            pkg_errors
5008        );
5009    }
5010
5011    #[test]
5012    fn test_package_binds_only_where_its_condition_holds() {
5013        // `[5P1..1]` with the package condition `[902]` (the AHB's
5014        // `<Paket Nummer="[5P]">[902]</Paket>`): no code of the package is
5015        // sent, which is a violation only where [902] holds.
5016        let report_for = |package_condition: CR| {
5017            let validator =
5018                EdifactValidator::new(MockEvaluator::new(vec![(902, package_condition)]));
5019            let segments = vec![OwnedSegment {
5020                id: "STS".into(),
5021                elements: vec![vec![], vec![], vec!["E09".into()]],
5022                segment_number: 5,
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: BTreeMap::from([(5, ConditionExpr::Ref(902))]),
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 [5P1..1]".into(),
5041                        },
5042                        AhbCodeRule {
5043                            value: "E09".into(),
5044                            description: "Code 9".into(),
5045                            ahb_status: "X".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            validator
5055                .validate(
5056                    &segments,
5057                    &workflow,
5058                    &NoOpExternalProvider,
5059                    ValidationLevel::Full,
5060                )
5061                .by_category(ValidationCategory::Ahb)
5062                .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5063                .count()
5064        };
5065        assert_eq!(report_for(CR::False), 0, "[902] false: [5P] does not bind");
5066        assert_eq!(report_for(CR::True), 1, "[902] true: [5P1..1] binds");
5067    }
5068
5069    #[test]
5070    fn test_package_cardinality_scoped_per_group_instance() {
5071        // Package [4P0..1] caps the number of codes per SG10 group instance at 0..1.
5072        // MSCONS PID 13025 messages contain many SG10 reps (one per interval); each
5073        // rep has its own STS+Z32++<reason> segment using one code from the package.
5074        // The old global count summed codes across reps and flagged every
5075        // multi-interval message (e.g. 96 intervals → 96 codes "allowed 0..1").
5076        // With a navigator present, package cardinality must be evaluated per
5077        // group instance, not message-wide.
5078        use mig_types::navigator::GroupNavigator;
5079
5080        struct TwoSg10s {
5081            sts_a: OwnedSegment,
5082            sts_b: OwnedSegment,
5083        }
5084        impl GroupNavigator for TwoSg10s {
5085            fn find_segments_in_group(
5086                &self,
5087                segment_id: &str,
5088                group_path: &[&str],
5089                instance_index: usize,
5090            ) -> Vec<OwnedSegment> {
5091                if group_path == ["SG5", "SG6", "SG9", "SG10"] && segment_id == "STS" {
5092                    match instance_index {
5093                        0 => vec![self.sts_a.clone()],
5094                        1 => vec![self.sts_b.clone()],
5095                        _ => vec![],
5096                    }
5097                } else {
5098                    vec![]
5099                }
5100            }
5101            fn find_segments_with_qualifier_in_group(
5102                &self,
5103                _: &str,
5104                _: usize,
5105                _: &str,
5106                _: &[&str],
5107                _: usize,
5108            ) -> Vec<OwnedSegment> {
5109                vec![]
5110            }
5111            fn group_instance_count(&self, group_path: &[&str]) -> usize {
5112                match group_path {
5113                    ["SG5"] | ["SG5", "SG6"] | ["SG5", "SG6", "SG9"] => 1,
5114                    ["SG5", "SG6", "SG9", "SG10"] => 2,
5115                    _ => 0,
5116                }
5117            }
5118        }
5119
5120        let sts_a = OwnedSegment {
5121            id: "STS".into(),
5122            elements: vec![vec!["Z32".into()], vec![], vec!["E01".into()]],
5123            segment_number: 10,
5124        };
5125        let sts_b = OwnedSegment {
5126            id: "STS".into(),
5127            elements: vec![vec!["Z32".into()], vec![], vec!["E01".into()]],
5128            segment_number: 15,
5129        };
5130        let nav = TwoSg10s {
5131            sts_a: sts_a.clone(),
5132            sts_b: sts_b.clone(),
5133        };
5134
5135        let evaluator = MockEvaluator::all_true(&[]);
5136        let validator = EdifactValidator::new(evaluator);
5137        let external = NoOpExternalProvider;
5138
5139        let workflow = AhbWorkflow {
5140            pruefidentifikator: "13025".to_string(),
5141            description: "Test".to_string(),
5142            communication_direction: None,
5143            ub_definitions: BTreeMap::new(),
5144            package_conditions: Default::default(),
5145            fields: vec![AhbFieldRule {
5146                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
5147                name: "Statusanlaß, Code".to_string(),
5148                ahb_status: "X".to_string(),
5149                element_index: Some(2),
5150                component_index: Some(0),
5151                codes: vec![
5152                    AhbCodeRule {
5153                        value: "E01".into(),
5154                        description: "Code 1".into(),
5155                        ahb_status: "X [4P0..1]".into(),
5156                    },
5157                    AhbCodeRule {
5158                        value: "E02".into(),
5159                        description: "Code 2".into(),
5160                        ahb_status: "X [4P0..1]".into(),
5161                    },
5162                ],
5163                parent_group_ahb_status: Some("Muss".to_string()),
5164                segment_ahb_status: None,
5165                mig_number: None,
5166                format: None,
5167            }],
5168        };
5169
5170        let report = validator.validate_with_navigator(
5171            &[sts_a, sts_b],
5172            &workflow,
5173            &external,
5174            ValidationLevel::Full,
5175            &nav,
5176        );
5177        let pkg_errors: Vec<_> = report
5178            .by_category(ValidationCategory::Ahb)
5179            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5180            .collect();
5181        assert!(
5182            pkg_errors.is_empty(),
5183            "Package cardinality is per-instance: 1 code per SG10 rep is within [4P0..1]. Got: {:?}",
5184            pkg_errors
5185        );
5186    }
5187
5188    fn make_segment(id: &str, elements: Vec<Vec<&str>>) -> OwnedSegment {
5189        OwnedSegment {
5190            id: id.to_string(),
5191            elements: elements
5192                .into_iter()
5193                .map(|e| e.into_iter().map(|s| s.to_string()).collect())
5194                .collect(),
5195            segment_number: 0,
5196        }
5197    }
5198
5199    /// Package cardinality must not fire against sibling group variants.
5200    ///
5201    /// PID 55218 has two SG8 variants: `sg8_z01` (SEQ mig=00115, no
5202    /// package) and `sg8_z45_z84` (SEQ mig=00171, with `[1P1..n]`
5203    /// package on Z45/Z84). The rule is scoped to mig=00171, but the
5204    /// old per-instance loop counted codes across every SG8 instance
5205    /// (including the Z01 sibling) and fired "0 codes present" on
5206    /// instances belonging to the other variant. With
5207    /// `instance_has_mig_number` the loop skips instances whose variant
5208    /// doesn't define the rule's mig_number.
5209    #[test]
5210    fn test_package_cardinality_scoped_to_rule_mig_variant() {
5211        use mig_types::navigator::GroupNavigator;
5212
5213        struct TwoSg8Variants {
5214            seq_z01: OwnedSegment,
5215            seq_z45: OwnedSegment,
5216        }
5217        impl GroupNavigator for TwoSg8Variants {
5218            fn find_segments_in_group(
5219                &self,
5220                segment_id: &str,
5221                group_path: &[&str],
5222                instance_index: usize,
5223            ) -> Vec<OwnedSegment> {
5224                if group_path == ["SG4", "SG8"] && segment_id == "SEQ" {
5225                    match instance_index {
5226                        0 => vec![self.seq_z01.clone()],
5227                        1 => vec![self.seq_z45.clone()],
5228                        _ => vec![],
5229                    }
5230                } else {
5231                    vec![]
5232                }
5233            }
5234            fn find_segments_with_qualifier_in_group(
5235                &self,
5236                _: &str,
5237                _: usize,
5238                _: &str,
5239                _: &[&str],
5240                _: usize,
5241            ) -> Vec<OwnedSegment> {
5242                vec![]
5243            }
5244            fn group_instance_count(&self, group_path: &[&str]) -> usize {
5245                match group_path {
5246                    ["SG4"] => 1,
5247                    ["SG4", "SG8"] => 2,
5248                    _ => 0,
5249                }
5250            }
5251            fn instance_has_mig_number(
5252                &self,
5253                group_path: &[&str],
5254                instance_index: usize,
5255                mig_number: &str,
5256            ) -> bool {
5257                if group_path != ["SG4", "SG8"] {
5258                    return true;
5259                }
5260                match (instance_index, mig_number) {
5261                    (0, "00115") => true,
5262                    (0, _) => false,
5263                    (1, "00171") => true,
5264                    (1, _) => false,
5265                    _ => false,
5266                }
5267            }
5268        }
5269
5270        let seq_z01 = OwnedSegment {
5271            id: "SEQ".into(),
5272            elements: vec![vec!["Z01".into()], vec!["1".into()]],
5273            segment_number: 10,
5274        };
5275        let seq_z45 = OwnedSegment {
5276            id: "SEQ".into(),
5277            elements: vec![vec!["Z45".into()], vec!["1".into()]],
5278            segment_number: 20,
5279        };
5280        let nav = TwoSg8Variants {
5281            seq_z01: seq_z01.clone(),
5282            seq_z45: seq_z45.clone(),
5283        };
5284
5285        let evaluator = MockEvaluator::all_true(&[]);
5286        let validator = EdifactValidator::new(evaluator);
5287        let external = NoOpExternalProvider;
5288
5289        let workflow = AhbWorkflow {
5290            pruefidentifikator: "55218".to_string(),
5291            description: "Test".to_string(),
5292            communication_direction: None,
5293            ub_definitions: BTreeMap::new(),
5294            package_conditions: Default::default(),
5295            fields: vec![AhbFieldRule {
5296                segment_path: "SG4/SG8/SEQ/1229".to_string(),
5297                name: "Handlung, Code".to_string(),
5298                ahb_status: "X".to_string(),
5299                element_index: Some(0),
5300                component_index: Some(0),
5301                codes: vec![
5302                    AhbCodeRule {
5303                        value: "Z45".into(),
5304                        description: "NNA".into(),
5305                        ahb_status: "X [1P1..4294967295]".into(),
5306                    },
5307                    AhbCodeRule {
5308                        value: "Z84".into(),
5309                        description: "Differenz-NNA".into(),
5310                        ahb_status: "X [1P0..4294967295]".into(),
5311                    },
5312                ],
5313                parent_group_ahb_status: Some("Muss".to_string()),
5314                segment_ahb_status: None,
5315                mig_number: Some("00171".to_string()),
5316                format: None,
5317            }],
5318        };
5319
5320        let report = validator.validate_with_navigator(
5321            &[seq_z01, seq_z45],
5322            &workflow,
5323            &external,
5324            ValidationLevel::Full,
5325            &nav,
5326        );
5327        let pkg_errors: Vec<_> = report
5328            .by_category(ValidationCategory::Ahb)
5329            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
5330            .collect();
5331        assert!(
5332            pkg_errors.is_empty(),
5333            "Package rule with mig=00171 must only count the Z45 instance (which has 1 code), not the sibling Z01 variant. Got: {:?}",
5334            pkg_errors
5335        );
5336    }
5337
5338    #[test]
5339    fn test_unt_count_correct() {
5340        // UNH + BGM + DTM + UNT = 4 segments, UNT declares 4
5341        let segments = vec![
5342            make_segment("UNH", vec![vec!["001"]]),
5343            make_segment("BGM", vec![vec!["E01"]]),
5344            make_segment("DTM", vec![vec!["137", "20250401"]]),
5345            make_segment("UNT", vec![vec!["4", "001"]]),
5346        ];
5347        assert!(
5348            validate_unt_segment_count(&segments).is_none(),
5349            "Correct count should produce no issue"
5350        );
5351    }
5352
5353    #[test]
5354    fn test_unt_count_mismatch() {
5355        // UNH + BGM + UNT = 3 segments, but UNT declares 5
5356        let segments = vec![
5357            make_segment("UNH", vec![vec!["001"]]),
5358            make_segment("BGM", vec![vec!["E01"]]),
5359            make_segment("UNT", vec![vec!["5", "001"]]),
5360        ];
5361        let issue =
5362            validate_unt_segment_count(&segments).expect("Mismatch should produce an issue");
5363        assert_eq!(issue.code(), ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH);
5364        assert_eq!(issue.severity, Severity::Error);
5365        assert!(narrate(&issue).contains("declared 5"));
5366        assert!(narrate(&issue).contains("actual 3"));
5367    }
5368
5369    #[test]
5370    fn test_unt_count_excludes_envelope() {
5371        // Envelope segments (UNA, UNB, UNZ) should not be counted
5372        let segments = vec![
5373            make_segment("UNA", vec![]),
5374            make_segment("UNB", vec![vec!["UNOC", "3"]]),
5375            make_segment("UNH", vec![vec!["001"]]),
5376            make_segment("BGM", vec![vec!["E01"]]),
5377            make_segment("UNT", vec![vec!["3", "001"]]),
5378            make_segment("UNZ", vec![vec!["1"]]),
5379        ];
5380        assert!(
5381            validate_unt_segment_count(&segments).is_none(),
5382            "Envelope segments excluded — count should be 3 (UNH+BGM+UNT)"
5383        );
5384    }
5385
5386    #[test]
5387    fn test_unt_count_no_unt_returns_none() {
5388        let segments = vec![
5389            make_segment("UNH", vec![vec!["001"]]),
5390            make_segment("BGM", vec![vec!["E01"]]),
5391        ];
5392        assert!(
5393            validate_unt_segment_count(&segments).is_none(),
5394            "No UNT segment should return None (not our problem)"
5395        );
5396    }
5397
5398    #[test]
5399    fn test_unt_count_rejects_multi_message_input() {
5400        // Two messages spliced together — should return an error
5401        let segments = vec![
5402            make_segment("UNH", vec![vec!["001"]]),
5403            make_segment("BGM", vec![vec!["E01"]]),
5404            make_segment("UNT", vec![vec!["3", "001"]]),
5405            make_segment("UNH", vec![vec!["002"]]),
5406            make_segment("BGM", vec![vec!["E02"]]),
5407            make_segment("UNT", vec![vec!["3", "002"]]),
5408        ];
5409        let issue = validate_unt_segment_count(&segments)
5410            .expect("Multi-message input should produce an error");
5411        assert_eq!(issue.code(), ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH);
5412        assert!(
5413            narrate(&issue).contains("2 UNH"),
5414            "Should mention UNH count: {}",
5415            narrate(&issue)
5416        );
5417    }
5418
5419    #[test]
5420    fn test_code_validation_accepts_multi_code_variant_qualifier() {
5421        // PID 55035 SG4/SG8 has many RFF variants — one per mig_number. The
5422        // variant with mig=00075 has TWO required codes (Z31, Z39): both are
5423        // valid qualifier values for that variant. Prior behavior only treated
5424        // buckets with exactly one required code as qualifier buckets, so
5425        // segments with Z31/Z39 fell through and were compared against the
5426        // union of *other* variants' codes — producing a false COD002.
5427        let evaluator = MockEvaluator::new(vec![]);
5428        let validator = EdifactValidator::new(evaluator);
5429        let external = NoOpExternalProvider;
5430
5431        let rff_z39 = OwnedSegment {
5432            id: "RFF".to_string(),
5433            elements: vec![
5434                vec!["RFF".to_string()],
5435                vec!["Z39".to_string(), "REF1".to_string()],
5436            ],
5437            segment_number: 1,
5438        };
5439
5440        let workflow = AhbWorkflow {
5441            pruefidentifikator: "55035".to_string(),
5442            description: "Test".to_string(),
5443            communication_direction: None,
5444            fields: vec![
5445                // Mig 00075 variant: qualifier is Z31 or Z39 (multi-code bucket)
5446                AhbFieldRule {
5447                    segment_path: "SG4/SG8/RFF/C506/1153".to_string(),
5448                    name: "Referenznummer Qualifier".to_string(),
5449                    ahb_status: "Muss".to_string(),
5450                    codes: vec![
5451                        AhbCodeRule {
5452                            value: "Z31".to_string(),
5453                            description: "".to_string(),
5454                            ahb_status: "X".to_string(),
5455                        },
5456                        AhbCodeRule {
5457                            value: "Z39".to_string(),
5458                            description: "".to_string(),
5459                            ahb_status: "X".to_string(),
5460                        },
5461                    ],
5462                    parent_group_ahb_status: None,
5463                    segment_ahb_status: None,
5464                    element_index: Some(1),
5465                    component_index: Some(0),
5466                    mig_number: Some("00075".to_string()),
5467                    format: None,
5468                },
5469                // Mig 00078 variant: single-code qualifier Z33
5470                AhbFieldRule {
5471                    segment_path: "SG4/SG8/RFF/C506/1153".to_string(),
5472                    name: "Referenznummer Qualifier".to_string(),
5473                    ahb_status: "Muss".to_string(),
5474                    codes: vec![AhbCodeRule {
5475                        value: "Z33".to_string(),
5476                        description: "".to_string(),
5477                        ahb_status: "X".to_string(),
5478                    }],
5479                    parent_group_ahb_status: None,
5480                    segment_ahb_status: None,
5481                    element_index: Some(1),
5482                    component_index: Some(0),
5483                    mig_number: Some("00078".to_string()),
5484                    format: None,
5485                },
5486            ],
5487            ub_definitions: BTreeMap::new(),
5488            package_conditions: Default::default(),
5489        };
5490
5491        let report = validator.validate(
5492            &[rff_z39],
5493            &workflow,
5494            &external,
5495            ValidationLevel::Conditions,
5496        );
5497
5498        let code_errors: Vec<_> = report
5499            .by_category(ValidationCategory::Code)
5500            .filter(|i| {
5501                i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
5502            })
5503            .collect();
5504        assert!(
5505            code_errors.is_empty(),
5506            "RFF+Z39 must be accepted (Z39 is valid for mig=00075). Got: {:?}",
5507            code_errors
5508        );
5509    }
5510
5511    #[test]
5512    fn test_code_validation_disambiguates_migs_by_full_code_profile() {
5513        // PID 55035 SG4/SG8 PIA variants all share element 0 code '5' as
5514        // qualifier. They differ only at composite C212/7143 (one variant
5515        // allows Z12, another allows SRW). Matching on the first single-code
5516        // field alone picks an arbitrary bucket and then rejects the other
5517        // variant's composite value — a false COD002.
5518        let evaluator = MockEvaluator::new(vec![]);
5519        let validator = EdifactValidator::new(evaluator);
5520        let external = NoOpExternalProvider;
5521
5522        // Include BOTH variants so the current bug is triggered deterministically:
5523        // match_segment_to_mig picks one bucket by HashMap iteration order, so
5524        // exactly one of {Z12, SRW} is rejected under the current logic.
5525        let pia_5_z12 = OwnedSegment {
5526            id: "PIA".to_string(),
5527            elements: vec![vec!["5".to_string()], vec!["Z12".to_string()]],
5528            segment_number: 1,
5529        };
5530        let pia_5_srw = OwnedSegment {
5531            id: "PIA".to_string(),
5532            elements: vec![vec!["5".to_string()], vec!["SRW".to_string()]],
5533            segment_number: 2,
5534        };
5535
5536        let make_rules = |mig: &str, composite_code: &str| {
5537            vec![
5538                AhbFieldRule {
5539                    segment_path: "SG4/SG8/PIA/4347".to_string(),
5540                    name: "Produkt-ID-Funktion".to_string(),
5541                    ahb_status: "Muss".to_string(),
5542                    codes: vec![AhbCodeRule {
5543                        value: "5".to_string(),
5544                        description: "".to_string(),
5545                        ahb_status: "X".to_string(),
5546                    }],
5547                    parent_group_ahb_status: None,
5548                    segment_ahb_status: None,
5549                    element_index: Some(0),
5550                    component_index: Some(0),
5551                    mig_number: Some(mig.to_string()),
5552                    format: None,
5553                },
5554                AhbFieldRule {
5555                    segment_path: "SG4/SG8/PIA/C212/7143".to_string(),
5556                    name: "Artikel/Dienstleistung-Identifikator".to_string(),
5557                    ahb_status: "Muss".to_string(),
5558                    codes: vec![AhbCodeRule {
5559                        value: composite_code.to_string(),
5560                        description: "".to_string(),
5561                        ahb_status: "X".to_string(),
5562                    }],
5563                    parent_group_ahb_status: None,
5564                    segment_ahb_status: None,
5565                    element_index: Some(1),
5566                    component_index: Some(0),
5567                    mig_number: Some(mig.to_string()),
5568                    format: None,
5569                },
5570            ]
5571        };
5572
5573        let mut fields = make_rules("00108", "Z12");
5574        fields.extend(make_rules("00197", "SRW"));
5575
5576        let workflow = AhbWorkflow {
5577            pruefidentifikator: "55035".to_string(),
5578            description: "Test".to_string(),
5579            communication_direction: None,
5580            fields,
5581            ub_definitions: BTreeMap::new(),
5582            package_conditions: Default::default(),
5583        };
5584
5585        let report = validator.validate(
5586            &[pia_5_z12, pia_5_srw],
5587            &workflow,
5588            &external,
5589            ValidationLevel::Conditions,
5590        );
5591
5592        let code_errors: Vec<_> = report
5593            .by_category(ValidationCategory::Code)
5594            .filter(|i| {
5595                i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
5596            })
5597            .collect();
5598        assert!(
5599            code_errors.is_empty(),
5600            "Both PIA+5+Z12 (mig=00108) and PIA+5+SRW (mig=00197) must be accepted. Got: {:?}",
5601            code_errors
5602        );
5603    }
5604}