Skip to main content

automapper_validation/validator/
validate.rs

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