import '../database/database.dart'; import '../database/daos/month_dao.dart'; import '../theme.dart'; /// The four math source values, computed from month data before rules run. class MathSourceValues { final int totalIncomeCents; final int ledgerRemainingCents; final int netBalanceCents; final int netMinusBufferCents; const MathSourceValues({ required this.totalIncomeCents, required this.ledgerRemainingCents, required this.netBalanceCents, required this.netMinusBufferCents, }); int operator [](MathSource source) => switch (source) { MathSource.totalIncome => totalIncomeCents, MathSource.ledgerRemaining => ledgerRemainingCents, MathSource.netBalance => netBalanceCents, MathSource.netMinusBuffer => netMinusBufferCents, }; } /// A single step in the rule computation for a bill. class RuleStep { final int billId; final RuleType type; final MathSource source; final int sourceAvailableCents; // source amount before this step final int desiredCents; // what the rule wanted final int actualCents; // what it actually got (clamped) final int rollingTotalCents; // bill's cumulative total after this step // Math rule specifics final double? multiplier; final int? additionCents; const RuleStep({ required this.billId, required this.type, required this.source, required this.sourceAvailableCents, required this.desiredCents, required this.actualCents, required this.rollingTotalCents, this.multiplier, this.additionCents, }); } /// Full result of running the rule solver. class RuleSolverResult { final Map billPayments; final Map> billRuleTypes; final Map> billAllRuleTypes; final Map billMinimums; /// Per-bill ordered list of computation steps (for expandable breakdown). final Map> billSteps; const RuleSolverResult({ required this.billPayments, required this.billRuleTypes, required this.billAllRuleTypes, required this.billMinimums, required this.billSteps, }); static const empty = RuleSolverResult( billPayments: {}, billRuleTypes: {}, billAllRuleTypes: {}, billMinimums: {}, billSteps: {}, ); } /// Compute math source values from month data. /// /// Rule-targeted bills (by ID) are excluded from deductions so they don't /// double-count — their amounts come from the rule solver. MathSourceValues computeMathSources({ required MonthData data, required Set ruleBillIds, required int bufferCents, }) { final totalIncome = data.paychecks.fold( 0, (sum, pw) => sum + pw.paycheck.amountCents, ); var ledgerRemaining = data.plan.openingBalanceCents; ledgerRemaining += totalIncome; for (final pw in data.paychecks) { for (final item in pw.items) { ledgerRemaining -= item.amountCents; } } for (final p in data.priorityPayouts) { ledgerRemaining -= p.amountCents; } for (final bill in data.bills) { if (bill.isOutside) continue; if (ruleBillIds.contains(bill.id)) continue; final amount = bill.amountOverrideCents ?? bill.defaultAmountCents; ledgerRemaining -= amount; } for (final p in data.notablePayouts) { ledgerRemaining -= p.amountCents; } final miscTotal = data.miscItems .where((m) => m.isEnabled) .fold(0, (sum, m) => sum + m.amountCents); final netBalance = ledgerRemaining - data.plan.eatingOutBudgetCents - miscTotal; final netMinusBuffer = netBalance - bufferCents; return MathSourceValues( totalIncomeCents: totalIncome, ledgerRemainingCents: ledgerRemaining, netBalanceCents: netBalance, netMinusBufferCents: netMinusBuffer, ); } /// Process payment rules top-to-bottom, allocating from math sources. /// /// [rules] must be sorted by sortOrder ASC. /// [billModes] maps billDefinitionId → paymentMode (0=full, 1=minimumOnly). /// [billOutstanding] maps billDefinitionId → outstandingBalanceCents (nullable). RuleSolverResult solveRules({ required MathSourceValues sources, required List rules, required Map billModes, required Map billOutstanding, }) { final remaining = { MathSource.totalIncome: sources.totalIncomeCents, MathSource.ledgerRemaining: sources.ledgerRemainingCents, MathSource.netBalance: sources.netBalanceCents, MathSource.netMinusBuffer: sources.netMinusBufferCents, }; final billPayments = {}; final billRuleTypes = >{}; final billAllRuleTypes = >{}; final billMinimums = {}; final billSteps = >{}; // First pass: record ALL configured rule types per bill for (final rule in rules) { final type = RuleType.values[rule.ruleType.clamp(0, 2)]; billAllRuleTypes.putIfAbsent(rule.billDefinitionId, () => {}).add(type); } // Track which remaining-bal rules have been processed (for even split) final processedIds = {}; for (final rule in rules) { final source = MathSource.values[rule.mathSource.clamp(0, 3)]; final type = RuleType.values[rule.ruleType.clamp(0, 2)]; final billId = rule.billDefinitionId; final mode = billModes[billId] ?? 0; // In minimumOnly mode, skip non-minimum rules if (mode == 1 && type != RuleType.minimum) { processedIds.add(rule.id); continue; } final available = remaining[source]!; int desired; switch (type) { case RuleType.minimum: desired = rule.minimumAmountCents ?? 0; billMinimums[billId] = (billMinimums[billId] ?? 0) + desired; case RuleType.math: final mult = rule.multiplier ?? 0.0; final add = rule.additionCents ?? 0; desired = (mult * available).round() + add; if (desired < 0) desired = 0; case RuleType.remainingBalance: final outstanding = billOutstanding[billId] ?? 0; final alreadyPaid = billPayments[billId] ?? 0; desired = outstanding - alreadyPaid; if (desired < 0) desired = 0; // Even split: count unprocessed remaining-bal rules sharing this source final peers = rules.where((r) => r.ruleType == 2 && // remainingBalance r.mathSource == rule.mathSource && !processedIds.contains(r.id) && (billModes[r.billDefinitionId] ?? 0) != 1 // not in min-only mode ).length; if (peers > 1 && available > 0) { // Cap this rule's draw to its fair share of the source final fairShare = available ~/ peers; // First peer gets the extra cents from integer division final isFirst = rules.where((r) => r.ruleType == 2 && r.mathSource == rule.mathSource && !processedIds.contains(r.id) && (billModes[r.billDefinitionId] ?? 0) != 1 ).first.id == rule.id; final extra = isFirst ? available % peers : 0; desired = desired.clamp(0, fairShare + extra); } } // Clamp to available source (don't go below zero) final actual = available >= desired ? desired : (available > 0 ? available : 0); remaining[source] = remaining[source]! - actual; billPayments[billId] = (billPayments[billId] ?? 0) + actual; billRuleTypes.putIfAbsent(billId, () => {}).add(type); billSteps.putIfAbsent(billId, () => []).add(RuleStep( billId: billId, type: type, source: source, sourceAvailableCents: available, desiredCents: desired, actualCents: actual, rollingTotalCents: billPayments[billId]!, multiplier: type == RuleType.math ? rule.multiplier : null, additionCents: type == RuleType.math ? rule.additionCents : null, )); processedIds.add(rule.id); } return RuleSolverResult( billPayments: billPayments, billRuleTypes: billRuleTypes, billAllRuleTypes: billAllRuleTypes, billMinimums: billMinimums, billSteps: billSteps, ); }