Skip to main content

automapper_validation/validator/
validate.rs

1//! Main EdifactValidator implementation.
2
3use std::collections::{HashMap, HashSet};
4
5use crate::expr::{ConditionExpr, ConditionParser};
6
7use crate::eval::{
8    ConditionEvaluator, ConditionExprEvaluator, ConditionResult, EvaluationContext,
9    ExternalConditionProvider,
10};
11use mig_types::navigator::GroupNavigator;
12use mig_types::segment::OwnedSegment;
13
14use super::tree::{AhbGroupNode, AhbNode, ValidatedTree};
15
16use super::codes::ErrorCodes;
17use super::issue::{Severity, ValidationCategory, ValidationIssue};
18use super::level::ValidationLevel;
19use super::report::ValidationReport;
20
21/// AHB field definition for validation.
22///
23/// Represents a single field in an AHB rule table with its status
24/// and allowed codes for a specific Pruefidentifikator.
25#[derive(Debug, Clone, Default)]
26pub struct AhbFieldRule {
27    /// Segment path (e.g., "SG2/NAD/C082/3039").
28    pub segment_path: String,
29
30    /// Human-readable field name (e.g., "MP-ID des MSB").
31    pub name: String,
32
33    /// AHB status (e.g., "Muss [182] ∧ [152]", "X", "Kann").
34    pub ahb_status: String,
35
36    /// Allowed code values with their AHB status.
37    pub codes: Vec<AhbCodeRule>,
38
39    /// AHB status of the innermost parent group (e.g., "Kann", "Muss", "Soll [46]").
40    ///
41    /// When the parent group is optional ("Kann") and its qualifier variant is
42    /// absent from the message, mandatory checks for child fields are skipped.
43    pub parent_group_ahb_status: Option<String>,
44
45    /// Element index within the segment (0-based). Used to locate the correct
46    /// element when checking presence and code values. `None` defaults to 0.
47    pub element_index: Option<usize>,
48
49    /// Component sub-index within a composite element (0-based). Used to locate
50    /// the correct component. `None` defaults to 0.
51    pub component_index: Option<usize>,
52
53    /// MIG `Number` attribute of the parent segment. Links this AHB field to
54    /// the corresponding `AssembledSegment::mig_number` for tree-based joining.
55    pub mig_number: Option<String>,
56}
57
58/// An allowed code value within an AHB field rule.
59#[derive(Debug, Clone, Default)]
60pub struct AhbCodeRule {
61    /// The code value (e.g., "E01", "Z33").
62    pub value: String,
63
64    /// Description of the code (e.g., "Anmeldung").
65    pub description: String,
66
67    /// AHB status for this code (e.g., "X", "Muss").
68    pub ahb_status: String,
69}
70
71/// AHB workflow definition for a specific Pruefidentifikator.
72#[derive(Debug, Clone)]
73pub struct AhbWorkflow {
74    /// The Pruefidentifikator (e.g., "11001", "55001").
75    pub pruefidentifikator: String,
76
77    /// Description of the workflow.
78    pub description: String,
79
80    /// Communication direction (e.g., "NB an LF").
81    pub communication_direction: Option<String>,
82
83    /// All field rules for this workflow.
84    pub fields: Vec<AhbFieldRule>,
85
86    /// UB (Unterbedingung) definitions parsed from the AHB XML.
87    ///
88    /// Maps UB IDs (e.g., "UB1") to their parsed condition expressions.
89    /// These are expanded inline when evaluating condition expressions
90    /// that reference UB conditions.
91    pub ub_definitions: HashMap<String, ConditionExpr>,
92}
93
94/// Validates EDIFACT messages against AHB business rules.
95///
96/// The validator is a pure validation engine: it receives pre-parsed
97/// segments, an AHB workflow, and an external condition provider.
98/// Parsing and message-type detection are the caller's responsibility.
99///
100/// The validator is generic over the `ConditionEvaluator` implementation,
101/// which is typically generated from AHB XML schemas.
102///
103/// # Example
104///
105/// ```ignore
106/// use automapper_validation::validator::EdifactValidator;
107/// use automapper_validation::eval::NoOpExternalProvider;
108///
109/// let evaluator = UtilmdConditionEvaluatorFV2510::new();
110/// let validator = EdifactValidator::new(evaluator);
111/// let external = NoOpExternalProvider;
112///
113/// let report = validator.validate(
114///     &segments,
115///     &ahb_workflow,
116///     &external,
117///     ValidationLevel::Full,
118/// );
119///
120/// if !report.is_valid() {
121///     for error in report.errors() {
122///         eprintln!("{error}");
123///     }
124/// }
125/// ```
126pub struct EdifactValidator<E: ConditionEvaluator> {
127    evaluator: E,
128}
129
130impl<E: ConditionEvaluator> EdifactValidator<E> {
131    /// Create a new validator with the given condition evaluator.
132    pub fn new(evaluator: E) -> Self {
133        Self { evaluator }
134    }
135
136    /// Validate pre-parsed EDIFACT segments against an AHB workflow.
137    ///
138    /// # Arguments
139    ///
140    /// * `segments` - Pre-parsed EDIFACT segments
141    /// * `workflow` - AHB workflow definition for the PID
142    /// * `external` - Provider for external conditions
143    /// * `level` - Validation strictness level
144    ///
145    /// # Returns
146    ///
147    /// A `ValidationReport` with all issues found.
148    pub fn validate(
149        &self,
150        segments: &[OwnedSegment],
151        workflow: &AhbWorkflow,
152        external: &dyn ExternalConditionProvider,
153        level: ValidationLevel,
154    ) -> ValidationReport {
155        let mut report = ValidationReport::new(self.evaluator.message_type(), level)
156            .with_format_version(self.evaluator.format_version())
157            .with_pruefidentifikator(&workflow.pruefidentifikator);
158
159        let ctx = EvaluationContext::new(&workflow.pruefidentifikator, external, segments);
160
161        if matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
162            self.validate_conditions(workflow, &ctx, &mut report);
163        }
164
165        report
166    }
167
168    /// Validate with a group navigator for group-scoped condition queries.
169    ///
170    /// Same as [`validate`] but passes a `GroupNavigator` to the
171    /// `EvaluationContext`, enabling conditions to query segments within
172    /// specific group instances (e.g., "in derselben SG8").
173    pub fn validate_with_navigator(
174        &self,
175        segments: &[OwnedSegment],
176        workflow: &AhbWorkflow,
177        external: &dyn ExternalConditionProvider,
178        level: ValidationLevel,
179        navigator: &dyn GroupNavigator,
180    ) -> ValidationReport {
181        let mut report = ValidationReport::new(self.evaluator.message_type(), level)
182            .with_format_version(self.evaluator.format_version())
183            .with_pruefidentifikator(&workflow.pruefidentifikator);
184
185        let ctx = EvaluationContext::with_navigator(
186            &workflow.pruefidentifikator,
187            external,
188            segments,
189            navigator,
190        );
191
192        if matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
193            self.validate_conditions(workflow, &ctx, &mut report);
194        }
195
196        report
197    }
198
199    /// Validate using a pre-built ValidatedTree where each node carries
200    /// its resolved EDIFACT value.
201    ///
202    /// This is the preferred validation path -- conditions see `ctx.resolved_value`
203    /// set from the tree node, so format conditions like [931] "ZZZ=+00" check
204    /// the correct segment instance (e.g., DTM+92, not DTM+137).
205    pub fn validate_tree(
206        &self,
207        validated_tree: &ValidatedTree,
208        segments: &[OwnedSegment],
209        external: &dyn ExternalConditionProvider,
210        level: ValidationLevel,
211        navigator: Option<&dyn GroupNavigator>,
212    ) -> ValidationReport {
213        let mut report = ValidationReport::new(self.evaluator.message_type(), level)
214            .with_format_version(self.evaluator.format_version())
215            .with_pruefidentifikator(validated_tree.pruefidentifikator);
216
217        if !matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
218            return report;
219        }
220
221        let ctx = match navigator {
222            Some(nav) => EvaluationContext::with_navigator(
223                validated_tree.pruefidentifikator,
224                external,
225                segments,
226                nav,
227            ),
228            None => EvaluationContext::new(
229                validated_tree.pruefidentifikator,
230                external,
231                segments,
232            ),
233        };
234
235        let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
236
237        // Collect all nodes depth-first from the tree.
238        let mut all_nodes: Vec<&AhbNode> = Vec::new();
239        all_nodes.extend(validated_tree.root_fields.iter());
240        for group in &validated_tree.groups {
241            collect_nodes_depth_first(group, &mut all_nodes);
242        }
243
244        // Evaluate each node.
245        for node in &all_nodes {
246            let field = node.rule;
247
248            // Create a field-scoped context with resolved value from the tree node.
249            let node_ctx = ctx.with_resolved(node.value, node.segment_elements);
250
251            // Skip if parent group condition is not met (False or Unknown).
252            if should_skip_for_parent_group(field, &expr_eval, &ctx, validated_tree.ub_definitions)
253            {
254                continue;
255            }
256
257            // Evaluate the field's AHB status condition using the node context
258            // (WITH resolved_value set).
259            let (condition_result, unknown_ids) = expr_eval.evaluate_status_detailed_with_ub(
260                &field.ahb_status,
261                &node_ctx,
262                validated_tree.ub_definitions,
263            );
264
265            match condition_result {
266                ConditionResult::True => {
267                    // Condition is met -- field is required/applicable.
268                    // Use node.value.is_some() instead of is_field_present()
269                    // because the tree already resolved the value.
270                    if is_mandatory_status(&field.ahb_status) && node.value.is_none() {
271                        let mut issue = ValidationIssue::new(
272                            Severity::Error,
273                            ValidationCategory::Ahb,
274                            ErrorCodes::MISSING_REQUIRED_FIELD,
275                            format!(
276                                "Required field '{}' at {} is missing",
277                                field.name, field.segment_path
278                            ),
279                        )
280                        .with_field_path(&field.segment_path)
281                        .with_rule(&field.ahb_status);
282                        if let Some(first_code) = field.codes.first() {
283                            issue.expected_value = Some(first_code.value.clone());
284                        }
285                        report.add_issue(issue);
286                    }
287                }
288                ConditionResult::False => {
289                    // Condition not met. If mandatory AND value is present,
290                    // the value violates the condition constraint.
291                    if is_mandatory_status(&field.ahb_status) && node.value.is_some() {
292                        report.add_issue(
293                            ValidationIssue::new(
294                                Severity::Error,
295                                ValidationCategory::Ahb,
296                                ErrorCodes::CONDITIONAL_RULE_VIOLATION,
297                                format!(
298                                    "Field '{}' at {} is present but does not satisfy condition: {}",
299                                    field.name, field.segment_path, field.ahb_status
300                                ),
301                            )
302                            .with_field_path(&field.segment_path)
303                            .with_rule(&field.ahb_status),
304                        );
305                    }
306                }
307                ConditionResult::Unknown => {
308                    // Partition unknown IDs into external / undetermined / missing.
309                    let mut external_ids = Vec::new();
310                    let mut undetermined_ids = Vec::new();
311                    let mut missing_ids = Vec::new();
312                    for id in unknown_ids {
313                        if self.evaluator.is_external(id) {
314                            external_ids.push(id);
315                        } else if self.evaluator.is_known(id) {
316                            undetermined_ids.push(id);
317                        } else {
318                            missing_ids.push(id);
319                        }
320                    }
321
322                    let mut parts = Vec::new();
323                    if !external_ids.is_empty() {
324                        let ids: Vec<String> =
325                            external_ids.iter().map(|id| format!("[{id}]")).collect();
326                        parts.push(format!(
327                            "external conditions require provider: {}",
328                            ids.join(", ")
329                        ));
330                    }
331                    if !undetermined_ids.is_empty() {
332                        let ids: Vec<String> = undetermined_ids
333                            .iter()
334                            .map(|id| format!("[{id}]"))
335                            .collect();
336                        parts.push(format!(
337                            "conditions could not be determined from message data: {}",
338                            ids.join(", ")
339                        ));
340                    }
341                    if !missing_ids.is_empty() {
342                        let ids: Vec<String> =
343                            missing_ids.iter().map(|id| format!("[{id}]")).collect();
344                        parts.push(format!("missing conditions: {}", ids.join(", ")));
345                    }
346                    let detail = if parts.is_empty() {
347                        String::new()
348                    } else {
349                        format!(" ({})", parts.join("; "))
350                    };
351                    report.add_issue(
352                        ValidationIssue::new(
353                            Severity::Info,
354                            ValidationCategory::Ahb,
355                            ErrorCodes::CONDITION_UNKNOWN,
356                            format!(
357                                "Condition for field '{}' could not be fully evaluated{}",
358                                field.name, detail
359                            ),
360                        )
361                        .with_field_path(&field.segment_path)
362                        .with_rule(&field.ahb_status),
363                    );
364                }
365            }
366        }
367
368        // Evaluate rules whose mig_number wasn't matched to any tree node.
369        // These represent entirely-absent group variants (e.g., NAD+MS when
370        // only NAD+MR is present). Use the flat is_field_present/is_group_variant_absent
371        // logic which correctly distinguishes mandatory-but-missing variants from
372        // optional-and-absent ones.
373        for field in &validated_tree.unmatched_rules {
374            if should_skip_for_parent_group(field, &expr_eval, &ctx, validated_tree.ub_definitions)
375            {
376                continue;
377            }
378
379            let (condition_result, _) = expr_eval.evaluate_status_detailed_with_ub(
380                &field.ahb_status,
381                &ctx,
382                validated_tree.ub_definitions,
383            );
384
385            if matches!(condition_result, ConditionResult::True)
386                && is_mandatory_status(&field.ahb_status)
387                && !is_field_present(&ctx, field)
388                && !is_group_variant_absent(&ctx, field)
389            {
390                let mut issue = ValidationIssue::new(
391                    Severity::Error,
392                    ValidationCategory::Ahb,
393                    ErrorCodes::MISSING_REQUIRED_FIELD,
394                    format!(
395                        "Required field '{}' at {} is missing",
396                        field.name, field.segment_path
397                    ),
398                )
399                .with_field_path(&field.segment_path)
400                .with_rule(&field.ahb_status);
401                if let Some(first_code) = field.codes.first() {
402                    issue.expected_value = Some(first_code.value.clone());
403                }
404                report.add_issue(issue);
405            }
406        }
407
408        // Run cross-field code validation and package cardinality checks.
409        // These operate on flat field rules (no resolved_value needed),
410        // so we reconstruct a lightweight workflow from the tree's rules.
411        if matches!(level, ValidationLevel::Full) {
412            let mut all_fields: Vec<AhbFieldRule> = Vec::new();
413            for node in &all_nodes {
414                all_fields.push(node.rule.clone());
415            }
416            for rule in &validated_tree.unmatched_rules {
417                all_fields.push((*rule).clone());
418            }
419            let synthetic_workflow = AhbWorkflow {
420                pruefidentifikator: validated_tree.pruefidentifikator.to_string(),
421                description: String::new(),
422                communication_direction: None,
423                fields: all_fields,
424                ub_definitions: validated_tree.ub_definitions.clone(),
425            };
426            self.validate_codes_cross_field(&synthetic_workflow, &ctx, &mut report);
427            self.validate_package_cardinality(&synthetic_workflow, &ctx, &mut report);
428        }
429
430        report
431    }
432
433    /// Validate AHB conditions for each field in the workflow.
434    fn validate_conditions(
435        &self,
436        workflow: &AhbWorkflow,
437        ctx: &EvaluationContext,
438        report: &mut ValidationReport,
439    ) {
440        let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
441
442        for field in &workflow.fields {
443            // Skip if parent group condition is not met (False or Unknown).
444            if should_skip_for_parent_group(field, &expr_eval, ctx, &workflow.ub_definitions) {
445                continue;
446            }
447
448            // Evaluate the AHB status condition expression, collecting
449            // which specific condition IDs are unknown when the result is Unknown.
450            let (condition_result, unknown_ids) = expr_eval.evaluate_status_detailed_with_ub(
451                &field.ahb_status,
452                ctx,
453                &workflow.ub_definitions,
454            );
455
456            match condition_result {
457                ConditionResult::True => {
458                    // Condition is met - field is required/applicable
459                    if is_mandatory_status(&field.ahb_status)
460                        && !is_field_present(ctx, field)
461                        && !is_group_variant_absent(ctx, field)
462                    {
463                        let mut issue = ValidationIssue::new(
464                            Severity::Error,
465                            ValidationCategory::Ahb,
466                            ErrorCodes::MISSING_REQUIRED_FIELD,
467                            format!(
468                                "Required field '{}' at {} is missing",
469                                field.name, field.segment_path
470                            ),
471                        )
472                        .with_field_path(&field.segment_path)
473                        .with_rule(&field.ahb_status);
474                        // Store the first expected code for BO4E path disambiguation.
475                        // E.g., DTM+93's code "93" helps resolve SG4/DTM/C507/2005
476                        // to Prozessdaten.gueltigBis vs gueltigAb.
477                        if let Some(first_code) = field.codes.first() {
478                            issue.expected_value = Some(first_code.value.clone());
479                        }
480                        report.add_issue(issue);
481                    }
482                }
483                ConditionResult::False => {
484                    // Condition not met.
485                    // If the field has a mandatory prefix (X, Muss) AND the field is
486                    // present, the condition is a format/value constraint that the
487                    // value violates. E.g., "X [UB1]" where UB1 checks timezone
488                    // format — the field is required, but its value is wrong.
489                    if is_mandatory_status(&field.ahb_status)
490                        && is_field_present(ctx, field)
491                    {
492                        report.add_issue(
493                            ValidationIssue::new(
494                                Severity::Error,
495                                ValidationCategory::Ahb,
496                                ErrorCodes::CONDITIONAL_RULE_VIOLATION,
497                                format!(
498                                    "Field '{}' at {} is present but does not satisfy condition: {}",
499                                    field.name, field.segment_path, field.ahb_status
500                                ),
501                            )
502                            .with_field_path(&field.segment_path)
503                            .with_rule(&field.ahb_status),
504                        );
505                    }
506                }
507                ConditionResult::Unknown => {
508                    // Partition unknown IDs into three categories:
509                    // 1. External: require an external provider (business context)
510                    // 2. Undetermined: implemented but returned Unknown (data not present)
511                    // 3. Missing: not implemented in evaluator at all
512                    let mut external_ids = Vec::new();
513                    let mut undetermined_ids = Vec::new();
514                    let mut missing_ids = Vec::new();
515                    for id in unknown_ids {
516                        if self.evaluator.is_external(id) {
517                            external_ids.push(id);
518                        } else if self.evaluator.is_known(id) {
519                            undetermined_ids.push(id);
520                        } else {
521                            missing_ids.push(id);
522                        }
523                    }
524
525                    let mut parts = Vec::new();
526                    if !external_ids.is_empty() {
527                        let ids: Vec<String> =
528                            external_ids.iter().map(|id| format!("[{id}]")).collect();
529                        parts.push(format!(
530                            "external conditions require provider: {}",
531                            ids.join(", ")
532                        ));
533                    }
534                    if !undetermined_ids.is_empty() {
535                        let ids: Vec<String> = undetermined_ids
536                            .iter()
537                            .map(|id| format!("[{id}]"))
538                            .collect();
539                        parts.push(format!(
540                            "conditions could not be determined from message data: {}",
541                            ids.join(", ")
542                        ));
543                    }
544                    if !missing_ids.is_empty() {
545                        let ids: Vec<String> =
546                            missing_ids.iter().map(|id| format!("[{id}]")).collect();
547                        parts.push(format!("missing conditions: {}", ids.join(", ")));
548                    }
549                    let detail = if parts.is_empty() {
550                        String::new()
551                    } else {
552                        format!(" ({})", parts.join("; "))
553                    };
554                    report.add_issue(
555                        ValidationIssue::new(
556                            Severity::Info,
557                            ValidationCategory::Ahb,
558                            ErrorCodes::CONDITION_UNKNOWN,
559                            format!(
560                                "Condition for field '{}' could not be fully evaluated{}",
561                                field.name, detail
562                            ),
563                        )
564                        .with_field_path(&field.segment_path)
565                        .with_rule(&field.ahb_status),
566                    );
567                }
568            }
569        }
570
571        // Cross-field code validation: aggregate allowed codes across all field
572        // rules sharing the same segment path, then check each segment instance
573        // against the combined set. This avoids false positives from per-field
574        // validation (e.g., NAD/3035 with [MS] for sender and [MR] for receiver).
575        self.validate_codes_cross_field(workflow, ctx, report);
576
577        // Package cardinality post-processing: check that the count of codes
578        // present from each package group falls within [min..max] bounds.
579        self.validate_package_cardinality(workflow, ctx, report);
580    }
581
582    /// Validate package cardinality constraints across all field rules.
583    ///
584    /// Scans each code's `ahb_status` for `[NP_min..max]` package references,
585    /// groups codes by `(segment_path, package_id)`, counts how many of the
586    /// package's codes are actually present in the segment, and emits AHB006
587    /// if the count falls outside `[min..max]`.
588    fn validate_package_cardinality(
589        &self,
590        workflow: &AhbWorkflow,
591        ctx: &EvaluationContext,
592        report: &mut ValidationReport,
593    ) {
594        // Collect package groups: (segment_path, package_id) -> (min, max, Vec<code_value>)
595        // Also store (element_index, component_index) for looking up the value in the segment.
596        struct PackageGroup {
597            min: u32,
598            max: u32,
599            code_values: Vec<String>,
600            element_index: usize,
601            component_index: usize,
602        }
603
604        // Key: (segment_path, package_id)
605        let mut groups: HashMap<(String, u32), PackageGroup> = HashMap::new();
606
607        let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
608
609        for field in &workflow.fields {
610            let el_idx = field.element_index.unwrap_or(0);
611            let comp_idx = field.component_index.unwrap_or(0);
612
613            // Skip package codes whose parent group condition is False or Unknown.
614            if should_skip_for_parent_group(field, &expr_eval, ctx, &workflow.ub_definitions) {
615                continue;
616            }
617
618            for code in &field.codes {
619                // Parse the code's ahb_status for Package nodes
620                if let Ok(Some(expr)) = ConditionParser::parse(&code.ahb_status) {
621                    // Walk the AST to find Package nodes
622                    let mut packages = Vec::new();
623                    collect_packages(&expr, &mut packages);
624
625                    for (pkg_id, pkg_min, pkg_max) in packages {
626                        let key = (field.segment_path.clone(), pkg_id);
627                        let group = groups.entry(key).or_insert_with(|| PackageGroup {
628                            min: pkg_min,
629                            max: pkg_max,
630                            code_values: Vec::new(),
631                            element_index: el_idx,
632                            component_index: comp_idx,
633                        });
634                        // Update min/max in case different codes specify different bounds
635                        // (should be the same, but take the intersection to be safe)
636                        group.min = group.min.max(pkg_min);
637                        group.max = group.max.min(pkg_max);
638                        group.code_values.push(code.value.clone());
639                    }
640                }
641            }
642        }
643
644        // For each package group, count present codes and check bounds
645        for ((seg_path, pkg_id), group) in &groups {
646            let segment_id = extract_segment_id(seg_path);
647            let segments = ctx.find_segments(&segment_id);
648
649            // Count how many of the package's codes appear across all segment instances
650            let mut present_count: usize = 0;
651            for seg in &segments {
652                if let Some(value) = seg
653                    .elements
654                    .get(group.element_index)
655                    .and_then(|e| e.get(group.component_index))
656                    .filter(|v| !v.is_empty())
657                {
658                    if group.code_values.contains(value) {
659                        present_count += 1;
660                    }
661                }
662            }
663
664            let min = group.min as usize;
665            let max = group.max as usize;
666
667            if present_count < min || present_count > max {
668                let code_list = group.code_values.join(", ");
669                report.add_issue(
670                    ValidationIssue::new(
671                        Severity::Error,
672                        ValidationCategory::Ahb,
673                        ErrorCodes::PACKAGE_CARDINALITY_VIOLATION,
674                        format!(
675                            "Package [{}P{}..{}] at {}: {} code(s) present (allowed {}..{}). Codes in package: [{}]",
676                            pkg_id, group.min, group.max, seg_path, present_count, group.min, group.max, code_list
677                        ),
678                    )
679                    .with_field_path(seg_path)
680                    .with_expected(format!("{}..{}", group.min, group.max))
681                    .with_actual(present_count.to_string()),
682                );
683            }
684        }
685    }
686
687    /// Validate qualifier codes by aggregating allowed values across field rules.
688    ///
689    /// When a group navigator is available, codes are grouped by segment path
690    /// (e.g., `SG2/NAD/3035` → `{MS, MR}`) and segments are looked up within
691    /// the specific group, giving precise per-group validation.
692    ///
693    /// Without a navigator, falls back to grouping by segment tag and unioning
694    /// all codes across groups to avoid cross-group false positives.
695    ///
696    /// Only validates simple qualifier paths (`[SG/]*/SEG/ELEMENT`) where the
697    /// code is in `element[0][0]`. Composite paths are skipped.
698    fn validate_codes_cross_field(
699        &self,
700        workflow: &AhbWorkflow,
701        ctx: &EvaluationContext,
702        report: &mut ValidationReport,
703    ) {
704        if ctx.navigator.is_some() {
705            self.validate_codes_group_scoped(workflow, ctx, report);
706        } else {
707            self.validate_codes_tag_scoped(workflow, ctx, report);
708        }
709    }
710
711    /// Group-scoped code validation: group codes by path, use navigator to
712    /// find segments within each group, check against that group's codes only.
713    fn validate_codes_group_scoped(
714        &self,
715        workflow: &AhbWorkflow,
716        ctx: &EvaluationContext,
717        report: &mut ValidationReport,
718    ) {
719        // Group allowed codes by (group_path_key, segment_tag, element_index, component_index).
720        // E.g., "SG2/NAD/3035" → key ("SG2", "NAD", 0, 0)
721        type CodeKey = (String, String, usize, usize);
722        let mut codes_by_group: HashMap<CodeKey, HashSet<&str>> = HashMap::new();
723
724        for field in &workflow.fields {
725            if field.codes.is_empty() || !is_qualifier_field(&field.segment_path) {
726                continue;
727            }
728            let tag = extract_segment_id(&field.segment_path);
729            let group_key = extract_group_path_key(&field.segment_path);
730            let el_idx = field.element_index.unwrap_or(0);
731            let comp_idx = field.component_index.unwrap_or(0);
732            let entry = codes_by_group
733                .entry((group_key, tag, el_idx, comp_idx))
734                .or_default();
735            for code in &field.codes {
736                if code.ahb_status == "X" || code.ahb_status.starts_with("Muss") {
737                    entry.insert(&code.value);
738                }
739            }
740        }
741
742        let nav = ctx.navigator.unwrap();
743
744        for ((group_key, tag, el_idx, comp_idx), allowed_codes) in &codes_by_group {
745            if allowed_codes.is_empty() {
746                continue;
747            }
748
749            let group_path: Vec<&str> = if group_key.is_empty() {
750                Vec::new()
751            } else {
752                group_key.split('/').collect()
753            };
754
755            // Iterate over all instances of this group and check segments.
756            if group_path.is_empty() {
757                // No group prefix (e.g., bare "NAD/3035") — check flat segments.
758                Self::check_segments_against_codes(
759                    ctx.find_segments(tag),
760                    allowed_codes,
761                    tag,
762                    *el_idx,
763                    *comp_idx,
764                    &format!("{tag}/qualifier"),
765                    report,
766                );
767            } else {
768                let instance_count = nav.group_instance_count(&group_path);
769                for i in 0..instance_count {
770                    let segments = nav.find_segments_in_group(tag, &group_path, i);
771                    let refs: Vec<&OwnedSegment> = segments.iter().collect();
772                    Self::check_segments_against_codes(
773                        refs,
774                        allowed_codes,
775                        tag,
776                        *el_idx,
777                        *comp_idx,
778                        &format!("{group_key}/{tag}/qualifier"),
779                        report,
780                    );
781                }
782            }
783        }
784    }
785
786    /// Fallback: group codes by segment tag (unioning across all groups) and
787    /// check all segments of that tag. Used when no navigator is available.
788    fn validate_codes_tag_scoped(
789        &self,
790        workflow: &AhbWorkflow,
791        ctx: &EvaluationContext,
792        report: &mut ValidationReport,
793    ) {
794        // Key: (tag, element_index, component_index)
795        let mut codes_by_tag: HashMap<(String, usize, usize), HashSet<&str>> = HashMap::new();
796
797        for field in &workflow.fields {
798            if field.codes.is_empty() || !is_qualifier_field(&field.segment_path) {
799                continue;
800            }
801            let tag = extract_segment_id(&field.segment_path);
802            let el_idx = field.element_index.unwrap_or(0);
803            let comp_idx = field.component_index.unwrap_or(0);
804            let entry = codes_by_tag.entry((tag, el_idx, comp_idx)).or_default();
805            for code in &field.codes {
806                if code.ahb_status == "X" || code.ahb_status.starts_with("Muss") {
807                    entry.insert(&code.value);
808                }
809            }
810        }
811
812        for ((tag, el_idx, comp_idx), allowed_codes) in &codes_by_tag {
813            if allowed_codes.is_empty() {
814                continue;
815            }
816            Self::check_segments_against_codes(
817                ctx.find_segments(tag),
818                allowed_codes,
819                tag,
820                *el_idx,
821                *comp_idx,
822                &format!("{tag}/qualifier"),
823                report,
824            );
825        }
826    }
827
828    /// Check a list of segments' qualifier at the given element/component index against allowed codes.
829    fn check_segments_against_codes(
830        segments: Vec<&OwnedSegment>,
831        allowed_codes: &HashSet<&str>,
832        _tag: &str,
833        el_idx: usize,
834        comp_idx: usize,
835        field_path: &str,
836        report: &mut ValidationReport,
837    ) {
838        for segment in segments {
839            if let Some(code_value) = segment
840                .elements
841                .get(el_idx)
842                .and_then(|e| e.get(comp_idx))
843                .filter(|v| !v.is_empty())
844            {
845                if !allowed_codes.contains(code_value.as_str()) {
846                    let mut sorted_codes: Vec<&str> = allowed_codes.iter().copied().collect();
847                    sorted_codes.sort_unstable();
848                    report.add_issue(
849                        ValidationIssue::new(
850                            Severity::Error,
851                            ValidationCategory::Code,
852                            ErrorCodes::CODE_NOT_ALLOWED_FOR_PID,
853                            format!(
854                                "Code '{}' is not allowed for this PID. Allowed: [{}]",
855                                code_value,
856                                sorted_codes.join(", ")
857                            ),
858                        )
859                        .with_field_path(field_path)
860                        .with_actual(code_value)
861                        .with_expected(sorted_codes.join(", ")),
862                    );
863                }
864            }
865        }
866    }
867}
868
869/// Check if the parent group's conditional status evaluates to False or Unknown.
870///
871/// Returns `true` if the field should be skipped (parent group condition not met).
872/// Only evaluates when `parent_group_ahb_status` contains condition brackets `[`.
873/// Simple statuses like "Kann" or "Muss" pass through (return `false`).
874fn should_skip_for_parent_group<E: ConditionEvaluator>(
875    field: &AhbFieldRule,
876    expr_eval: &ConditionExprEvaluator<E>,
877    ctx: &EvaluationContext,
878    ub_definitions: &HashMap<String, ConditionExpr>,
879) -> bool {
880    if let Some(ref group_status) = field.parent_group_ahb_status {
881        if group_status.contains('[') {
882            let result = expr_eval.evaluate_status_with_ub(group_status, ctx, ub_definitions);
883            return matches!(result, ConditionResult::False | ConditionResult::Unknown);
884        }
885    }
886    false
887}
888
889/// Check if a field's required segment instance is present.
890///
891/// For fields with qualifier codes (e.g., `SG2/NAD/3035` with `[MR]`),
892/// checks that a segment with one of those qualifier values exists.
893/// Otherwise just checks that any segment with the tag is present.
894///
895/// This prevents false negatives where `NAD+MS` is present but the
896/// validator incorrectly says NAD "exists" when `NAD+MR` is missing.
897fn is_field_present(ctx: &EvaluationContext, field: &AhbFieldRule) -> bool {
898    let segment_id = extract_segment_id(&field.segment_path);
899
900    // When a field has known codes, check for a segment containing one of those
901    // specific values at the right element/component position. This applies to both
902    // simple qualifier paths (NAD/3035) and composite paths (CCI/C240/7037) —
903    // without this, a CCI segment from a different group variant (e.g., CCI+Z61)
904    // would falsely satisfy the check for CCI with C240/7037 codes Z15/Z18.
905    if !field.codes.is_empty() {
906        if let (Some(el_idx), Some(comp_idx)) = (field.element_index, field.component_index) {
907            let required_codes: Vec<&str> =
908                field.codes.iter().map(|c| c.value.as_str()).collect();
909            let matching = ctx.find_segments(&segment_id);
910            return matching.iter().any(|seg| {
911                seg.elements
912                    .get(el_idx)
913                    .and_then(|e| e.get(comp_idx))
914                    .is_some_and(|v| required_codes.contains(&v.as_str()))
915            });
916        }
917        // Fallback for fields with codes but no element/component indices:
918        // use simple qualifier check if it's a qualifier-style path.
919        if is_qualifier_field(&field.segment_path) {
920            let required_codes: Vec<&str> =
921                field.codes.iter().map(|c| c.value.as_str()).collect();
922            let el_idx = field.element_index.unwrap_or(0);
923            let comp_idx = field.component_index.unwrap_or(0);
924            let matching = ctx.find_segments(&segment_id);
925            return matching.iter().any(|seg| {
926                seg.elements
927                    .get(el_idx)
928                    .and_then(|e| e.get(comp_idx))
929                    .is_some_and(|v| required_codes.contains(&v.as_str()))
930            });
931        }
932    }
933
934    ctx.has_segment(&segment_id)
935}
936
937/// Check if the group variant for a field is absent from the message.
938///
939/// Uses tree-based logic:
940/// 1. **Group entirely absent**: If the group path has 0 instances, the
941///    whole group is absent and its children aren't required.
942/// 2. **Optional group variant absent**: If the parent group is optional
943///    ("Kann") and the specific qualifier variant isn't present, the variant
944///    is absent. E.g., SG5 "Kann" with LOC+Z17 — if no SG5 instance has
945///    LOC+Z17, all fields under that variant are skipped.
946///
947/// Returns `false` (not absent) when:
948/// - The field has no group prefix (e.g., `NAD/3035`)
949/// - No navigator is available (can't determine group presence)
950/// - The parent group is mandatory and has instances
951fn is_group_variant_absent(ctx: &EvaluationContext, field: &AhbFieldRule) -> bool {
952    let group_path: Vec<&str> = field
953        .segment_path
954        .split('/')
955        .take_while(|p| p.starts_with("SG"))
956        .collect();
957
958    if group_path.is_empty() {
959        return false;
960    }
961
962    let nav = match ctx.navigator {
963        Some(nav) => nav,
964        None => return false,
965    };
966
967    let instance_count = nav.group_instance_count(&group_path);
968
969    // Case 1: group entirely absent — only skip if the group is NOT mandatory.
970    // When a mandatory group (parent_group_ahb_status contains "Muss" or "X")
971    // has 0 instances, the group's absence is itself the error, so we should
972    // NOT skip field validation — let the field-level check report the missing field.
973    if instance_count == 0 {
974        let is_group_mandatory = field
975            .parent_group_ahb_status
976            .as_deref()
977            .is_some_and(is_mandatory_status);
978        if !is_group_mandatory {
979            return true;
980        }
981        // Mandatory group with 0 instances → don't suppress field errors
982        return false;
983    }
984
985    // Case 2: group has instances, but the specific qualifier variant may be absent.
986    // Only applies when the parent group is optional ("Kann") — mandatory groups
987    // ("Muss", "X") require all their qualifier variants to be present.
988    if let Some(ref group_status) = field.parent_group_ahb_status {
989        if !is_mandatory_status(group_status) && !group_status.contains('[') {
990            // Parent group is unconditionally optional (e.g., "Kann").
991            // Check if the field's qualifier variant is present in any instance.
992            if !field.codes.is_empty() && is_qualifier_field(&field.segment_path) {
993                let segment_id = extract_segment_id(&field.segment_path);
994                let required_codes: Vec<&str> =
995                    field.codes.iter().map(|c| c.value.as_str()).collect();
996
997                let any_instance_has_qualifier = (0..instance_count).any(|i| {
998                    nav.find_segments_in_group(&segment_id, &group_path, i)
999                        .iter()
1000                        .any(|seg| {
1001                            seg.elements
1002                                .first()
1003                                .and_then(|e| e.first())
1004                                .is_some_and(|v| required_codes.contains(&v.as_str()))
1005                        })
1006                });
1007
1008                if !any_instance_has_qualifier {
1009                    return true; // optional group variant absent
1010                }
1011            }
1012        }
1013    }
1014
1015    // Case 3: non-entry segment absent from all group instances.
1016    // E.g., SG10 has QTY (entry) + optional STS segments. If no STS appears
1017    // in any SG10 instance but the entry segment (QTY) is present, fields
1018    // under STS are not required.
1019    //
1020    // We check `has_any_segment_in_group` to confirm the group instance is
1021    // genuinely populated (proving our target segment is a non-entry optional
1022    // one) vs a navigator that simply can't resolve segments.
1023    let segment_id = extract_segment_id(&field.segment_path);
1024    let segment_absent_from_all = (0..instance_count).all(|i| {
1025        nav.find_segments_in_group(&segment_id, &group_path, i)
1026            .is_empty()
1027    });
1028    if segment_absent_from_all {
1029        let group_has_other_segments =
1030            (0..instance_count).any(|i| nav.has_any_segment_in_group(&group_path, i));
1031        if group_has_other_segments {
1032            return true;
1033        }
1034    }
1035
1036    false
1037}
1038
1039/// Recursively collect all AhbNodes from a group node in depth-first order.
1040fn collect_nodes_depth_first<'a, 'b>(group: &'b AhbGroupNode<'a>, out: &mut Vec<&'b AhbNode<'a>>) {
1041    out.extend(group.fields.iter());
1042    for child in &group.children {
1043        collect_nodes_depth_first(child, out);
1044    }
1045}
1046
1047/// Recursively collect all Package nodes from a condition expression tree.
1048fn collect_packages(expr: &ConditionExpr, out: &mut Vec<(u32, u32, u32)>) {
1049    match expr {
1050        ConditionExpr::Package { id, min, max } => {
1051            out.push((*id, *min, *max));
1052        }
1053        ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
1054            for e in exprs {
1055                collect_packages(e, out);
1056            }
1057        }
1058        ConditionExpr::Xor(left, right) => {
1059            collect_packages(left, out);
1060            collect_packages(right, out);
1061        }
1062        ConditionExpr::Not(inner) => {
1063            collect_packages(inner, out);
1064        }
1065        ConditionExpr::Ref(_) => {}
1066    }
1067}
1068
1069/// Check if an AHB status is mandatory (Muss or X prefix).
1070fn is_mandatory_status(status: &str) -> bool {
1071    let trimmed = status.trim();
1072    trimmed.starts_with("Muss") || trimmed.starts_with('X')
1073}
1074
1075/// Check if a field path points to a simple qualifier element (element[0] of the segment).
1076///
1077/// Returns `true` for paths like `[SG/]*/SEG/ELEMENT` where the data element is
1078/// directly under the segment (no composite wrapper). These fields have their code
1079/// in `element[0][0]` and can be validated.
1080///
1081/// Returns `false` for composite paths like `SEG/COMPOSITE/ELEMENT` (e.g.,
1082/// `UNH/S009/0065`) where the element is inside a composite at an unknown index.
1083fn is_qualifier_field(path: &str) -> bool {
1084    let parts: Vec<&str> = path.split('/').filter(|p| !p.starts_with("SG")).collect();
1085    // Expected: [SEGMENT_TAG, ELEMENT_ID] — exactly 2 parts after SG stripping.
1086    // If 3+ parts, there's a composite layer (e.g., [SEG, COMPOSITE, ELEMENT]).
1087    parts.len() == 2
1088}
1089
1090/// Extract the group path prefix from a field path.
1091///
1092/// `"SG2/NAD/3035"` → `"SG2"`, `"SG4/SG12/NAD/3035"` → `"SG4/SG12"`,
1093/// `"NAD/3035"` → `""` (no group prefix).
1094fn extract_group_path_key(path: &str) -> String {
1095    let sg_parts: Vec<&str> = path
1096        .split('/')
1097        .take_while(|p| p.starts_with("SG"))
1098        .collect();
1099    sg_parts.join("/")
1100}
1101
1102/// Extract the segment ID from a field path like "SG2/NAD/C082/3039" -> "NAD".
1103fn extract_segment_id(path: &str) -> String {
1104    for part in path.split('/') {
1105        // Skip segment group identifiers and composite/element identifiers
1106        if part.starts_with("SG") || part.starts_with("C_") || part.starts_with("D_") {
1107            continue;
1108        }
1109        // Return first 3-letter uppercase segment identifier
1110        if part.len() >= 3
1111            && part
1112                .chars()
1113                .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
1114        {
1115            return part.to_string();
1116        }
1117    }
1118    // Fallback: return the last part
1119    path.split('/').next_back().unwrap_or(path).to_string()
1120}
1121
1122/// Validate that the UNT segment count matches the actual number of segments.
1123///
1124/// The UNT segment's first data element (D0074) declares the number of segments
1125/// in the message, counting from UNH to UNT inclusive. This function compares
1126/// that declared value against the actual segment count.
1127///
1128/// Pass per-message segments (from [`MessageChunk::message_segments()`] or
1129/// [`MessageChunk::all_segments()`]), not the full interchange. For multi-message
1130/// interchanges, call once per message.
1131///
1132/// Returns `Some(ValidationIssue)` if there's a mismatch, `None` if correct.
1133pub fn validate_unt_segment_count(segments: &[OwnedSegment]) -> Option<ValidationIssue> {
1134    // Reject multi-message input — counting would be incorrect.
1135    let unh_count = segments.iter().filter(|s| s.id == "UNH").count();
1136    if unh_count > 1 {
1137        return Some(ValidationIssue::new(
1138            Severity::Error,
1139            ValidationCategory::Structure,
1140            ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH,
1141            format!(
1142                "UNT validation requires per-message segments, found {unh_count} UNH segments"
1143            ),
1144        ));
1145    }
1146
1147    // Find the UNT segment
1148    let unt = segments.iter().rfind(|s| s.id == "UNT")?;
1149    let declared: usize = unt.get_element(0).parse().ok()?;
1150
1151    // Count segments from UNH to UNT inclusive
1152    let actual = segments
1153        .iter()
1154        .filter(|s| s.id != "UNA" && s.id != "UNB" && s.id != "UNZ")
1155        .count();
1156
1157    if declared != actual {
1158        Some(
1159            ValidationIssue::new(
1160                Severity::Error,
1161                ValidationCategory::Structure,
1162                ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH,
1163                format!(
1164                    "UNT segment count mismatch: declared {declared}, actual {actual}"
1165                ),
1166            )
1167            .with_field_path("UNT/0074")
1168            .with_expected(actual.to_string())
1169            .with_actual(declared.to_string()),
1170        )
1171    } else {
1172        None
1173    }
1174}
1175
1176#[cfg(test)]
1177mod tests {
1178    use super::*;
1179    use crate::eval::{ConditionResult as CR, NoOpExternalProvider};
1180    use std::collections::HashMap;
1181
1182    /// Mock evaluator for testing the validator.
1183    struct MockEvaluator {
1184        results: HashMap<u32, CR>,
1185    }
1186
1187    impl MockEvaluator {
1188        fn new(results: Vec<(u32, CR)>) -> Self {
1189            Self {
1190                results: results.into_iter().collect(),
1191            }
1192        }
1193
1194        fn all_true(ids: &[u32]) -> Self {
1195            Self::new(ids.iter().map(|&id| (id, CR::True)).collect())
1196        }
1197    }
1198
1199    impl ConditionEvaluator for MockEvaluator {
1200        fn evaluate(&self, condition: u32, _ctx: &EvaluationContext) -> CR {
1201            self.results.get(&condition).copied().unwrap_or(CR::Unknown)
1202        }
1203        fn is_external(&self, _condition: u32) -> bool {
1204            false
1205        }
1206        fn message_type(&self) -> &str {
1207            "UTILMD"
1208        }
1209        fn format_version(&self) -> &str {
1210            "FV2510"
1211        }
1212    }
1213
1214    // === Helper function tests ===
1215
1216    #[test]
1217    fn test_is_mandatory_status() {
1218        assert!(is_mandatory_status("Muss"));
1219        assert!(is_mandatory_status("Muss [182] ∧ [152]"));
1220        assert!(is_mandatory_status("X"));
1221        assert!(is_mandatory_status("X [567]"));
1222        assert!(!is_mandatory_status("Soll [1]"));
1223        assert!(!is_mandatory_status("Kann [1]"));
1224        assert!(!is_mandatory_status(""));
1225    }
1226
1227    #[test]
1228    fn test_extract_segment_id_simple() {
1229        assert_eq!(extract_segment_id("NAD"), "NAD");
1230    }
1231
1232    #[test]
1233    fn test_extract_segment_id_with_sg_prefix() {
1234        assert_eq!(extract_segment_id("SG2/NAD/C082/3039"), "NAD");
1235    }
1236
1237    #[test]
1238    fn test_extract_segment_id_nested_sg() {
1239        assert_eq!(extract_segment_id("SG4/SG8/SEQ/C286/6350"), "SEQ");
1240    }
1241
1242    // === Validator tests with mock data ===
1243
1244    #[test]
1245    fn test_validate_missing_mandatory_field() {
1246        let evaluator = MockEvaluator::all_true(&[182, 152]);
1247        let validator = EdifactValidator::new(evaluator);
1248        let external = NoOpExternalProvider;
1249
1250        let workflow = AhbWorkflow {
1251            pruefidentifikator: "11001".to_string(),
1252            description: "Test".to_string(),
1253            communication_direction: None,
1254            fields: vec![AhbFieldRule {
1255                segment_path: "SG2/NAD/C082/3039".to_string(),
1256                name: "MP-ID des MSB".to_string(),
1257                ahb_status: "Muss [182] ∧ [152]".to_string(),
1258                codes: vec![],
1259                parent_group_ahb_status: None,
1260                ..Default::default()
1261            }],
1262            ub_definitions: HashMap::new(),
1263        };
1264
1265        // Validate with no segments
1266        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1267
1268        // Should have an error for missing mandatory field
1269        assert!(!report.is_valid());
1270        let errors: Vec<_> = report.errors().collect();
1271        assert_eq!(errors.len(), 1);
1272        assert_eq!(errors[0].code, ErrorCodes::MISSING_REQUIRED_FIELD);
1273        assert!(errors[0].message.contains("MP-ID des MSB"));
1274    }
1275
1276    #[test]
1277    fn test_validate_condition_false_no_error() {
1278        // When condition evaluates to False, field is not required
1279        let evaluator = MockEvaluator::new(vec![(182, CR::True), (152, CR::False)]);
1280        let validator = EdifactValidator::new(evaluator);
1281        let external = NoOpExternalProvider;
1282
1283        let workflow = AhbWorkflow {
1284            pruefidentifikator: "11001".to_string(),
1285            description: "Test".to_string(),
1286            communication_direction: None,
1287            fields: vec![AhbFieldRule {
1288                segment_path: "NAD".to_string(),
1289                name: "Partnerrolle".to_string(),
1290                ahb_status: "Muss [182] ∧ [152]".to_string(),
1291                codes: vec![],
1292                parent_group_ahb_status: None,
1293                ..Default::default()
1294            }],
1295            ub_definitions: HashMap::new(),
1296        };
1297
1298        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1299
1300        // Condition is false, so field is not required - no error
1301        assert!(report.is_valid());
1302    }
1303
1304    #[test]
1305    fn test_validate_condition_unknown_adds_info() {
1306        // When condition is Unknown, add an info-level note
1307        let evaluator = MockEvaluator::new(vec![(182, CR::True)]);
1308        // 152 is not registered -> Unknown
1309        let validator = EdifactValidator::new(evaluator);
1310        let external = NoOpExternalProvider;
1311
1312        let workflow = AhbWorkflow {
1313            pruefidentifikator: "11001".to_string(),
1314            description: "Test".to_string(),
1315            communication_direction: None,
1316            fields: vec![AhbFieldRule {
1317                segment_path: "NAD".to_string(),
1318                name: "Partnerrolle".to_string(),
1319                ahb_status: "Muss [182] ∧ [152]".to_string(),
1320                codes: vec![],
1321                parent_group_ahb_status: None,
1322                ..Default::default()
1323            }],
1324            ub_definitions: HashMap::new(),
1325        };
1326
1327        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1328
1329        // Should be valid (Unknown is not an error) but have an info issue
1330        assert!(report.is_valid());
1331        let infos: Vec<_> = report.infos().collect();
1332        assert_eq!(infos.len(), 1);
1333        assert_eq!(infos[0].code, ErrorCodes::CONDITION_UNKNOWN);
1334    }
1335
1336    #[test]
1337    fn test_validate_structure_level_skips_conditions() {
1338        let evaluator = MockEvaluator::all_true(&[182, 152]);
1339        let validator = EdifactValidator::new(evaluator);
1340        let external = NoOpExternalProvider;
1341
1342        let workflow = AhbWorkflow {
1343            pruefidentifikator: "11001".to_string(),
1344            description: "Test".to_string(),
1345            communication_direction: None,
1346            fields: vec![AhbFieldRule {
1347                segment_path: "NAD".to_string(),
1348                name: "Partnerrolle".to_string(),
1349                ahb_status: "Muss [182] ∧ [152]".to_string(),
1350                codes: vec![],
1351                parent_group_ahb_status: None,
1352                ..Default::default()
1353            }],
1354            ub_definitions: HashMap::new(),
1355        };
1356
1357        // With Structure level, conditions are not checked
1358        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Structure);
1359
1360        // No AHB errors because conditions were not evaluated
1361        assert!(report.is_valid());
1362        assert_eq!(report.by_category(ValidationCategory::Ahb).count(), 0);
1363    }
1364
1365    #[test]
1366    fn test_validate_empty_workflow_no_condition_errors() {
1367        let evaluator = MockEvaluator::all_true(&[]);
1368        let validator = EdifactValidator::new(evaluator);
1369        let external = NoOpExternalProvider;
1370
1371        let empty_workflow = AhbWorkflow {
1372            pruefidentifikator: String::new(),
1373            description: String::new(),
1374            communication_direction: None,
1375            fields: vec![],
1376            ub_definitions: HashMap::new(),
1377        };
1378
1379        let report = validator.validate(&[], &empty_workflow, &external, ValidationLevel::Full);
1380
1381        assert!(report.is_valid());
1382    }
1383
1384    #[test]
1385    fn test_validate_bare_muss_always_required() {
1386        let evaluator = MockEvaluator::new(vec![]);
1387        let validator = EdifactValidator::new(evaluator);
1388        let external = NoOpExternalProvider;
1389
1390        let workflow = AhbWorkflow {
1391            pruefidentifikator: "55001".to_string(),
1392            description: "Test".to_string(),
1393            communication_direction: Some("NB an LF".to_string()),
1394            fields: vec![AhbFieldRule {
1395                segment_path: "SG2/NAD/3035".to_string(),
1396                name: "Partnerrolle".to_string(),
1397                ahb_status: "Muss".to_string(), // No conditions
1398                codes: vec![],
1399                parent_group_ahb_status: None,
1400                ..Default::default()
1401            }],
1402            ub_definitions: HashMap::new(),
1403        };
1404
1405        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1406
1407        // Bare "Muss" with no conditions -> unconditionally required -> missing = error
1408        assert!(!report.is_valid());
1409        assert_eq!(report.error_count(), 1);
1410    }
1411
1412    #[test]
1413    fn test_validate_x_status_is_mandatory() {
1414        let evaluator = MockEvaluator::new(vec![]);
1415        let validator = EdifactValidator::new(evaluator);
1416        let external = NoOpExternalProvider;
1417
1418        let workflow = AhbWorkflow {
1419            pruefidentifikator: "55001".to_string(),
1420            description: "Test".to_string(),
1421            communication_direction: None,
1422            fields: vec![AhbFieldRule {
1423                segment_path: "DTM".to_string(),
1424                name: "Datum".to_string(),
1425                ahb_status: "X".to_string(),
1426                codes: vec![],
1427                parent_group_ahb_status: None,
1428                ..Default::default()
1429            }],
1430            ub_definitions: HashMap::new(),
1431        };
1432
1433        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1434
1435        assert!(!report.is_valid());
1436        let errors: Vec<_> = report.errors().collect();
1437        assert_eq!(errors[0].code, ErrorCodes::MISSING_REQUIRED_FIELD);
1438    }
1439
1440    #[test]
1441    fn test_validate_soll_not_mandatory() {
1442        let evaluator = MockEvaluator::new(vec![]);
1443        let validator = EdifactValidator::new(evaluator);
1444        let external = NoOpExternalProvider;
1445
1446        let workflow = AhbWorkflow {
1447            pruefidentifikator: "55001".to_string(),
1448            description: "Test".to_string(),
1449            communication_direction: None,
1450            fields: vec![AhbFieldRule {
1451                segment_path: "DTM".to_string(),
1452                name: "Datum".to_string(),
1453                ahb_status: "Soll".to_string(),
1454                codes: vec![],
1455                parent_group_ahb_status: None,
1456                ..Default::default()
1457            }],
1458            ub_definitions: HashMap::new(),
1459        };
1460
1461        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1462
1463        // Soll is not mandatory, so missing is not an error
1464        assert!(report.is_valid());
1465    }
1466
1467    #[test]
1468    fn test_report_includes_metadata() {
1469        let evaluator = MockEvaluator::new(vec![]);
1470        let validator = EdifactValidator::new(evaluator);
1471        let external = NoOpExternalProvider;
1472
1473        let workflow = AhbWorkflow {
1474            pruefidentifikator: "55001".to_string(),
1475            description: String::new(),
1476            communication_direction: None,
1477            fields: vec![],
1478            ub_definitions: HashMap::new(),
1479        };
1480
1481        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Full);
1482
1483        assert_eq!(report.format_version.as_deref(), Some("FV2510"));
1484        assert_eq!(report.level, ValidationLevel::Full);
1485        assert_eq!(report.message_type, "UTILMD");
1486        assert_eq!(report.pruefidentifikator.as_deref(), Some("55001"));
1487    }
1488
1489    #[test]
1490    fn test_validate_with_navigator_returns_report() {
1491        let evaluator = MockEvaluator::all_true(&[]);
1492        let validator = EdifactValidator::new(evaluator);
1493        let external = NoOpExternalProvider;
1494        let nav = crate::eval::NoOpGroupNavigator;
1495
1496        let workflow = AhbWorkflow {
1497            pruefidentifikator: "55001".to_string(),
1498            description: "Test".to_string(),
1499            communication_direction: None,
1500            fields: vec![],
1501            ub_definitions: HashMap::new(),
1502        };
1503
1504        let report = validator.validate_with_navigator(
1505            &[],
1506            &workflow,
1507            &external,
1508            ValidationLevel::Full,
1509            &nav,
1510        );
1511        assert!(report.is_valid());
1512    }
1513
1514    #[test]
1515    fn test_code_validation_skips_composite_paths() {
1516        // UNH/S009/0065 has codes like ["UTILMD"], but the code is in element[1]
1517        // (composite S009), not element[0] (message reference).
1518        // The validator should skip code validation for composite paths.
1519        let evaluator = MockEvaluator::new(vec![]);
1520        let validator = EdifactValidator::new(evaluator);
1521        let external = NoOpExternalProvider;
1522
1523        let unh_segment = OwnedSegment {
1524            id: "UNH".to_string(),
1525            elements: vec![
1526                vec!["ALEXANDE951842".to_string()], // element 0: message ref
1527                vec![
1528                    "UTILMD".to_string(),
1529                    "D".to_string(),
1530                    "11A".to_string(),
1531                    "UN".to_string(),
1532                    "S2.1".to_string(),
1533                ],
1534            ],
1535            segment_number: 1,
1536        };
1537
1538        let workflow = AhbWorkflow {
1539            pruefidentifikator: "55001".to_string(),
1540            description: "Test".to_string(),
1541            communication_direction: None,
1542            fields: vec![
1543                AhbFieldRule {
1544                    segment_path: "UNH/S009/0065".to_string(),
1545                    name: "Nachrichtentyp".to_string(),
1546                    ahb_status: "X".to_string(),
1547                    codes: vec![AhbCodeRule {
1548                        value: "UTILMD".to_string(),
1549                        description: "Stammdaten".to_string(),
1550                        ahb_status: "X".to_string(),
1551                    }],
1552                    parent_group_ahb_status: None,
1553                    ..Default::default()
1554                },
1555                AhbFieldRule {
1556                    segment_path: "UNH/S009/0052".to_string(),
1557                    name: "Version".to_string(),
1558                    ahb_status: "X".to_string(),
1559                    codes: vec![AhbCodeRule {
1560                        value: "D".to_string(),
1561                        description: "Draft".to_string(),
1562                        ahb_status: "X".to_string(),
1563                    }],
1564                    parent_group_ahb_status: None,
1565                    ..Default::default()
1566                },
1567            ],
1568            ub_definitions: HashMap::new(),
1569        };
1570
1571        let report = validator.validate(
1572            &[unh_segment],
1573            &workflow,
1574            &external,
1575            ValidationLevel::Conditions,
1576        );
1577
1578        // Should NOT produce COD002 false positives for composite element paths
1579        let code_errors: Vec<_> = report
1580            .by_category(ValidationCategory::Code)
1581            .filter(|i| i.severity == Severity::Error)
1582            .collect();
1583        assert!(
1584            code_errors.is_empty(),
1585            "Expected no code errors for composite paths, got: {:?}",
1586            code_errors
1587        );
1588    }
1589
1590    #[test]
1591    fn test_cross_field_code_validation_valid_qualifiers() {
1592        // NAD/3035 has separate field rules: [MS] for sender, [MR] for receiver.
1593        // Cross-field validation unions them → {MS, MR}. Both segments are valid.
1594        let evaluator = MockEvaluator::new(vec![]);
1595        let validator = EdifactValidator::new(evaluator);
1596        let external = NoOpExternalProvider;
1597
1598        let nad_ms = OwnedSegment {
1599            id: "NAD".to_string(),
1600            elements: vec![vec!["MS".to_string()]],
1601            segment_number: 4,
1602        };
1603        let nad_mr = OwnedSegment {
1604            id: "NAD".to_string(),
1605            elements: vec![vec!["MR".to_string()]],
1606            segment_number: 5,
1607        };
1608
1609        let workflow = AhbWorkflow {
1610            pruefidentifikator: "55001".to_string(),
1611            description: "Test".to_string(),
1612            communication_direction: None,
1613            fields: vec![
1614                AhbFieldRule {
1615                    segment_path: "SG2/NAD/3035".to_string(),
1616                    name: "Absender".to_string(),
1617                    ahb_status: "X".to_string(),
1618                    codes: vec![AhbCodeRule {
1619                        value: "MS".to_string(),
1620                        description: "Absender".to_string(),
1621                        ahb_status: "X".to_string(),
1622                    }],
1623                    parent_group_ahb_status: None,
1624                    ..Default::default()
1625                },
1626                AhbFieldRule {
1627                    segment_path: "SG2/NAD/3035".to_string(),
1628                    name: "Empfaenger".to_string(),
1629                    ahb_status: "X".to_string(),
1630                    codes: vec![AhbCodeRule {
1631                        value: "MR".to_string(),
1632                        description: "Empfaenger".to_string(),
1633                        ahb_status: "X".to_string(),
1634                    }],
1635                    parent_group_ahb_status: None,
1636                    ..Default::default()
1637                },
1638            ],
1639            ub_definitions: HashMap::new(),
1640        };
1641
1642        let report = validator.validate(
1643            &[nad_ms, nad_mr],
1644            &workflow,
1645            &external,
1646            ValidationLevel::Conditions,
1647        );
1648
1649        let code_errors: Vec<_> = report
1650            .by_category(ValidationCategory::Code)
1651            .filter(|i| i.severity == Severity::Error)
1652            .collect();
1653        assert!(
1654            code_errors.is_empty(),
1655            "Expected no code errors for valid qualifiers, got: {:?}",
1656            code_errors
1657        );
1658    }
1659
1660    #[test]
1661    fn test_cross_field_code_validation_catches_invalid_qualifier() {
1662        // NAD+MT is not in the allowed set {MS, MR} → should produce COD002.
1663        let evaluator = MockEvaluator::new(vec![]);
1664        let validator = EdifactValidator::new(evaluator);
1665        let external = NoOpExternalProvider;
1666
1667        let nad_ms = OwnedSegment {
1668            id: "NAD".to_string(),
1669            elements: vec![vec!["MS".to_string()]],
1670            segment_number: 4,
1671        };
1672        let nad_mt = OwnedSegment {
1673            id: "NAD".to_string(),
1674            elements: vec![vec!["MT".to_string()]], // invalid
1675            segment_number: 5,
1676        };
1677
1678        let workflow = AhbWorkflow {
1679            pruefidentifikator: "55001".to_string(),
1680            description: "Test".to_string(),
1681            communication_direction: None,
1682            fields: vec![
1683                AhbFieldRule {
1684                    segment_path: "SG2/NAD/3035".to_string(),
1685                    name: "Absender".to_string(),
1686                    ahb_status: "X".to_string(),
1687                    codes: vec![AhbCodeRule {
1688                        value: "MS".to_string(),
1689                        description: "Absender".to_string(),
1690                        ahb_status: "X".to_string(),
1691                    }],
1692                    parent_group_ahb_status: None,
1693                    ..Default::default()
1694                },
1695                AhbFieldRule {
1696                    segment_path: "SG2/NAD/3035".to_string(),
1697                    name: "Empfaenger".to_string(),
1698                    ahb_status: "X".to_string(),
1699                    codes: vec![AhbCodeRule {
1700                        value: "MR".to_string(),
1701                        description: "Empfaenger".to_string(),
1702                        ahb_status: "X".to_string(),
1703                    }],
1704                    parent_group_ahb_status: None,
1705                    ..Default::default()
1706                },
1707            ],
1708            ub_definitions: HashMap::new(),
1709        };
1710
1711        let report = validator.validate(
1712            &[nad_ms, nad_mt],
1713            &workflow,
1714            &external,
1715            ValidationLevel::Conditions,
1716        );
1717
1718        let code_errors: Vec<_> = report
1719            .by_category(ValidationCategory::Code)
1720            .filter(|i| i.severity == Severity::Error)
1721            .collect();
1722        assert_eq!(code_errors.len(), 1, "Expected one COD002 error for MT");
1723        assert!(code_errors[0].message.contains("MT"));
1724        assert!(code_errors[0].message.contains("MR"));
1725        assert!(code_errors[0].message.contains("MS"));
1726    }
1727
1728    #[test]
1729    fn test_cross_field_code_validation_unions_across_groups() {
1730        // SG2/NAD/3035 allows {MS, MR}, SG4/SG12/NAD/3035 allows {Z04, Z09}.
1731        // Since find_segments("NAD") returns all NADs, codes must be unioned
1732        // by tag: {MS, MR, Z04, Z09}. NAD+MT should be caught, all others pass.
1733        let evaluator = MockEvaluator::new(vec![]);
1734        let validator = EdifactValidator::new(evaluator);
1735        let external = NoOpExternalProvider;
1736
1737        let segments = vec![
1738            OwnedSegment {
1739                id: "NAD".to_string(),
1740                elements: vec![vec!["MS".to_string()]],
1741                segment_number: 3,
1742            },
1743            OwnedSegment {
1744                id: "NAD".to_string(),
1745                elements: vec![vec!["MR".to_string()]],
1746                segment_number: 4,
1747            },
1748            OwnedSegment {
1749                id: "NAD".to_string(),
1750                elements: vec![vec!["Z04".to_string()]],
1751                segment_number: 20,
1752            },
1753            OwnedSegment {
1754                id: "NAD".to_string(),
1755                elements: vec![vec!["Z09".to_string()]],
1756                segment_number: 21,
1757            },
1758            OwnedSegment {
1759                id: "NAD".to_string(),
1760                elements: vec![vec!["MT".to_string()]], // invalid
1761                segment_number: 22,
1762            },
1763        ];
1764
1765        let workflow = AhbWorkflow {
1766            pruefidentifikator: "55001".to_string(),
1767            description: "Test".to_string(),
1768            communication_direction: None,
1769            fields: vec![
1770                AhbFieldRule {
1771                    segment_path: "SG2/NAD/3035".to_string(),
1772                    name: "Absender".to_string(),
1773                    ahb_status: "X".to_string(),
1774                    codes: vec![AhbCodeRule {
1775                        value: "MS".to_string(),
1776                        description: "Absender".to_string(),
1777                        ahb_status: "X".to_string(),
1778                    }],
1779                    parent_group_ahb_status: None,
1780                    ..Default::default()
1781                },
1782                AhbFieldRule {
1783                    segment_path: "SG2/NAD/3035".to_string(),
1784                    name: "Empfaenger".to_string(),
1785                    ahb_status: "X".to_string(),
1786                    codes: vec![AhbCodeRule {
1787                        value: "MR".to_string(),
1788                        description: "Empfaenger".to_string(),
1789                        ahb_status: "X".to_string(),
1790                    }],
1791                    parent_group_ahb_status: None,
1792                    ..Default::default()
1793                },
1794                AhbFieldRule {
1795                    segment_path: "SG4/SG12/NAD/3035".to_string(),
1796                    name: "Anschlussnutzer".to_string(),
1797                    ahb_status: "X".to_string(),
1798                    codes: vec![AhbCodeRule {
1799                        value: "Z04".to_string(),
1800                        description: "Anschlussnutzer".to_string(),
1801                        ahb_status: "X".to_string(),
1802                    }],
1803                    parent_group_ahb_status: None,
1804                    ..Default::default()
1805                },
1806                AhbFieldRule {
1807                    segment_path: "SG4/SG12/NAD/3035".to_string(),
1808                    name: "Korrespondenzanschrift".to_string(),
1809                    ahb_status: "X".to_string(),
1810                    codes: vec![AhbCodeRule {
1811                        value: "Z09".to_string(),
1812                        description: "Korrespondenzanschrift".to_string(),
1813                        ahb_status: "X".to_string(),
1814                    }],
1815                    parent_group_ahb_status: None,
1816                    ..Default::default()
1817                },
1818            ],
1819            ub_definitions: HashMap::new(),
1820        };
1821
1822        let report =
1823            validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
1824
1825        let code_errors: Vec<_> = report
1826            .by_category(ValidationCategory::Code)
1827            .filter(|i| i.severity == Severity::Error)
1828            .collect();
1829        assert_eq!(
1830            code_errors.len(),
1831            1,
1832            "Expected exactly one COD002 error for MT, got: {:?}",
1833            code_errors
1834        );
1835        assert!(code_errors[0].message.contains("MT"));
1836    }
1837
1838    #[test]
1839    fn test_is_qualifier_field_simple_paths() {
1840        assert!(is_qualifier_field("NAD/3035"));
1841        assert!(is_qualifier_field("SG2/NAD/3035"));
1842        assert!(is_qualifier_field("SG4/SG8/SEQ/6350"));
1843        assert!(is_qualifier_field("LOC/3227"));
1844    }
1845
1846    #[test]
1847    fn test_is_qualifier_field_composite_paths() {
1848        assert!(!is_qualifier_field("UNH/S009/0065"));
1849        assert!(!is_qualifier_field("NAD/C082/3039"));
1850        assert!(!is_qualifier_field("SG2/NAD/C082/3039"));
1851    }
1852
1853    #[test]
1854    fn test_is_qualifier_field_bare_segment() {
1855        assert!(!is_qualifier_field("NAD"));
1856        assert!(!is_qualifier_field("SG2/NAD"));
1857    }
1858
1859    #[test]
1860    fn test_missing_qualifier_instance_is_detected() {
1861        // NAD+MS is present but NAD+MR is missing.
1862        // The Empfaenger field requires [MR] → should produce AHB001.
1863        let evaluator = MockEvaluator::new(vec![]);
1864        let validator = EdifactValidator::new(evaluator);
1865        let external = NoOpExternalProvider;
1866
1867        let nad_ms = OwnedSegment {
1868            id: "NAD".to_string(),
1869            elements: vec![vec!["MS".to_string()]],
1870            segment_number: 3,
1871        };
1872
1873        let workflow = AhbWorkflow {
1874            pruefidentifikator: "55001".to_string(),
1875            description: "Test".to_string(),
1876            communication_direction: None,
1877            fields: vec![
1878                AhbFieldRule {
1879                    segment_path: "SG2/NAD/3035".to_string(),
1880                    name: "Absender".to_string(),
1881                    ahb_status: "X".to_string(),
1882                    codes: vec![AhbCodeRule {
1883                        value: "MS".to_string(),
1884                        description: "Absender".to_string(),
1885                        ahb_status: "X".to_string(),
1886                    }],
1887                    parent_group_ahb_status: None,
1888                    ..Default::default()
1889                },
1890                AhbFieldRule {
1891                    segment_path: "SG2/NAD/3035".to_string(),
1892                    name: "Empfaenger".to_string(),
1893                    ahb_status: "Muss".to_string(),
1894                    codes: vec![AhbCodeRule {
1895                        value: "MR".to_string(),
1896                        description: "Empfaenger".to_string(),
1897                        ahb_status: "X".to_string(),
1898                    }],
1899                    parent_group_ahb_status: None,
1900                    ..Default::default()
1901                },
1902            ],
1903            ub_definitions: HashMap::new(),
1904        };
1905
1906        let report =
1907            validator.validate(&[nad_ms], &workflow, &external, ValidationLevel::Conditions);
1908
1909        let ahb_errors: Vec<_> = report
1910            .by_category(ValidationCategory::Ahb)
1911            .filter(|i| i.severity == Severity::Error)
1912            .collect();
1913        assert_eq!(
1914            ahb_errors.len(),
1915            1,
1916            "Expected AHB001 for missing NAD+MR, got: {:?}",
1917            ahb_errors
1918        );
1919        assert!(ahb_errors[0].message.contains("Empfaenger"));
1920    }
1921
1922    #[test]
1923    fn test_present_qualifier_instance_no_error() {
1924        // Both NAD+MS and NAD+MR present → no AHB001 for either.
1925        let evaluator = MockEvaluator::new(vec![]);
1926        let validator = EdifactValidator::new(evaluator);
1927        let external = NoOpExternalProvider;
1928
1929        let segments = vec![
1930            OwnedSegment {
1931                id: "NAD".to_string(),
1932                elements: vec![vec!["MS".to_string()]],
1933                segment_number: 3,
1934            },
1935            OwnedSegment {
1936                id: "NAD".to_string(),
1937                elements: vec![vec!["MR".to_string()]],
1938                segment_number: 4,
1939            },
1940        ];
1941
1942        let workflow = AhbWorkflow {
1943            pruefidentifikator: "55001".to_string(),
1944            description: "Test".to_string(),
1945            communication_direction: None,
1946            fields: vec![
1947                AhbFieldRule {
1948                    segment_path: "SG2/NAD/3035".to_string(),
1949                    name: "Absender".to_string(),
1950                    ahb_status: "Muss".to_string(),
1951                    codes: vec![AhbCodeRule {
1952                        value: "MS".to_string(),
1953                        description: "Absender".to_string(),
1954                        ahb_status: "X".to_string(),
1955                    }],
1956                    parent_group_ahb_status: None,
1957                    ..Default::default()
1958                },
1959                AhbFieldRule {
1960                    segment_path: "SG2/NAD/3035".to_string(),
1961                    name: "Empfaenger".to_string(),
1962                    ahb_status: "Muss".to_string(),
1963                    codes: vec![AhbCodeRule {
1964                        value: "MR".to_string(),
1965                        description: "Empfaenger".to_string(),
1966                        ahb_status: "X".to_string(),
1967                    }],
1968                    parent_group_ahb_status: None,
1969                    ..Default::default()
1970                },
1971            ],
1972            ub_definitions: HashMap::new(),
1973        };
1974
1975        let report =
1976            validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
1977
1978        let ahb_errors: Vec<_> = report
1979            .by_category(ValidationCategory::Ahb)
1980            .filter(|i| i.severity == Severity::Error)
1981            .collect();
1982        assert!(
1983            ahb_errors.is_empty(),
1984            "Expected no AHB001 errors, got: {:?}",
1985            ahb_errors
1986        );
1987    }
1988
1989    #[test]
1990    fn test_extract_group_path_key() {
1991        assert_eq!(extract_group_path_key("SG2/NAD/3035"), "SG2");
1992        assert_eq!(extract_group_path_key("SG4/SG12/NAD/3035"), "SG4/SG12");
1993        assert_eq!(extract_group_path_key("NAD/3035"), "");
1994        assert_eq!(extract_group_path_key("SG4/SG8/SEQ/6350"), "SG4/SG8");
1995    }
1996
1997    #[test]
1998    fn test_absent_optional_group_no_missing_field_error() {
1999        // SG3 is optional ("Kann"). If SG3 is absent, its children CTA/3139
2000        // and CTA/C056/3412 should NOT produce AHB001 errors.
2001        use mig_types::navigator::GroupNavigator;
2002
2003        struct NavWithoutSG3;
2004        impl GroupNavigator for NavWithoutSG3 {
2005            fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
2006                vec![]
2007            }
2008            fn find_segments_with_qualifier_in_group(
2009                &self,
2010                _: &str,
2011                _: usize,
2012                _: &str,
2013                _: &[&str],
2014                _: usize,
2015            ) -> Vec<OwnedSegment> {
2016                vec![]
2017            }
2018            fn group_instance_count(&self, group_path: &[&str]) -> usize {
2019                match group_path {
2020                    ["SG2"] => 2,        // two NAD groups present
2021                    ["SG2", "SG3"] => 0, // SG3 is absent
2022                    _ => 0,
2023                }
2024            }
2025        }
2026
2027        let evaluator = MockEvaluator::new(vec![]);
2028        let validator = EdifactValidator::new(evaluator);
2029        let external = NoOpExternalProvider;
2030        let nav = NavWithoutSG3;
2031
2032        // Only NAD segments present, no CTA
2033        let segments = vec![
2034            OwnedSegment {
2035                id: "NAD".into(),
2036                elements: vec![vec!["MS".into()]],
2037                segment_number: 3,
2038            },
2039            OwnedSegment {
2040                id: "NAD".into(),
2041                elements: vec![vec!["MR".into()]],
2042                segment_number: 4,
2043            },
2044        ];
2045
2046        let workflow = AhbWorkflow {
2047            pruefidentifikator: "55001".to_string(),
2048            description: "Test".to_string(),
2049            communication_direction: None,
2050            fields: vec![
2051                AhbFieldRule {
2052                    segment_path: "SG2/SG3/CTA/3139".to_string(),
2053                    name: "Funktion des Ansprechpartners, Code".to_string(),
2054                    ahb_status: "Muss".to_string(),
2055                    codes: vec![],
2056                    parent_group_ahb_status: None,
2057                    ..Default::default()
2058                },
2059                AhbFieldRule {
2060                    segment_path: "SG2/SG3/CTA/C056/3412".to_string(),
2061                    name: "Name vom Ansprechpartner".to_string(),
2062                    ahb_status: "X".to_string(),
2063                    codes: vec![],
2064                    parent_group_ahb_status: None,
2065                    ..Default::default()
2066                },
2067            ],
2068            ub_definitions: HashMap::new(),
2069        };
2070
2071        let report = validator.validate_with_navigator(
2072            &segments,
2073            &workflow,
2074            &external,
2075            ValidationLevel::Conditions,
2076            &nav,
2077        );
2078
2079        let ahb_errors: Vec<_> = report
2080            .by_category(ValidationCategory::Ahb)
2081            .filter(|i| i.severity == Severity::Error)
2082            .collect();
2083        assert!(
2084            ahb_errors.is_empty(),
2085            "Expected no AHB001 errors when SG3 is absent, got: {:?}",
2086            ahb_errors
2087        );
2088    }
2089
2090    #[test]
2091    fn test_present_group_still_checks_mandatory_fields() {
2092        // If SG3 IS present but CTA is missing within it → AHB001 error.
2093        use mig_types::navigator::GroupNavigator;
2094
2095        struct NavWithSG3;
2096        impl GroupNavigator for NavWithSG3 {
2097            fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
2098                vec![]
2099            }
2100            fn find_segments_with_qualifier_in_group(
2101                &self,
2102                _: &str,
2103                _: usize,
2104                _: &str,
2105                _: &[&str],
2106                _: usize,
2107            ) -> Vec<OwnedSegment> {
2108                vec![]
2109            }
2110            fn group_instance_count(&self, group_path: &[&str]) -> usize {
2111                match group_path {
2112                    ["SG2"] => 1,
2113                    ["SG2", "SG3"] => 1, // SG3 is present
2114                    _ => 0,
2115                }
2116            }
2117        }
2118
2119        let evaluator = MockEvaluator::new(vec![]);
2120        let validator = EdifactValidator::new(evaluator);
2121        let external = NoOpExternalProvider;
2122        let nav = NavWithSG3;
2123
2124        // SG3 is present (nav says 1 instance) but CTA is not in flat segments
2125        let segments = vec![OwnedSegment {
2126            id: "NAD".into(),
2127            elements: vec![vec!["MS".into()]],
2128            segment_number: 3,
2129        }];
2130
2131        let workflow = AhbWorkflow {
2132            pruefidentifikator: "55001".to_string(),
2133            description: "Test".to_string(),
2134            communication_direction: None,
2135            fields: vec![AhbFieldRule {
2136                segment_path: "SG2/SG3/CTA/3139".to_string(),
2137                name: "Funktion des Ansprechpartners, Code".to_string(),
2138                ahb_status: "Muss".to_string(),
2139                codes: vec![],
2140                parent_group_ahb_status: None,
2141                ..Default::default()
2142            }],
2143            ub_definitions: HashMap::new(),
2144        };
2145
2146        let report = validator.validate_with_navigator(
2147            &segments,
2148            &workflow,
2149            &external,
2150            ValidationLevel::Conditions,
2151            &nav,
2152        );
2153
2154        let ahb_errors: Vec<_> = report
2155            .by_category(ValidationCategory::Ahb)
2156            .filter(|i| i.severity == Severity::Error)
2157            .collect();
2158        assert_eq!(
2159            ahb_errors.len(),
2160            1,
2161            "Expected AHB001 error when SG3 is present but CTA missing"
2162        );
2163        assert!(ahb_errors[0].message.contains("CTA"));
2164    }
2165
2166    #[test]
2167    fn test_missing_qualifier_with_navigator_is_detected() {
2168        // NAD+MS is in SG2 but NAD+MR is missing. With a navigator that
2169        // reports SG2 has 1 instance, the missing MR must still be flagged.
2170        use mig_types::navigator::GroupNavigator;
2171
2172        struct NavWithSG2;
2173        impl GroupNavigator for NavWithSG2 {
2174            fn find_segments_in_group(
2175                &self,
2176                segment_id: &str,
2177                group_path: &[&str],
2178                instance_index: usize,
2179            ) -> Vec<OwnedSegment> {
2180                if segment_id == "NAD" && group_path == ["SG2"] && instance_index == 0 {
2181                    vec![OwnedSegment {
2182                        id: "NAD".into(),
2183                        elements: vec![vec!["MS".into()]],
2184                        segment_number: 3,
2185                    }]
2186                } else {
2187                    vec![]
2188                }
2189            }
2190            fn find_segments_with_qualifier_in_group(
2191                &self,
2192                _: &str,
2193                _: usize,
2194                _: &str,
2195                _: &[&str],
2196                _: usize,
2197            ) -> Vec<OwnedSegment> {
2198                vec![]
2199            }
2200            fn group_instance_count(&self, group_path: &[&str]) -> usize {
2201                match group_path {
2202                    ["SG2"] => 1,
2203                    _ => 0,
2204                }
2205            }
2206        }
2207
2208        let evaluator = MockEvaluator::new(vec![]);
2209        let validator = EdifactValidator::new(evaluator);
2210        let external = NoOpExternalProvider;
2211        let nav = NavWithSG2;
2212
2213        let segments = vec![OwnedSegment {
2214            id: "NAD".into(),
2215            elements: vec![vec!["MS".into()]],
2216            segment_number: 3,
2217        }];
2218
2219        let workflow = AhbWorkflow {
2220            pruefidentifikator: "55001".to_string(),
2221            description: "Test".to_string(),
2222            communication_direction: None,
2223            fields: vec![
2224                AhbFieldRule {
2225                    segment_path: "SG2/NAD/3035".to_string(),
2226                    name: "Absender".to_string(),
2227                    ahb_status: "X".to_string(),
2228                    codes: vec![AhbCodeRule {
2229                        value: "MS".to_string(),
2230                        description: "Absender".to_string(),
2231                        ahb_status: "X".to_string(),
2232                    }],
2233                    parent_group_ahb_status: None,
2234                    ..Default::default()
2235                },
2236                AhbFieldRule {
2237                    segment_path: "SG2/NAD/3035".to_string(),
2238                    name: "Empfaenger".to_string(),
2239                    ahb_status: "Muss".to_string(),
2240                    codes: vec![AhbCodeRule {
2241                        value: "MR".to_string(),
2242                        description: "Empfaenger".to_string(),
2243                        ahb_status: "X".to_string(),
2244                    }],
2245                    parent_group_ahb_status: None,
2246                    ..Default::default()
2247                },
2248            ],
2249            ub_definitions: HashMap::new(),
2250        };
2251
2252        let report = validator.validate_with_navigator(
2253            &segments,
2254            &workflow,
2255            &external,
2256            ValidationLevel::Conditions,
2257            &nav,
2258        );
2259
2260        let ahb_errors: Vec<_> = report
2261            .by_category(ValidationCategory::Ahb)
2262            .filter(|i| i.severity == Severity::Error)
2263            .collect();
2264        assert_eq!(
2265            ahb_errors.len(),
2266            1,
2267            "Expected AHB001 for missing NAD+MR even with navigator, got: {:?}",
2268            ahb_errors
2269        );
2270        assert!(ahb_errors[0].message.contains("Empfaenger"));
2271    }
2272
2273    #[test]
2274    fn test_optional_group_variant_absent_no_error() {
2275        // SG5 is "Kann" (optional) with LOC+Z16 present but LOC+Z17 absent.
2276        // Field rules for LOC/3227 with Z17 and its children should NOT error
2277        // because the parent group is optional and the variant is absent.
2278        // Meanwhile, SG2 is "Muss" — missing NAD+MR MUST still error.
2279        use mig_types::navigator::GroupNavigator;
2280
2281        struct TestNav;
2282        impl GroupNavigator for TestNav {
2283            fn find_segments_in_group(
2284                &self,
2285                segment_id: &str,
2286                group_path: &[&str],
2287                instance_index: usize,
2288            ) -> Vec<OwnedSegment> {
2289                match (segment_id, group_path, instance_index) {
2290                    ("LOC", ["SG4", "SG5"], 0) => vec![OwnedSegment {
2291                        id: "LOC".into(),
2292                        elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
2293                        segment_number: 10,
2294                    }],
2295                    ("NAD", ["SG2"], 0) => vec![OwnedSegment {
2296                        id: "NAD".into(),
2297                        elements: vec![vec!["MS".into()]],
2298                        segment_number: 3,
2299                    }],
2300                    _ => vec![],
2301                }
2302            }
2303            fn find_segments_with_qualifier_in_group(
2304                &self,
2305                _: &str,
2306                _: usize,
2307                _: &str,
2308                _: &[&str],
2309                _: usize,
2310            ) -> Vec<OwnedSegment> {
2311                vec![]
2312            }
2313            fn group_instance_count(&self, group_path: &[&str]) -> usize {
2314                match group_path {
2315                    ["SG2"] => 1,
2316                    ["SG4"] => 1,
2317                    ["SG4", "SG5"] => 1, // only Z16 instance
2318                    _ => 0,
2319                }
2320            }
2321        }
2322
2323        let evaluator = MockEvaluator::new(vec![]);
2324        let validator = EdifactValidator::new(evaluator);
2325        let external = NoOpExternalProvider;
2326        let nav = TestNav;
2327
2328        let segments = vec![
2329            OwnedSegment {
2330                id: "NAD".into(),
2331                elements: vec![vec!["MS".into()]],
2332                segment_number: 3,
2333            },
2334            OwnedSegment {
2335                id: "LOC".into(),
2336                elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
2337                segment_number: 10,
2338            },
2339        ];
2340
2341        let workflow = AhbWorkflow {
2342            pruefidentifikator: "55001".to_string(),
2343            description: "Test".to_string(),
2344            communication_direction: None,
2345            fields: vec![
2346                // SG2 "Muss" — NAD+MS present, NAD+MR missing → should error
2347                AhbFieldRule {
2348                    segment_path: "SG2/NAD/3035".to_string(),
2349                    name: "Absender".to_string(),
2350                    ahb_status: "X".to_string(),
2351                    codes: vec![AhbCodeRule {
2352                        value: "MS".to_string(),
2353                        description: "Absender".to_string(),
2354                        ahb_status: "X".to_string(),
2355                    }],
2356                    parent_group_ahb_status: Some("Muss".to_string()),
2357                    ..Default::default()
2358                },
2359                AhbFieldRule {
2360                    segment_path: "SG2/NAD/3035".to_string(),
2361                    name: "Empfaenger".to_string(),
2362                    ahb_status: "Muss".to_string(),
2363                    codes: vec![AhbCodeRule {
2364                        value: "MR".to_string(),
2365                        description: "Empfaenger".to_string(),
2366                        ahb_status: "X".to_string(),
2367                    }],
2368                    parent_group_ahb_status: Some("Muss".to_string()),
2369                    ..Default::default()
2370                },
2371                // SG5 "Kann" — LOC+Z16 present, LOC+Z17 absent → should NOT error
2372                AhbFieldRule {
2373                    segment_path: "SG4/SG5/LOC/3227".to_string(),
2374                    name: "Ortsangabe, Qualifier (Z16)".to_string(),
2375                    ahb_status: "X".to_string(),
2376                    codes: vec![AhbCodeRule {
2377                        value: "Z16".to_string(),
2378                        description: "Marktlokation".to_string(),
2379                        ahb_status: "X".to_string(),
2380                    }],
2381                    parent_group_ahb_status: Some("Kann".to_string()),
2382                    ..Default::default()
2383                },
2384                AhbFieldRule {
2385                    segment_path: "SG4/SG5/LOC/3227".to_string(),
2386                    name: "Ortsangabe, Qualifier (Z17)".to_string(),
2387                    ahb_status: "Muss".to_string(),
2388                    codes: vec![AhbCodeRule {
2389                        value: "Z17".to_string(),
2390                        description: "Messlokation".to_string(),
2391                        ahb_status: "X".to_string(),
2392                    }],
2393                    parent_group_ahb_status: Some("Kann".to_string()),
2394                    ..Default::default()
2395                },
2396            ],
2397            ub_definitions: HashMap::new(),
2398        };
2399
2400        let report = validator.validate_with_navigator(
2401            &segments,
2402            &workflow,
2403            &external,
2404            ValidationLevel::Conditions,
2405            &nav,
2406        );
2407
2408        let ahb_errors: Vec<_> = report
2409            .by_category(ValidationCategory::Ahb)
2410            .filter(|i| i.severity == Severity::Error)
2411            .collect();
2412
2413        // Exactly 1 error: NAD+MR missing (mandatory group)
2414        // LOC+Z17 should NOT error (optional group variant absent)
2415        assert_eq!(
2416            ahb_errors.len(),
2417            1,
2418            "Expected only AHB001 for missing NAD+MR, got: {:?}",
2419            ahb_errors
2420        );
2421        assert!(
2422            ahb_errors[0].message.contains("Empfaenger"),
2423            "Error should be for missing NAD+MR (Empfaenger)"
2424        );
2425    }
2426
2427    #[test]
2428    fn test_conditional_group_variant_absent_no_error() {
2429        // Real-world scenario: SG5 "Messlokation" has AHB_Status="Soll [165]".
2430        // Condition [165] evaluates to False → LOC+Z17 should NOT error.
2431        // SG5 "Marktlokation" has AHB_Status="Muss [2061]".
2432        // Condition [2061] evaluates to True → LOC+Z16 is present → no error.
2433        use mig_types::navigator::GroupNavigator;
2434
2435        struct TestNav;
2436        impl GroupNavigator for TestNav {
2437            fn find_segments_in_group(
2438                &self,
2439                segment_id: &str,
2440                group_path: &[&str],
2441                instance_index: usize,
2442            ) -> Vec<OwnedSegment> {
2443                if segment_id == "LOC" && group_path == ["SG4", "SG5"] && instance_index == 0 {
2444                    vec![OwnedSegment {
2445                        id: "LOC".into(),
2446                        elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
2447                        segment_number: 10,
2448                    }]
2449                } else {
2450                    vec![]
2451                }
2452            }
2453            fn find_segments_with_qualifier_in_group(
2454                &self,
2455                _: &str,
2456                _: usize,
2457                _: &str,
2458                _: &[&str],
2459                _: usize,
2460            ) -> Vec<OwnedSegment> {
2461                vec![]
2462            }
2463            fn group_instance_count(&self, group_path: &[&str]) -> usize {
2464                match group_path {
2465                    ["SG4"] => 1,
2466                    ["SG4", "SG5"] => 1, // only Z16 instance
2467                    _ => 0,
2468                }
2469            }
2470        }
2471
2472        // Condition 165 → False (Messlokation not required)
2473        // Condition 2061 → True (Marktlokation required)
2474        let evaluator = MockEvaluator::new(vec![(165, CR::False), (2061, CR::True)]);
2475        let validator = EdifactValidator::new(evaluator);
2476        let external = NoOpExternalProvider;
2477        let nav = TestNav;
2478
2479        let segments = vec![OwnedSegment {
2480            id: "LOC".into(),
2481            elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
2482            segment_number: 10,
2483        }];
2484
2485        let workflow = AhbWorkflow {
2486            pruefidentifikator: "55001".to_string(),
2487            description: "Test".to_string(),
2488            communication_direction: None,
2489            fields: vec![
2490                // SG5 "Muss [2061]" with [2061]=True → LOC+Z16 required, present → OK
2491                AhbFieldRule {
2492                    segment_path: "SG4/SG5/LOC/3227".to_string(),
2493                    name: "Ortsangabe, Qualifier (Z16)".to_string(),
2494                    ahb_status: "X".to_string(),
2495                    codes: vec![AhbCodeRule {
2496                        value: "Z16".to_string(),
2497                        description: "Marktlokation".to_string(),
2498                        ahb_status: "X".to_string(),
2499                    }],
2500                    parent_group_ahb_status: Some("Muss [2061]".to_string()),
2501                    ..Default::default()
2502                },
2503                // SG5 "Soll [165]" with [165]=False → LOC+Z17 NOT required → skip
2504                AhbFieldRule {
2505                    segment_path: "SG4/SG5/LOC/3227".to_string(),
2506                    name: "Ortsangabe, Qualifier (Z17)".to_string(),
2507                    ahb_status: "X".to_string(),
2508                    codes: vec![AhbCodeRule {
2509                        value: "Z17".to_string(),
2510                        description: "Messlokation".to_string(),
2511                        ahb_status: "X".to_string(),
2512                    }],
2513                    parent_group_ahb_status: Some("Soll [165]".to_string()),
2514                    ..Default::default()
2515                },
2516            ],
2517            ub_definitions: HashMap::new(),
2518        };
2519
2520        let report = validator.validate_with_navigator(
2521            &segments,
2522            &workflow,
2523            &external,
2524            ValidationLevel::Conditions,
2525            &nav,
2526        );
2527
2528        let ahb_errors: Vec<_> = report
2529            .by_category(ValidationCategory::Ahb)
2530            .filter(|i| i.severity == Severity::Error)
2531            .collect();
2532
2533        // Zero errors: Z16 is present, and Z17's group condition is False
2534        assert!(
2535            ahb_errors.is_empty(),
2536            "Expected no errors when conditional group variant [165]=False, got: {:?}",
2537            ahb_errors
2538        );
2539    }
2540
2541    #[test]
2542    fn test_conditional_group_variant_unknown_no_error() {
2543        // When a parent group condition evaluates to Unknown (unimplemented
2544        // condition), child fields should NOT produce mandatory-missing errors.
2545        // The group-level entry itself will produce an Info "condition unknown".
2546
2547        // Condition 165 is NOT in the evaluator → returns Unknown
2548        let evaluator = MockEvaluator::new(vec![]);
2549        let validator = EdifactValidator::new(evaluator);
2550        let external = NoOpExternalProvider;
2551
2552        let workflow = AhbWorkflow {
2553            pruefidentifikator: "55001".to_string(),
2554            description: "Test".to_string(),
2555            communication_direction: None,
2556            fields: vec![AhbFieldRule {
2557                segment_path: "SG4/SG5/LOC/3227".to_string(),
2558                name: "Ortsangabe, Qualifier (Z17)".to_string(),
2559                ahb_status: "X".to_string(),
2560                codes: vec![AhbCodeRule {
2561                    value: "Z17".to_string(),
2562                    description: "Messlokation".to_string(),
2563                    ahb_status: "X".to_string(),
2564                }],
2565                parent_group_ahb_status: Some("Soll [165]".to_string()),
2566                ..Default::default()
2567            }],
2568            ub_definitions: HashMap::new(),
2569        };
2570
2571        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2572
2573        let ahb_errors: Vec<_> = report
2574            .by_category(ValidationCategory::Ahb)
2575            .filter(|i| i.severity == Severity::Error)
2576            .collect();
2577
2578        // No errors: parent group condition [165] is Unknown → skip child fields
2579        assert!(
2580            ahb_errors.is_empty(),
2581            "Expected no errors when parent group condition is Unknown, got: {:?}",
2582            ahb_errors
2583        );
2584    }
2585
2586    #[test]
2587    fn test_segment_absent_within_present_group_no_error() {
2588        // MSCONS scenario: SG10 has QTY (entry) + optional STS segments.
2589        // SG10 is present (QTY+220 exists) but STS is absent.
2590        // Fields under STS should NOT produce AHB001 errors.
2591        use mig_types::navigator::GroupNavigator;
2592
2593        struct TestNav;
2594        impl GroupNavigator for TestNav {
2595            fn find_segments_in_group(
2596                &self,
2597                segment_id: &str,
2598                group_path: &[&str],
2599                instance_index: usize,
2600            ) -> Vec<OwnedSegment> {
2601                // SG10 has QTY but no STS
2602                if segment_id == "QTY"
2603                    && group_path == ["SG5", "SG6", "SG9", "SG10"]
2604                    && instance_index == 0
2605                {
2606                    vec![OwnedSegment {
2607                        id: "QTY".into(),
2608                        elements: vec![vec!["220".into(), "0".into()]],
2609                        segment_number: 14,
2610                    }]
2611                } else {
2612                    vec![]
2613                }
2614            }
2615            fn find_segments_with_qualifier_in_group(
2616                &self,
2617                _: &str,
2618                _: usize,
2619                _: &str,
2620                _: &[&str],
2621                _: usize,
2622            ) -> Vec<OwnedSegment> {
2623                vec![]
2624            }
2625            fn group_instance_count(&self, group_path: &[&str]) -> usize {
2626                match group_path {
2627                    ["SG5"] => 1,
2628                    ["SG5", "SG6"] => 1,
2629                    ["SG5", "SG6", "SG9"] => 1,
2630                    ["SG5", "SG6", "SG9", "SG10"] => 1,
2631                    _ => 0,
2632                }
2633            }
2634            fn has_any_segment_in_group(&self, group_path: &[&str], instance_index: usize) -> bool {
2635                // SG10 instance 0 has QTY (the entry segment)
2636                group_path == ["SG5", "SG6", "SG9", "SG10"] && instance_index == 0
2637            }
2638        }
2639
2640        let evaluator = MockEvaluator::all_true(&[]);
2641        let validator = EdifactValidator::new(evaluator);
2642        let external = NoOpExternalProvider;
2643        let nav = TestNav;
2644
2645        let segments = vec![OwnedSegment {
2646            id: "QTY".into(),
2647            elements: vec![vec!["220".into(), "0".into()]],
2648            segment_number: 14,
2649        }];
2650
2651        let workflow = AhbWorkflow {
2652            pruefidentifikator: "13017".to_string(),
2653            description: "Test".to_string(),
2654            communication_direction: None,
2655            fields: vec![
2656                // STS/C601/9015 — mandatory field under STS, but STS is absent from SG10
2657                AhbFieldRule {
2658                    segment_path: "SG5/SG6/SG9/SG10/STS/C601/9015".to_string(),
2659                    name: "Statuskategorie, Code".to_string(),
2660                    ahb_status: "X".to_string(),
2661                    codes: vec![],
2662                    parent_group_ahb_status: Some("Muss".to_string()),
2663                    ..Default::default()
2664                },
2665                // STS/C556/9013 — another field under STS
2666                AhbFieldRule {
2667                    segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
2668                    name: "Statusanlaß, Code".to_string(),
2669                    ahb_status: "X [5]".to_string(),
2670                    codes: vec![],
2671                    parent_group_ahb_status: Some("Muss".to_string()),
2672                    ..Default::default()
2673                },
2674            ],
2675            ub_definitions: HashMap::new(),
2676        };
2677
2678        let report = validator.validate_with_navigator(
2679            &segments,
2680            &workflow,
2681            &external,
2682            ValidationLevel::Conditions,
2683            &nav,
2684        );
2685
2686        let ahb_errors: Vec<_> = report
2687            .by_category(ValidationCategory::Ahb)
2688            .filter(|i| i.severity == Severity::Error)
2689            .collect();
2690
2691        assert!(
2692            ahb_errors.is_empty(),
2693            "Expected no AHB001 errors when STS segment is absent from SG10, got: {:?}",
2694            ahb_errors
2695        );
2696    }
2697
2698    #[test]
2699    fn test_group_scoped_code_validation_with_navigator() {
2700        // With a navigator, SG2/NAD is checked against {MS, MR} only,
2701        // and SG4/SG12/NAD is checked against {Z04, Z09} only.
2702        // NAD+MT in SG2 → error with allowed [MR, MS] (not the full union).
2703        use mig_types::navigator::GroupNavigator;
2704
2705        struct TestNav;
2706        impl GroupNavigator for TestNav {
2707            fn find_segments_in_group(
2708                &self,
2709                segment_id: &str,
2710                group_path: &[&str],
2711                _instance_index: usize,
2712            ) -> Vec<OwnedSegment> {
2713                if segment_id != "NAD" {
2714                    return vec![];
2715                }
2716                match group_path {
2717                    ["SG2"] => vec![
2718                        OwnedSegment {
2719                            id: "NAD".into(),
2720                            elements: vec![vec!["MS".into()]],
2721                            segment_number: 3,
2722                        },
2723                        OwnedSegment {
2724                            id: "NAD".into(),
2725                            elements: vec![vec!["MT".into()]], // invalid in SG2
2726                            segment_number: 4,
2727                        },
2728                    ],
2729                    ["SG4", "SG12"] => vec![
2730                        OwnedSegment {
2731                            id: "NAD".into(),
2732                            elements: vec![vec!["Z04".into()]],
2733                            segment_number: 20,
2734                        },
2735                        OwnedSegment {
2736                            id: "NAD".into(),
2737                            elements: vec![vec!["Z09".into()]],
2738                            segment_number: 21,
2739                        },
2740                    ],
2741                    _ => vec![],
2742                }
2743            }
2744            fn find_segments_with_qualifier_in_group(
2745                &self,
2746                _: &str,
2747                _: usize,
2748                _: &str,
2749                _: &[&str],
2750                _: usize,
2751            ) -> Vec<OwnedSegment> {
2752                vec![]
2753            }
2754            fn group_instance_count(&self, group_path: &[&str]) -> usize {
2755                match group_path {
2756                    ["SG2"] | ["SG4", "SG12"] => 1,
2757                    _ => 0,
2758                }
2759            }
2760        }
2761
2762        let evaluator = MockEvaluator::new(vec![]);
2763        let validator = EdifactValidator::new(evaluator);
2764        let external = NoOpExternalProvider;
2765        let nav = TestNav;
2766
2767        let workflow = AhbWorkflow {
2768            pruefidentifikator: "55001".to_string(),
2769            description: "Test".to_string(),
2770            communication_direction: None,
2771            fields: vec![
2772                AhbFieldRule {
2773                    segment_path: "SG2/NAD/3035".to_string(),
2774                    name: "Absender".to_string(),
2775                    ahb_status: "X".to_string(),
2776                    codes: vec![AhbCodeRule {
2777                        value: "MS".to_string(),
2778                        description: "Absender".to_string(),
2779                        ahb_status: "X".to_string(),
2780                    }],
2781                    parent_group_ahb_status: None,
2782                    ..Default::default()
2783                },
2784                AhbFieldRule {
2785                    segment_path: "SG2/NAD/3035".to_string(),
2786                    name: "Empfaenger".to_string(),
2787                    ahb_status: "X".to_string(),
2788                    codes: vec![AhbCodeRule {
2789                        value: "MR".to_string(),
2790                        description: "Empfaenger".to_string(),
2791                        ahb_status: "X".to_string(),
2792                    }],
2793                    parent_group_ahb_status: None,
2794                    ..Default::default()
2795                },
2796                AhbFieldRule {
2797                    segment_path: "SG4/SG12/NAD/3035".to_string(),
2798                    name: "Anschlussnutzer".to_string(),
2799                    ahb_status: "X".to_string(),
2800                    codes: vec![AhbCodeRule {
2801                        value: "Z04".to_string(),
2802                        description: "Anschlussnutzer".to_string(),
2803                        ahb_status: "X".to_string(),
2804                    }],
2805                    parent_group_ahb_status: None,
2806                    ..Default::default()
2807                },
2808                AhbFieldRule {
2809                    segment_path: "SG4/SG12/NAD/3035".to_string(),
2810                    name: "Korrespondenzanschrift".to_string(),
2811                    ahb_status: "X".to_string(),
2812                    codes: vec![AhbCodeRule {
2813                        value: "Z09".to_string(),
2814                        description: "Korrespondenzanschrift".to_string(),
2815                        ahb_status: "X".to_string(),
2816                    }],
2817                    parent_group_ahb_status: None,
2818                    ..Default::default()
2819                },
2820            ],
2821            ub_definitions: HashMap::new(),
2822        };
2823
2824        // All segments flat (for condition evaluation), navigator provides group scope.
2825        let all_segments = vec![
2826            OwnedSegment {
2827                id: "NAD".into(),
2828                elements: vec![vec!["MS".into()]],
2829                segment_number: 3,
2830            },
2831            OwnedSegment {
2832                id: "NAD".into(),
2833                elements: vec![vec!["MT".into()]],
2834                segment_number: 4,
2835            },
2836            OwnedSegment {
2837                id: "NAD".into(),
2838                elements: vec![vec!["Z04".into()]],
2839                segment_number: 20,
2840            },
2841            OwnedSegment {
2842                id: "NAD".into(),
2843                elements: vec![vec!["Z09".into()]],
2844                segment_number: 21,
2845            },
2846        ];
2847
2848        let report = validator.validate_with_navigator(
2849            &all_segments,
2850            &workflow,
2851            &external,
2852            ValidationLevel::Conditions,
2853            &nav,
2854        );
2855
2856        let code_errors: Vec<_> = report
2857            .by_category(ValidationCategory::Code)
2858            .filter(|i| i.severity == Severity::Error)
2859            .collect();
2860
2861        // Only one error: MT in SG2 (not allowed in {MS, MR}).
2862        // Z04 and Z09 are NOT checked against {MS, MR} — group-scoped.
2863        assert_eq!(
2864            code_errors.len(),
2865            1,
2866            "Expected exactly one COD002 error for MT in SG2, got: {:?}",
2867            code_errors
2868        );
2869        assert!(code_errors[0].message.contains("MT"));
2870        // Error should show only SG2's allowed codes, not the full union
2871        assert!(code_errors[0].message.contains("MR"));
2872        assert!(code_errors[0].message.contains("MS"));
2873        assert!(
2874            !code_errors[0].message.contains("Z04"),
2875            "SG4/SG12 codes should not leak into SG2 error"
2876        );
2877        // Field path should include the group
2878        assert!(
2879            code_errors[0]
2880                .field_path
2881                .as_deref()
2882                .unwrap_or("")
2883                .contains("SG2"),
2884            "Error field_path should reference SG2, got: {:?}",
2885            code_errors[0].field_path
2886        );
2887    }
2888
2889    // === Package cardinality tests ===
2890
2891    #[test]
2892    fn test_package_cardinality_within_bounds() {
2893        // 1 code present from package [4P0..1], max=1 -> OK
2894        let evaluator = MockEvaluator::all_true(&[]);
2895        let validator = EdifactValidator::new(evaluator);
2896        let external = NoOpExternalProvider;
2897
2898        let segments = vec![OwnedSegment {
2899            id: "STS".into(),
2900            elements: vec![
2901                vec!["Z33".into()], // element[0]
2902                vec![],             // element[1]
2903                vec!["E01".into()], // element[2], component[0]
2904            ],
2905            segment_number: 5,
2906        }];
2907
2908        let workflow = AhbWorkflow {
2909            pruefidentifikator: "13017".to_string(),
2910            description: "Test".to_string(),
2911            communication_direction: None,
2912            ub_definitions: HashMap::new(),
2913            fields: vec![AhbFieldRule {
2914                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
2915                name: "Statusanlaß, Code".to_string(),
2916                ahb_status: "X".to_string(),
2917                element_index: Some(2),
2918                component_index: Some(0),
2919                codes: vec![
2920                    AhbCodeRule {
2921                        value: "E01".into(),
2922                        description: "Code 1".into(),
2923                        ahb_status: "X [4P0..1]".into(),
2924                    },
2925                    AhbCodeRule {
2926                        value: "E02".into(),
2927                        description: "Code 2".into(),
2928                        ahb_status: "X [4P0..1]".into(),
2929                    },
2930                ],
2931                parent_group_ahb_status: Some("Muss".to_string()),
2932                mig_number: None,
2933            }],
2934        };
2935
2936        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
2937        let pkg_errors: Vec<_> = report
2938            .by_category(ValidationCategory::Ahb)
2939            .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
2940            .collect();
2941        assert!(
2942            pkg_errors.is_empty(),
2943            "1 code within [4P0..1] bounds — no error expected, got: {:?}",
2944            pkg_errors
2945        );
2946    }
2947
2948    #[test]
2949    fn test_package_cardinality_zero_present_min_zero() {
2950        // No codes from the package present, min=0 -> OK
2951        let evaluator = MockEvaluator::all_true(&[]);
2952        let validator = EdifactValidator::new(evaluator);
2953        let external = NoOpExternalProvider;
2954
2955        let segments = vec![OwnedSegment {
2956            id: "STS".into(),
2957            elements: vec![
2958                vec!["Z33".into()],
2959                vec![],
2960                vec!["X99".into()], // X99 not in package
2961            ],
2962            segment_number: 5,
2963        }];
2964
2965        let workflow = AhbWorkflow {
2966            pruefidentifikator: "13017".to_string(),
2967            description: "Test".to_string(),
2968            communication_direction: None,
2969            ub_definitions: HashMap::new(),
2970            fields: vec![AhbFieldRule {
2971                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
2972                name: "Statusanlaß, Code".to_string(),
2973                ahb_status: "X".to_string(),
2974                element_index: Some(2),
2975                component_index: Some(0),
2976                codes: vec![
2977                    AhbCodeRule {
2978                        value: "E01".into(),
2979                        description: "Code 1".into(),
2980                        ahb_status: "X [4P0..1]".into(),
2981                    },
2982                    AhbCodeRule {
2983                        value: "E02".into(),
2984                        description: "Code 2".into(),
2985                        ahb_status: "X [4P0..1]".into(),
2986                    },
2987                ],
2988                parent_group_ahb_status: Some("Muss".to_string()),
2989                mig_number: None,
2990            }],
2991        };
2992
2993        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
2994        let pkg_errors: Vec<_> = report
2995            .by_category(ValidationCategory::Ahb)
2996            .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
2997            .collect();
2998        assert!(
2999            pkg_errors.is_empty(),
3000            "0 codes, min=0 — no error expected, got: {:?}",
3001            pkg_errors
3002        );
3003    }
3004
3005    #[test]
3006    fn test_package_cardinality_too_many() {
3007        // 2 codes present from package [4P0..1], max=1 -> ERROR
3008        let evaluator = MockEvaluator::all_true(&[]);
3009        let validator = EdifactValidator::new(evaluator);
3010        let external = NoOpExternalProvider;
3011
3012        // Two STS segments, each with a different package code
3013        let segments = vec![
3014            OwnedSegment {
3015                id: "STS".into(),
3016                elements: vec![vec!["Z33".into()], vec![], vec!["E01".into()]],
3017                segment_number: 5,
3018            },
3019            OwnedSegment {
3020                id: "STS".into(),
3021                elements: vec![vec!["Z33".into()], vec![], vec!["E02".into()]],
3022                segment_number: 6,
3023            },
3024        ];
3025
3026        let workflow = AhbWorkflow {
3027            pruefidentifikator: "13017".to_string(),
3028            description: "Test".to_string(),
3029            communication_direction: None,
3030            ub_definitions: HashMap::new(),
3031            fields: vec![AhbFieldRule {
3032                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
3033                name: "Statusanlaß, Code".to_string(),
3034                ahb_status: "X".to_string(),
3035                element_index: Some(2),
3036                component_index: Some(0),
3037                codes: vec![
3038                    AhbCodeRule {
3039                        value: "E01".into(),
3040                        description: "Code 1".into(),
3041                        ahb_status: "X [4P0..1]".into(),
3042                    },
3043                    AhbCodeRule {
3044                        value: "E02".into(),
3045                        description: "Code 2".into(),
3046                        ahb_status: "X [4P0..1]".into(),
3047                    },
3048                ],
3049                parent_group_ahb_status: Some("Muss".to_string()),
3050                mig_number: None,
3051            }],
3052        };
3053
3054        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
3055        let pkg_errors: Vec<_> = report
3056            .by_category(ValidationCategory::Ahb)
3057            .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
3058            .collect();
3059        assert_eq!(
3060            pkg_errors.len(),
3061            1,
3062            "2 codes present, max=1 — expected 1 error, got: {:?}",
3063            pkg_errors
3064        );
3065        assert!(pkg_errors[0].message.contains("[4P0..1]"));
3066        assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("2"));
3067        assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("0..1"));
3068    }
3069
3070    #[test]
3071    fn test_package_cardinality_too_few() {
3072        // 0 codes present from package [5P1..3], min=1 -> ERROR
3073        let evaluator = MockEvaluator::all_true(&[]);
3074        let validator = EdifactValidator::new(evaluator);
3075        let external = NoOpExternalProvider;
3076
3077        let segments = vec![OwnedSegment {
3078            id: "STS".into(),
3079            elements: vec![
3080                vec!["Z33".into()],
3081                vec![],
3082                vec!["X99".into()], // not in package
3083            ],
3084            segment_number: 5,
3085        }];
3086
3087        let workflow = AhbWorkflow {
3088            pruefidentifikator: "13017".to_string(),
3089            description: "Test".to_string(),
3090            communication_direction: None,
3091            ub_definitions: HashMap::new(),
3092            fields: vec![AhbFieldRule {
3093                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
3094                name: "Statusanlaß, Code".to_string(),
3095                ahb_status: "X".to_string(),
3096                element_index: Some(2),
3097                component_index: Some(0),
3098                codes: vec![
3099                    AhbCodeRule {
3100                        value: "E01".into(),
3101                        description: "Code 1".into(),
3102                        ahb_status: "X [5P1..3]".into(),
3103                    },
3104                    AhbCodeRule {
3105                        value: "E02".into(),
3106                        description: "Code 2".into(),
3107                        ahb_status: "X [5P1..3]".into(),
3108                    },
3109                    AhbCodeRule {
3110                        value: "E03".into(),
3111                        description: "Code 3".into(),
3112                        ahb_status: "X [5P1..3]".into(),
3113                    },
3114                ],
3115                parent_group_ahb_status: Some("Muss".to_string()),
3116                mig_number: None,
3117            }],
3118        };
3119
3120        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
3121        let pkg_errors: Vec<_> = report
3122            .by_category(ValidationCategory::Ahb)
3123            .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
3124            .collect();
3125        assert_eq!(
3126            pkg_errors.len(),
3127            1,
3128            "0 codes present, min=1 — expected 1 error, got: {:?}",
3129            pkg_errors
3130        );
3131        assert!(pkg_errors[0].message.contains("[5P1..3]"));
3132        assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("0"));
3133        assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("1..3"));
3134    }
3135
3136    #[test]
3137    fn test_package_cardinality_no_packages_in_workflow() {
3138        // Codes without package annotations -> no package errors
3139        let evaluator = MockEvaluator::all_true(&[]);
3140        let validator = EdifactValidator::new(evaluator);
3141        let external = NoOpExternalProvider;
3142
3143        let segments = vec![OwnedSegment {
3144            id: "STS".into(),
3145            elements: vec![vec!["E01".into()]],
3146            segment_number: 5,
3147        }];
3148
3149        let workflow = AhbWorkflow {
3150            pruefidentifikator: "13017".to_string(),
3151            description: "Test".to_string(),
3152            communication_direction: None,
3153            ub_definitions: HashMap::new(),
3154            fields: vec![AhbFieldRule {
3155                segment_path: "STS/9015".to_string(),
3156                name: "Status Code".to_string(),
3157                ahb_status: "X".to_string(),
3158                codes: vec![AhbCodeRule {
3159                    value: "E01".into(),
3160                    description: "Code 1".into(),
3161                    ahb_status: "X".into(),
3162                }],
3163                parent_group_ahb_status: Some("Muss".to_string()),
3164                ..Default::default()
3165            }],
3166        };
3167
3168        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
3169        let pkg_errors: Vec<_> = report
3170            .by_category(ValidationCategory::Ahb)
3171            .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
3172            .collect();
3173        assert!(
3174            pkg_errors.is_empty(),
3175            "No packages in workflow — no errors expected"
3176        );
3177    }
3178
3179    #[test]
3180    fn test_package_cardinality_with_condition_and_package() {
3181        // Code status "X [901] [4P0..1]" has both condition and package
3182        let evaluator = MockEvaluator::all_true(&[901]);
3183        let validator = EdifactValidator::new(evaluator);
3184        let external = NoOpExternalProvider;
3185
3186        let segments = vec![OwnedSegment {
3187            id: "STS".into(),
3188            elements: vec![vec![], vec![], vec!["E01".into()]],
3189            segment_number: 5,
3190        }];
3191
3192        let workflow = AhbWorkflow {
3193            pruefidentifikator: "13017".to_string(),
3194            description: "Test".to_string(),
3195            communication_direction: None,
3196            ub_definitions: HashMap::new(),
3197            fields: vec![AhbFieldRule {
3198                segment_path: "SG10/STS/C556/9013".to_string(),
3199                name: "Code".to_string(),
3200                ahb_status: "X".to_string(),
3201                element_index: Some(2),
3202                component_index: Some(0),
3203                codes: vec![
3204                    AhbCodeRule {
3205                        value: "E01".into(),
3206                        description: "Code 1".into(),
3207                        ahb_status: "X [901] [4P0..1]".into(),
3208                    },
3209                    AhbCodeRule {
3210                        value: "E02".into(),
3211                        description: "Code 2".into(),
3212                        ahb_status: "X [901] [4P0..1]".into(),
3213                    },
3214                ],
3215                parent_group_ahb_status: Some("Muss".to_string()),
3216                mig_number: None,
3217            }],
3218        };
3219
3220        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
3221        let pkg_errors: Vec<_> = report
3222            .by_category(ValidationCategory::Ahb)
3223            .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
3224            .collect();
3225        assert!(
3226            pkg_errors.is_empty(),
3227            "1 code within [4P0..1] bounds — no error, got: {:?}",
3228            pkg_errors
3229        );
3230    }
3231
3232    fn make_segment(id: &str, elements: Vec<Vec<&str>>) -> OwnedSegment {
3233        OwnedSegment {
3234            id: id.to_string(),
3235            elements: elements
3236                .into_iter()
3237                .map(|e| e.into_iter().map(|s| s.to_string()).collect())
3238                .collect(),
3239            segment_number: 0,
3240        }
3241    }
3242
3243    #[test]
3244    fn test_unt_count_correct() {
3245        // UNH + BGM + DTM + UNT = 4 segments, UNT declares 4
3246        let segments = vec![
3247            make_segment("UNH", vec![vec!["001"]]),
3248            make_segment("BGM", vec![vec!["E01"]]),
3249            make_segment("DTM", vec![vec!["137", "20250401"]]),
3250            make_segment("UNT", vec![vec!["4", "001"]]),
3251        ];
3252        assert!(
3253            validate_unt_segment_count(&segments).is_none(),
3254            "Correct count should produce no issue"
3255        );
3256    }
3257
3258    #[test]
3259    fn test_unt_count_mismatch() {
3260        // UNH + BGM + UNT = 3 segments, but UNT declares 5
3261        let segments = vec![
3262            make_segment("UNH", vec![vec!["001"]]),
3263            make_segment("BGM", vec![vec!["E01"]]),
3264            make_segment("UNT", vec![vec!["5", "001"]]),
3265        ];
3266        let issue = validate_unt_segment_count(&segments)
3267            .expect("Mismatch should produce an issue");
3268        assert_eq!(issue.code, ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH);
3269        assert_eq!(issue.severity, Severity::Error);
3270        assert!(issue.message.contains("declared 5"));
3271        assert!(issue.message.contains("actual 3"));
3272    }
3273
3274    #[test]
3275    fn test_unt_count_excludes_envelope() {
3276        // Envelope segments (UNA, UNB, UNZ) should not be counted
3277        let segments = vec![
3278            make_segment("UNA", vec![]),
3279            make_segment("UNB", vec![vec!["UNOC", "3"]]),
3280            make_segment("UNH", vec![vec!["001"]]),
3281            make_segment("BGM", vec![vec!["E01"]]),
3282            make_segment("UNT", vec![vec!["3", "001"]]),
3283            make_segment("UNZ", vec![vec!["1"]]),
3284        ];
3285        assert!(
3286            validate_unt_segment_count(&segments).is_none(),
3287            "Envelope segments excluded — count should be 3 (UNH+BGM+UNT)"
3288        );
3289    }
3290
3291    #[test]
3292    fn test_unt_count_no_unt_returns_none() {
3293        let segments = vec![
3294            make_segment("UNH", vec![vec!["001"]]),
3295            make_segment("BGM", vec![vec!["E01"]]),
3296        ];
3297        assert!(
3298            validate_unt_segment_count(&segments).is_none(),
3299            "No UNT segment should return None (not our problem)"
3300        );
3301    }
3302
3303    #[test]
3304    fn test_unt_count_rejects_multi_message_input() {
3305        // Two messages spliced together — should return an error
3306        let segments = vec![
3307            make_segment("UNH", vec![vec!["001"]]),
3308            make_segment("BGM", vec![vec!["E01"]]),
3309            make_segment("UNT", vec![vec!["3", "001"]]),
3310            make_segment("UNH", vec![vec!["002"]]),
3311            make_segment("BGM", vec![vec!["E02"]]),
3312            make_segment("UNT", vec![vec!["3", "002"]]),
3313        ];
3314        let issue = validate_unt_segment_count(&segments)
3315            .expect("Multi-message input should produce an error");
3316        assert_eq!(issue.code, ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH);
3317        assert!(issue.message.contains("2 UNH"), "Should mention UNH count: {}", issue.message);
3318    }
3319}