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