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