import 'package:uto_manager/domain/entities/echelon.dart'; import 'package:uto_manager/domain/entities/personnel_record.dart'; import 'package:uto_manager/domain/entities/position_slot.dart'; import 'package:uto_manager/domain/entities/unit_node.dart'; /// Result of a doctrinal validation check. class ValidationResult { const ValidationResult({required this.isValid, this.reason}); /// Whether the operation is doctrinally valid. final bool isValid; /// Human-readable reason if [isValid] is false. final String? reason; /// A passing validation result. static const valid = ValidationResult(isValid: true); /// Creates a failing validation result with a [reason]. static ValidationResult invalid(String reason) => ValidationResult(isValid: false, reason: reason); } /// Represents a node in the unit tree with its children and slots. class UnitTreeNode { const UnitTreeNode({ required this.unit, required this.children, required this.slots, }); /// The unit node at this position in the tree. final UnitNode unit; /// Direct child [UnitTreeNode]s. final List children; /// Position slots belonging to this unit. final List slots; /// Total number of slots in this subtree (inclusive). int get totalSlots => slots.length + children.fold(0, (sum, c) => sum + c.totalSlots); /// Total number of filled slots in this subtree (inclusive). int get filledSlots => slots.where((s) => s.isFilled).length + children.fold(0, (sum, c) => sum + c.filledSlots); /// Fill rate as a percentage (0–100). double get fillRate => totalSlots == 0 ? 0.0 : (filledSlots / totalSlots) * 100.0; } /// Domain service for building and validating the unit tree structure. /// /// Provides tree construction from flat lists, doctrinal validation, /// and subtree operations. This service is pure domain logic with no /// infrastructure dependencies. class UnitTreeService { const UnitTreeService(); // --------------------------------------------------------------------------- // Tree Construction // --------------------------------------------------------------------------- /// Builds a hierarchical [UnitTreeNode] tree from flat [units] and [slots]. /// /// Returns a list of root [UnitTreeNode]s (units with no parent). List buildTree( List units, List slots, ) { final slotsByUnit = >{}; for (final slot in slots) { slotsByUnit.putIfAbsent(slot.unitId, () => []).add(slot); } final nodeMap = {}; // First pass: create leaf nodes (no children yet). for (final unit in units) { nodeMap[unit.unitId] = UnitTreeNode( unit: unit, children: [], slots: slotsByUnit[unit.unitId] ?? [], ); } final roots = []; // Second pass: wire parent-child relationships. for (final unit in units) { if (unit.parentUnitId == null) { roots.add(nodeMap[unit.unitId]!); } else { final parent = nodeMap[unit.parentUnitId]; if (parent != null) { (parent.children as List).add(nodeMap[unit.unitId]!); } } } return roots; } /// Returns all units in the subtree rooted at [unitId] (inclusive). List getSubtree(String unitId, List allUnits) { final result = []; final queue = [unitId]; while (queue.isNotEmpty) { final current = queue.removeAt(0); final node = allUnits.firstWhere( (u) => u.unitId == current, orElse: () => throw StateError('Unit $current not found'), ); result.add(node); final children = allUnits.where((u) => u.parentUnitId == current).map((u) => u.unitId); queue.addAll(children); } return result; } /// Returns all ancestors of [unitId] from immediate parent up to root. List getAncestors(String unitId, List allUnits) { final ancestors = []; var current = allUnits.firstWhere( (u) => u.unitId == unitId, orElse: () => throw StateError('Unit $unitId not found'), ); while (current.parentUnitId != null) { final parent = allUnits.firstWhere( (u) => u.unitId == current.parentUnitId, orElse: () => throw StateError('Parent ${current.parentUnitId} not found'), ); ancestors.add(parent); current = parent; } return ancestors; } // --------------------------------------------------------------------------- // Doctrinal Validation // --------------------------------------------------------------------------- /// Validates whether [child] can be re-parented under [newParent]. /// /// Checks echelon hierarchy rules and prevents circular references. ValidationResult validateReparent( UnitNode child, UnitNode newParent, List allUnits, ) { // Cannot parent a unit to itself. if (child.unitId == newParent.unitId) { return ValidationResult.invalid('A unit cannot be its own parent.'); } // Check echelon hierarchy: parent must be exactly one level higher. if (!child.echelon.isValidParent(newParent.echelon)) { return ValidationResult.invalid( '${child.echelon.displayName} cannot be a child of ' '${newParent.echelon.displayName}. ' 'Expected parent echelon: ${_expectedParentEchelon(child.echelon)?.displayName ?? "none"}.', ); } // Prevent circular reference: newParent must not be in child's subtree. final subtreeIds = getSubtree(child.unitId, allUnits).map((u) => u.unitId).toSet(); if (subtreeIds.contains(newParent.unitId)) { return ValidationResult.invalid( 'Cannot re-parent: ${newParent.name} is a descendant of ${child.name}.', ); } return ValidationResult.valid; } /// Validates whether [slot] can accept [soldier] as an assignment. ValidationResult validateAssignment( PositionSlot slot, PersonnelRecord soldier, ) { if (slot.isFilled) { return ValidationResult.invalid( 'Position "${slot.positionTitle}" is already filled.', ); } if (soldier.isAssigned) { return ValidationResult.invalid( '${soldier.name} is already assigned to another position.', ); } if (soldier.primaryMos != slot.dutyMos) { // Allow assignment but flag as a warning (not a hard block). // The UI layer decides whether to warn or block. } return ValidationResult.valid; } /// Returns the expected parent echelon for [echelon], or null for Brigade. Echelon? _expectedParentEchelon(Echelon echelon) { if (echelon == Echelon.brigade) return null; return Echelon.values.firstWhere((e) => e.level == echelon.level - 1); } // --------------------------------------------------------------------------- // Statistics // --------------------------------------------------------------------------- /// Calculates the fill rate for a list of [slots] (0.0–1.0). double calculateFillRate(List slots) { if (slots.isEmpty) return 0.0; final filled = slots.where((s) => s.isFilled).length; return filled / slots.length; } /// Returns all vacant key leader slots from [slots]. List getVacantKeyLeaderSlots(List slots) { return slots.where((s) => s.isKeyLeader && s.isVacant).toList(); } /// Returns all slots with MOS mismatches given [personnel]. List getMosMismatches( List slots, List personnel, ) { final personnelById = {for (final p in personnel) p.soldierId: p}; return slots.where((slot) { if (slot.isVacant) return false; final soldier = personnelById[slot.assignedPersonnelId]; if (soldier == null) return false; return soldier.primaryMos != slot.dutyMos; }).toList(); } }