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