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