//! Transaction interpolation.
//!
//! Fills in missing posting amounts to balance transactions.

use rust_decimal::Decimal;
use rust_decimal::prelude::Signed;
use rustledger_core::{Amount, IncompleteAmount, InternedStr, Transaction};
use std::collections::HashMap;
use thiserror::Error;

/// Errors that can occur during interpolation.
#[derive(Debug, Clone, Error)]
pub enum InterpolationError {
    /// Multiple postings are missing amounts for the same currency.
    #[error("multiple postings missing amounts for currency {currency}")]
    MultipleMissing {
        /// The currency with multiple missing amounts.
        currency: InternedStr,
        /// Number of postings missing this currency.
        count: usize,
    },

    /// Cannot infer currency for a posting.
    #[error("cannot infer currency for posting to account {account}")]
    CannotInferCurrency {
        /// The account of the posting.
        account: InternedStr,
    },

    /// Transaction does not balance after interpolation.
    #[error("transaction does not balance: residual {residual} {currency}")]
    DoesNotBalance {
        /// The unbalanced currency.
        currency: InternedStr,
        /// The residual amount.
        residual: Decimal,
    },
}

/// Result of interpolation.
#[derive(Debug, Clone)]
pub struct InterpolationResult {
    /// The interpolated transaction.
    pub transaction: Transaction,
    /// Which posting indices were filled in.
    pub filled_indices: Vec<usize>,
    /// Residuals after interpolation (should all be near zero).
    pub residuals: HashMap<InternedStr, Decimal>,
}

/// Round an interpolated amount to match existing scale, but never round
/// a non-zero residual to zero (that would leave the transaction unbalanced).
fn round_interpolated(residual: Decimal, existing_scale: Option<u32>) -> Decimal {
    let interpolated = -residual;
    if let Some(scale) = existing_scale {
        let rounded = interpolated.round_dp(scale);
        // If rounding would make non-zero residual into zero, preserve precision
        if rounded.is_zero() && !residual.is_zero() {
            interpolated
        } else {
            rounded
        }
    } else {
        interpolated
    }
}

/// Interpolate missing amounts in a transaction.
///
/// This function:
/// 1. Identifies postings with missing amounts
/// 2. For each currency, calculates the residual
/// 3. Fills in the missing amount to balance
///
/// # Rules
///
/// - At most one posting per currency can have a missing amount
/// - If a posting has a cost spec with a currency, that currency is used
/// - Otherwise, the posting gets the residual that makes the transaction balance
///
/// # TLA+ Specification
///
/// Implements invariants from `Interpolation.tla`:
/// - `AtMostOneNull`: At most one posting per currency can have a missing amount
///   (returns `MultipleMissing` error if violated)
/// - `CompleteImpliesBalanced`: After interpolation, `sum(postings) = 0` for each currency
/// - `HasNullAccurate`: `filled_indices` contains exactly the indices of postings
///   that were originally missing amounts
///
/// See: `spec/tla/Interpolation.tla`
///
/// # Example
///
/// ```ignore
/// let txn = Transaction::new(date, "Test")
///     .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(50.00), "USD")))
///     .with_posting(Posting::auto("Assets:Cash"));
///
/// let result = interpolate(&txn)?;
/// // Assets:Cash now has -50.00 USD
/// ```
pub fn interpolate(transaction: &Transaction) -> Result<InterpolationResult, InterpolationError> {
    // Clone the transaction for modification
    let mut result = transaction.clone();
    let mut filled_indices = Vec::new();

    // Lazily compute inferred currency only when needed (most transactions don't need it)
    let mut inferred_cost_currency: Option<Option<InternedStr>> = None;
    let get_inferred_currency = |cache: &mut Option<Option<InternedStr>>| -> Option<InternedStr> {
        cache
            .get_or_insert_with(|| crate::infer_cost_currency_from_postings(transaction))
            .clone()
    };

    // Calculate initial residuals from postings with amounts
    // Pre-allocate for typical case (1-2 currencies per transaction)
    let num_postings = transaction.postings.len();
    let mut residuals: HashMap<InternedStr, Decimal> = HashMap::with_capacity(num_postings.min(4));
    let mut missing_by_currency: HashMap<InternedStr, Vec<usize>> = HashMap::with_capacity(2);
    let mut unassigned_missing: Vec<usize> = Vec::with_capacity(2);

    // Track maximum scale (decimal places) per currency for rounding interpolated amounts.
    // Python beancount rounds interpolated amounts to match the precision of other amounts
    // in the same currency, which can create small residuals within tolerance.
    let mut max_scale_by_currency: HashMap<InternedStr, u32> = HashMap::with_capacity(4);

    // Track scales from cost specs separately. These are merged with max_scale_by_currency
    // after the loop, but only for currencies that have explicit amounts. This ensures we
    // preserve precision when cost has more decimal places than other postings (#333),
    // without forcing rounding when there are no explicit amounts (#251).
    let mut cost_scale_by_currency: HashMap<InternedStr, u32> = HashMap::with_capacity(2);

    for (i, posting) in transaction.postings.iter().enumerate() {
        match &posting.units {
            Some(IncompleteAmount::Complete(amount)) => {
                // Track scale (decimal places) for rounding interpolated amounts
                let scale = amount.number.scale();
                max_scale_by_currency
                    .entry(amount.currency.clone())
                    .and_modify(|s| *s = (*s).max(scale))
                    .or_insert(scale);

                // Determine the "weight" of this posting for balance purposes.
                // This must match the logic in calculate_residual().
                //
                // Rules:
                // - If there's a cost spec, weight is in cost currency (not units)
                // - If there's a price annotation (no cost), weight is in price currency
                // - Otherwise, weight is the units themselves

                // Check if cost spec has determinable values.
                // If cost has number but no currency, try to infer currency from:
                // 1. Price annotation
                // 2. Other postings in the transaction
                let cost_contribution = posting.cost.as_ref().and_then(|cost_spec| {
                    // Helper to get currency from price annotation
                    let price_currency = posting.price.as_ref().and_then(|p| match p {
                        rustledger_core::PriceAnnotation::Unit(a)
                        | rustledger_core::PriceAnnotation::Total(a) => Some(a.currency.clone()),
                        rustledger_core::PriceAnnotation::UnitIncomplete(inc)
                        | rustledger_core::PriceAnnotation::TotalIncomplete(inc) => {
                            inc.as_amount().map(|a| a.currency.clone())
                        }
                        _ => None,
                    });

                    // Try to get cost currency, falling back to price currency, then other postings
                    let inferred_currency = cost_spec
                        .currency
                        .clone()
                        .or(price_currency)
                        .or_else(|| get_inferred_currency(&mut inferred_cost_currency));

                    if let (Some(per_unit), Some(cost_curr)) =
                        (&cost_spec.number_per, &inferred_currency)
                    {
                        let cost_amount = amount.number * per_unit;
                        // Track the scale of number_per for rounding interpolated amounts.
                        // This ensures we preserve the precision of the per-unit price.
                        // See: https://github.com/rustledger/rustledger/issues/333
                        Some((cost_curr.clone(), cost_amount, Some(per_unit.scale())))
                    } else if let (Some(total), Some(cost_curr)) =
                        (&cost_spec.number_total, &inferred_currency)
                    {
                        // For total cost, sign depends on units sign
                        // Track the scale of number_total for rounding
                        Some((
                            cost_curr.clone(),
                            *total * amount.number.signum(),
                            Some(total.scale()),
                        ))
                    } else {
                        None // Cost spec without determinable amount (e.g., empty `{}`)
                    }
                });

                if let Some((currency, cost_amount, cost_scale)) = cost_contribution {
                    // Cost-based posting: weight is in the cost currency.
                    // Track cost scale separately - it will be merged later only for
                    // currencies that have explicit amounts.
                    if let Some(scale) = cost_scale {
                        cost_scale_by_currency
                            .entry(currency.clone())
                            .and_modify(|s| *s = (*s).max(scale))
                            .or_insert(scale);
                    }
                    *residuals.entry(currency).or_default() += cost_amount;
                } else if let Some(price) = &posting.price {
                    // Price annotation: converts units to price currency
                    // Note: We do NOT track scale from per-unit prices (they're multipliers).
                    // We DO track scale from total prices (they're explicit amounts).
                    match price {
                        rustledger_core::PriceAnnotation::Unit(price_amt) => {
                            let converted = amount.number.abs() * price_amt.number;
                            *residuals.entry(price_amt.currency.clone()).or_default() +=
                                converted * amount.number.signum();
                        }
                        rustledger_core::PriceAnnotation::Total(price_amt) => {
                            // Total price is an explicit amount - track its scale
                            let scale = price_amt.number.scale();
                            max_scale_by_currency
                                .entry(price_amt.currency.clone())
                                .and_modify(|s| *s = (*s).max(scale))
                                .or_insert(scale);
                            *residuals.entry(price_amt.currency.clone()).or_default() +=
                                price_amt.number * amount.number.signum();
                        }
                        rustledger_core::PriceAnnotation::UnitIncomplete(inc) => {
                            if let Some(price_amt) = inc.as_amount() {
                                let converted = amount.number.abs() * price_amt.number;
                                *residuals.entry(price_amt.currency.clone()).or_default() +=
                                    converted * amount.number.signum();
                            } else {
                                // Can't calculate, fall back to units
                                *residuals.entry(amount.currency.clone()).or_default() +=
                                    amount.number;
                            }
                        }
                        rustledger_core::PriceAnnotation::TotalIncomplete(inc) => {
                            if let Some(price_amt) = inc.as_amount() {
                                // Total price is an explicit amount - track its scale
                                let scale = price_amt.number.scale();
                                max_scale_by_currency
                                    .entry(price_amt.currency.clone())
                                    .and_modify(|s| *s = (*s).max(scale))
                                    .or_insert(scale);
                                *residuals.entry(price_amt.currency.clone()).or_default() +=
                                    price_amt.number * amount.number.signum();
                            } else {
                                // Can't calculate, fall back to units
                                *residuals.entry(amount.currency.clone()).or_default() +=
                                    amount.number;
                            }
                        }
                        // Empty price annotations - fall back to units
                        rustledger_core::PriceAnnotation::UnitEmpty
                        | rustledger_core::PriceAnnotation::TotalEmpty => {
                            *residuals.entry(amount.currency.clone()).or_default() += amount.number;
                        }
                    }
                } else if posting.cost.is_some() {
                    // Cost spec exists but is empty (e.g., `{}`), and no price annotation
                    // Don't contribute to residual - cost will be filled by lot matching
                } else {
                    // Simple posting: weight is just the units
                    *residuals.entry(amount.currency.clone()).or_default() += amount.number;
                }
            }
            Some(IncompleteAmount::CurrencyOnly(currency)) => {
                // Currency known, number to be interpolated
                missing_by_currency
                    .entry(currency.clone())
                    .or_default()
                    .push(i);
            }
            Some(IncompleteAmount::NumberOnly(number)) => {
                // Number known, currency to be inferred
                // Try to get currency from cost or price
                let currency = posting
                    .cost
                    .as_ref()
                    .and_then(|c| c.currency.clone())
                    .or_else(|| {
                        posting.price.as_ref().and_then(|p| match p {
                            rustledger_core::PriceAnnotation::Unit(a) => Some(a.currency.clone()),
                            rustledger_core::PriceAnnotation::Total(a) => Some(a.currency.clone()),
                            rustledger_core::PriceAnnotation::UnitIncomplete(inc)
                            | rustledger_core::PriceAnnotation::TotalIncomplete(inc) => {
                                inc.as_amount().map(|a| a.currency.clone())
                            }
                            rustledger_core::PriceAnnotation::UnitEmpty
                            | rustledger_core::PriceAnnotation::TotalEmpty => None,
                        })
                    });

                if let Some(curr) = currency {
                    // We have currency from context, make it complete
                    *residuals.entry(curr.clone()).or_default() += *number;
                } else {
                    // Can't determine currency yet
                    unassigned_missing.push(i);
                }
            }
            None => {
                // Missing amount - try to determine currency from cost
                if let Some(cost_spec) = &posting.cost
                    && let Some(currency) = &cost_spec.currency
                {
                    missing_by_currency
                        .entry(currency.clone())
                        .or_default()
                        .push(i);
                    continue;
                }
                // Can't determine currency yet
                unassigned_missing.push(i);
            }
        }
    }

    // Merge cost scales into max_scale_by_currency, but only for currencies that
    // already have explicit amounts. This preserves precision from cost specs (#333)
    // without forcing rounding when there are no explicit amounts (#251).
    for (currency, cost_scale) in cost_scale_by_currency {
        max_scale_by_currency
            .entry(currency)
            .and_modify(|s| *s = (*s).max(cost_scale));
    }

    // Check for multiple missing in same currency
    for (currency, indices) in &missing_by_currency {
        if indices.len() > 1 {
            return Err(InterpolationError::MultipleMissing {
                currency: currency.clone(),
                count: indices.len(),
            });
        }
    }

    // Fill in known-currency missing postings
    for (currency, indices) in missing_by_currency {
        let idx = indices[0];
        let residual = residuals.get(&currency).copied().unwrap_or(Decimal::ZERO);

        let interpolated =
            round_interpolated(residual, max_scale_by_currency.get(&currency).copied());

        result.postings[idx].units = Some(IncompleteAmount::Complete(Amount::new(
            interpolated,
            &currency,
        )));
        filled_indices.push(idx);

        // Update residual to reflect actual interpolated amount (may have rounding difference)
        *residuals.entry(currency).or_default() += interpolated;
    }

    // Handle unassigned missing postings
    // Each one absorbs one or more currencies' residuals
    if !unassigned_missing.is_empty() {
        // Get currencies with non-zero residuals
        let non_zero_residuals: Vec<(InternedStr, Decimal)> = residuals
            .iter()
            .filter(|&(_, v)| !v.is_zero())
            .map(|(k, v)| (k.clone(), *v))
            .collect();

        // Special case: single missing posting with multiple currencies
        // This is multi-currency interpolation - split into multiple postings
        if unassigned_missing.len() == 1 && non_zero_residuals.len() > 1 {
            let idx = unassigned_missing[0];
            let original_posting = &transaction.postings[idx];

            // Fill the first currency into the original posting
            let (first_currency, first_residual) = &non_zero_residuals[0];
            let interpolated = round_interpolated(
                *first_residual,
                max_scale_by_currency.get(first_currency).copied(),
            );
            result.postings[idx].units = Some(IncompleteAmount::Complete(Amount::new(
                interpolated,
                first_currency,
            )));
            filled_indices.push(idx);
            *residuals.entry(first_currency.clone()).or_default() += interpolated;

            // Add new postings for remaining currencies
            for (currency, residual) in non_zero_residuals.iter().skip(1) {
                let mut new_posting = original_posting.clone();
                let interpolated =
                    round_interpolated(*residual, max_scale_by_currency.get(currency).copied());
                new_posting.units = Some(IncompleteAmount::Complete(Amount::new(
                    interpolated,
                    currency,
                )));
                result.postings.push(new_posting);
                filled_indices.push(result.postings.len() - 1);
                *residuals.entry(currency.clone()).or_default() += interpolated;
            }
        } else {
            // Check for ambiguous elision: more unassigned missing postings than
            // available residual currencies means multiple postings would all be
            // assigned to the same currency, which is ambiguous and an error.
            if unassigned_missing.len() > non_zero_residuals.len() && !non_zero_residuals.is_empty()
            {
                let (currency, _) = &non_zero_residuals[0];
                return Err(InterpolationError::MultipleMissing {
                    currency: currency.clone(),
                    count: unassigned_missing.len(),
                });
            }

            // Standard case: assign one currency per missing posting
            for (i, idx) in unassigned_missing.iter().enumerate() {
                if i < non_zero_residuals.len() {
                    let (currency, residual) = &non_zero_residuals[i];
                    let interpolated =
                        round_interpolated(*residual, max_scale_by_currency.get(currency).copied());
                    result.postings[*idx].units = Some(IncompleteAmount::Complete(Amount::new(
                        interpolated,
                        currency,
                    )));
                    filled_indices.push(*idx);
                    *residuals.entry(currency.clone()).or_default() += interpolated;
                } else if !non_zero_residuals.is_empty() {
                    // Use the first currency
                    let (currency, _) = &non_zero_residuals[0];
                    result.postings[*idx].units =
                        Some(IncompleteAmount::Complete(Amount::zero(currency)));
                    filled_indices.push(*idx);
                } else if let Some(currency) = get_inferred_currency(&mut inferred_cost_currency) {
                    // No residuals but we can infer currency from cost basis
                    // This handles balanced cost-basis transactions like:
                    //   Assets:Crypto  100 USDC {1.0 USD}
                    //   Assets:Cash   -100 USD
                    //   Income:Trading  ; <- infer 0 USD from cost basis
                    result.postings[*idx].units =
                        Some(IncompleteAmount::Complete(Amount::zero(&currency)));
                    filled_indices.push(*idx);
                } else {
                    // No residuals and cannot infer currency
                    return Err(InterpolationError::CannotInferCurrency {
                        account: transaction.postings[*idx].account.clone(),
                    });
                }
            }
        }
    }

    // Note: We intentionally do NOT prune postings that interpolate to zero.
    // Although Python beancount removes such postings, pruning them before
    // validation hides errors (e.g., E1001 for unopened accounts).
    // See issue #877 / beancount/beancount#962.

    // Return the residuals we've been tracking incrementally
    // (no need to recalculate - we've updated residuals as we filled amounts)
    Ok(InterpolationResult {
        transaction: result,
        filled_indices,
        residuals,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use rust_decimal_macros::dec;
    use rustledger_core::{NaiveDate, Posting};

    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
        rustledger_core::naive_date(year, month, day).unwrap()
    }

    /// Helper to get the complete amount from a posting.
    fn get_amount(posting: &rustledger_core::Posting) -> Option<&Amount> {
        posting.units.as_ref().and_then(|u| u.as_amount())
    }

    #[test]
    fn test_interpolate_simple() {
        let txn = Transaction::new(date(2024, 1, 15), "Test")
            .with_posting(Posting::new(
                "Expenses:Food",
                Amount::new(dec!(50.00), "USD"),
            ))
            .with_posting(Posting::auto("Assets:Cash"));

        let result = interpolate(&txn).unwrap();

        assert_eq!(result.filled_indices, vec![1]);

        let filled = &result.transaction.postings[1];
        let amount = get_amount(filled).expect("should have amount");
        assert_eq!(amount.number, dec!(-50.00));
        assert_eq!(amount.currency, "USD");
    }

    #[test]
    fn test_interpolate_multiple_postings() {
        let txn = Transaction::new(date(2024, 1, 15), "Test")
            .with_posting(Posting::new(
                "Expenses:Food",
                Amount::new(dec!(30.00), "USD"),
            ))
            .with_posting(Posting::new(
                "Expenses:Drink",
                Amount::new(dec!(20.00), "USD"),
            ))
            .with_posting(Posting::auto("Assets:Cash"));

        let result = interpolate(&txn).unwrap();

        let filled = &result.transaction.postings[2];
        let amount = get_amount(filled).expect("should have amount");
        assert_eq!(amount.number, dec!(-50.00));
    }

    #[test]
    fn test_interpolate_no_missing() {
        let txn = Transaction::new(date(2024, 1, 15), "Test")
            .with_posting(Posting::new(
                "Expenses:Food",
                Amount::new(dec!(50.00), "USD"),
            ))
            .with_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(-50.00), "USD"),
            ));

        let result = interpolate(&txn).unwrap();

        assert!(result.filled_indices.is_empty());
    }

    #[test]
    fn test_interpolate_multiple_currencies() {
        let txn = Transaction::new(date(2024, 1, 15), "Test")
            .with_posting(Posting::new(
                "Expenses:Food",
                Amount::new(dec!(50.00), "USD"),
            ))
            .with_posting(Posting::new(
                "Expenses:Travel",
                Amount::new(dec!(100.00), "EUR"),
            ))
            .with_posting(Posting::new(
                "Assets:Cash:USD",
                Amount::new(dec!(-50.00), "USD"),
            ))
            .with_posting(Posting::auto("Assets:Cash:EUR"));

        let result = interpolate(&txn).unwrap();

        let filled = &result.transaction.postings[3];
        let amount = get_amount(filled).expect("should have amount");
        assert_eq!(amount.number, dec!(-100.00));
        assert_eq!(amount.currency, "EUR");
    }

    #[test]
    fn test_interpolate_error_multiple_missing_same_currency() {
        let txn = Transaction::new(date(2024, 1, 15), "Test")
            .with_posting(Posting::new(
                "Expenses:Food",
                Amount::new(dec!(50.00), "USD"),
            ))
            .with_posting(Posting::auto("Assets:Cash"))
            .with_posting(Posting::auto("Assets:Bank"));

        // Multiple unassigned missing postings with a single residual currency
        // is ambiguous and should return MultipleMissing error.
        let result = interpolate(&txn);
        assert!(
            matches!(result, Err(InterpolationError::MultipleMissing { .. })),
            "expected MultipleMissing error, got: {result:?}"
        );
    }

    #[test]
    fn test_interpolate_multiple_missing_different_currencies_ok() {
        // Two elided postings but two residual currencies - each gets one
        let txn = Transaction::new(date(2024, 1, 15), "Multi-currency")
            .with_posting(Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD")))
            .with_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")))
            .with_posting(Posting::auto("Liabilities:CreditCard"))
            .with_posting(Posting::auto("Equity:Exchange"));

        // Two unassigned missing, two non-zero residuals - this is unambiguous
        let result = interpolate(&txn);
        assert!(
            result.is_ok(),
            "expected success for different-currency elision, got: {result:?}"
        );
    }

    #[test]
    fn test_interpolate_with_per_unit_cost() {
        // 2015-10-02 *
        //   Assets:Stock   10 HOOL {100.00 USD}
        //   Assets:Cash
        //
        // Expected: Assets:Cash should be interpolated to -1000.00 USD
        let txn = Transaction::new(date(2015, 10, 2), "Buy stock")
            .with_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(10), "HOOL")).with_cost(
                    rustledger_core::CostSpec::empty()
                        .with_number_per(dec!(100.00))
                        .with_currency("USD"),
                ),
            )
            .with_posting(Posting::auto("Assets:Cash"));

        let result = interpolate(&txn).expect("interpolation should succeed");

        // Check that the cash posting was filled
        assert_eq!(result.filled_indices, vec![1]);

        // Check the interpolated amount
        let filled = &result.transaction.postings[1];
        let amount = get_amount(filled).expect("should have amount");
        assert_eq!(
            amount.currency, "USD",
            "should be USD (cost currency), not HOOL"
        );
        assert_eq!(
            amount.number,
            dec!(-1000.00),
            "should be -1000 USD (10 * 100)"
        );

        // Verify the transaction balances
        let residual = result
            .residuals
            .get("USD")
            .copied()
            .unwrap_or(Decimal::ZERO);
        assert!(
            residual.abs() < dec!(0.01),
            "USD residual should be ~0, got {residual}"
        );
        // There should be NO HOOL residual
        assert!(
            !result.residuals.contains_key("HOOL"),
            "should not have HOOL residual"
        );
    }

    #[test]
    fn test_interpolate_with_total_cost() {
        // 2015-10-02 *
        //   Assets:Stock   10 HOOL {{1000.00 USD}}
        //   Assets:Cash
        //
        // Expected: Assets:Cash should be interpolated to -1000.00 USD
        let txn = Transaction::new(date(2015, 10, 2), "Buy stock")
            .with_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(10), "HOOL")).with_cost(
                    rustledger_core::CostSpec::empty()
                        .with_number_total(dec!(1000.00))
                        .with_currency("USD"),
                ),
            )
            .with_posting(Posting::auto("Assets:Cash"));

        let result = interpolate(&txn).expect("interpolation should succeed");

        let filled = &result.transaction.postings[1];
        let amount = get_amount(filled).expect("should have amount");
        assert_eq!(amount.currency, "USD");
        assert_eq!(amount.number, dec!(-1000.00));
    }

    #[test]
    fn test_interpolate_stock_purchase_with_commission() {
        // From beancount starter.beancount:
        // 2013-02-03 * "Bought some stock"
        //   Assets:Stock         8 HOOL {701.20 USD}
        //   Expenses:Commission  7.95 USD
        //   Assets:Cash
        //
        // Expected: Cash = -(8 * 701.20 + 7.95) = -5617.55 USD
        let txn = Transaction::new(date(2013, 2, 3), "Bought some stock")
            .with_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(8), "HOOL")).with_cost(
                    rustledger_core::CostSpec::empty()
                        .with_number_per(dec!(701.20))
                        .with_currency("USD"),
                ),
            )
            .with_posting(Posting::new(
                "Expenses:Commission",
                Amount::new(dec!(7.95), "USD"),
            ))
            .with_posting(Posting::auto("Assets:Cash"));

        let result = interpolate(&txn).expect("interpolation should succeed");

        let filled = &result.transaction.postings[2];
        let amount = get_amount(filled).expect("should have amount");
        assert_eq!(amount.currency, "USD");
        // 8 * 701.20 = 5609.60, plus 7.95 commission = 5617.55
        assert_eq!(amount.number, dec!(-5617.55));
    }

    #[test]
    fn test_interpolate_stock_sale_with_cost_and_price() {
        // Selling stock at a different price than cost basis
        // 2015-10-02 *
        //   Assets:Stock   -10 HOOL {100.00 USD} @ 120.00 USD
        //   Assets:Cash
        //   Income:Gains
        //
        // The sale is at cost (for booking), but price is 120 USD
        // Weight: -10 * 100 = -1000 USD (at cost)
        // Cash should receive: 10 * 120 = 1200 USD (at price)
        // Gains: -200 USD
        let txn = Transaction::new(date(2015, 10, 2), "Sell stock")
            .with_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
                    .with_cost(
                        rustledger_core::CostSpec::empty()
                            .with_number_per(dec!(100.00))
                            .with_currency("USD"),
                    )
                    .with_price(rustledger_core::PriceAnnotation::Unit(Amount::new(
                        dec!(120.00),
                        "USD",
                    ))),
            )
            .with_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(1200.00), "USD"),
            ))
            .with_posting(Posting::auto("Income:Gains"));

        let result = interpolate(&txn).expect("interpolation should succeed");

        let filled = &result.transaction.postings[2];
        let amount = get_amount(filled).expect("should have amount");
        assert_eq!(amount.currency, "USD");
        // Gains = cost - proceeds = 1000 - 1200 = -200 (income is negative)
        assert_eq!(amount.number, dec!(-200.00));
    }

    #[test]
    fn test_interpolate_balanced_with_cost_no_interpolation_needed() {
        // When all amounts are provided, no interpolation needed
        // 2015-10-02 *
        //   Assets:Stock   10 HOOL {100.00 USD}
        //   Assets:Cash   -1000.00 USD
        let txn = Transaction::new(date(2015, 10, 2), "Buy stock")
            .with_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(10), "HOOL")).with_cost(
                    rustledger_core::CostSpec::empty()
                        .with_number_per(dec!(100.00))
                        .with_currency("USD"),
                ),
            )
            .with_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(-1000.00), "USD"),
            ));

        let result = interpolate(&txn).expect("interpolation should succeed");

        // No postings should be filled
        assert!(result.filled_indices.is_empty());

        // Transaction should balance
        let residual = result
            .residuals
            .get("USD")
            .copied()
            .unwrap_or(Decimal::ZERO);
        assert!(residual.abs() < dec!(0.01));
    }

    #[test]
    fn test_interpolate_negative_cost_units_sale() {
        // Selling stock (negative units) with cost
        // 2015-10-02 *
        //   Assets:Stock   -5 HOOL {100.00 USD}
        //   Assets:Cash
        //
        // Expected: Cash = 500.00 USD (proceeds from sale at cost)
        let txn = Transaction::new(date(2015, 10, 2), "Sell stock")
            .with_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(-5), "HOOL")).with_cost(
                    rustledger_core::CostSpec::empty()
                        .with_number_per(dec!(100.00))
                        .with_currency("USD"),
                ),
            )
            .with_posting(Posting::auto("Assets:Cash"));

        let result = interpolate(&txn).expect("interpolation should succeed");

        let filled = &result.transaction.postings[1];
        let amount = get_amount(filled).expect("should have amount");
        assert_eq!(amount.currency, "USD");
        assert_eq!(amount.number, dec!(500.00)); // Positive (receiving cash)
    }

    // =========================================================================
    // Multi-currency interpolation tests
    // =========================================================================

    #[test]
    fn test_interpolate_multi_currency_single_elided() {
        // Test case from basic.beancount:
        // 2008-04-02 * "Gilbert paid back for iPhone"
        //   Assets:Cash                            440.00 CAD
        //   Assets:AccountsReceivable             -431.92 USD
        //   Assets:Cash
        //
        // Expected: The elided Assets:Cash becomes TWO postings:
        //   Assets:Cash: -440.00 CAD
        //   Assets:Cash: 431.92 USD
        let txn = Transaction::new(date(2008, 4, 2), "Gilbert paid back for iPhone")
            .with_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(440.00), "CAD"),
            ))
            .with_posting(Posting::new(
                "Assets:AccountsReceivable",
                Amount::new(dec!(-431.92), "USD"),
            ))
            .with_posting(Posting::auto("Assets:Cash"));

        let result = interpolate(&txn).expect("interpolation should succeed");

        // Should now have 4 postings (original 3 + 1 added for second currency)
        assert_eq!(
            result.transaction.postings.len(),
            4,
            "should split elided posting into 2"
        );

        // Check that all residuals are zero
        for (currency, residual) in &result.residuals {
            assert!(
                residual.abs() < dec!(0.01),
                "{currency} residual should be ~0, got {residual}"
            );
        }

        // Verify the amounts (order may vary based on HashMap iteration)
        let mut found_cad = false;
        let mut found_usd = false;
        for posting in &result.transaction.postings {
            if let Some(amount) = get_amount(posting)
                && posting.account.as_str() == "Assets:Cash"
            {
                if amount.currency == "CAD" && amount.number == dec!(-440.00) {
                    found_cad = true;
                } else if amount.currency == "USD" && amount.number == dec!(431.92) {
                    found_usd = true;
                }
            }
        }
        assert!(found_cad, "should have -440.00 CAD posting");
        assert!(found_usd, "should have 431.92 USD posting");
    }

    #[test]
    fn test_interpolate_multi_currency_three_currencies() {
        // Three currencies with one elided posting
        let txn = Transaction::new(date(2024, 1, 15), "Multi-currency test")
            .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(100), "USD")))
            .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(200), "EUR")))
            .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(300), "GBP")))
            .with_posting(Posting::auto("Equity:Opening"));

        let result = interpolate(&txn).expect("interpolation should succeed");

        // Should now have 6 postings (original 4 + 2 added)
        assert_eq!(result.transaction.postings.len(), 6);

        // All residuals should be zero
        for (currency, residual) in &result.residuals {
            assert!(
                residual.abs() < dec!(0.01),
                "{currency} residual should be ~0, got {residual}"
            );
        }
    }

    // =========================================================================
    // Cost currency inference tests (issue #203)
    // =========================================================================

    /// Test interpolation with cost currency inferred from other postings.
    /// This is the exact case from issue #203.
    #[test]
    fn test_interpolate_cost_currency_inferred_from_other_posting() {
        // 2026-01-01 * "Opening balance"
        //   Assets:Vanguard:IRA:Trad:VFIFX  10 VFIFX {100}
        //   Equity:Opening-Balances
        //
        // The cost currency should be inferred, and the elided posting should
        // be filled with -1000 USD.
        let txn = Transaction::new(date(2026, 1, 1), "Opening balance")
            .with_posting(
                Posting::new(
                    "Assets:Vanguard:IRA:Trad:VFIFX",
                    Amount::new(dec!(10), "VFIFX"),
                )
                .with_cost(rustledger_core::CostSpec::empty().with_number_per(dec!(100))),
            )
            .with_posting(Posting::new(
                "Equity:Opening-Balances",
                Amount::new(dec!(-1000), "USD"),
            ));

        let result = interpolate(&txn).expect("interpolation should succeed");

        // Transaction should balance
        let residual = result
            .residuals
            .get("USD")
            .copied()
            .unwrap_or(Decimal::ZERO);
        assert!(
            residual.abs() < dec!(0.01),
            "USD residual should be ~0, got {residual}"
        );
    }

    /// Test interpolation where the cash posting is elided.
    #[test]
    fn test_interpolate_cost_currency_inferred_elided_cash() {
        // Like issue #203 but with elided cash posting:
        // 2026-01-01 * "Opening balance"
        //   Assets:Vanguard:IRA:Trad:VFIFX  10 VFIFX {100}
        //   Equity:Opening-Balances  -1000 USD
        //
        // Both postings are complete, should just balance.
        let txn = Transaction::new(date(2026, 1, 1), "Opening balance")
            .with_posting(
                Posting::new(
                    "Assets:Vanguard:IRA:Trad:VFIFX",
                    Amount::new(dec!(10), "VFIFX"),
                )
                .with_cost(rustledger_core::CostSpec::empty().with_number_per(dec!(100))),
            )
            .with_posting(Posting::new(
                "Equity:Opening-Balances",
                Amount::new(dec!(-1000), "USD"),
            ));

        let result = interpolate(&txn).expect("interpolation should succeed");

        // No postings filled since both are complete
        assert!(result.filled_indices.is_empty());

        // Should balance
        let residual = result
            .residuals
            .get("USD")
            .copied()
            .unwrap_or(Decimal::ZERO);
        assert!(
            residual.abs() < dec!(0.01),
            "USD residual should be ~0, got {residual}"
        );
    }

    // =========================================================================
    // Interpolation rounding tests (issue #268)
    // =========================================================================

    /// Test that interpolated amounts are rounded to match the precision of other amounts.
    /// This matches Python beancount's behavior where interpolated amounts use the same
    /// quantum (decimal places) as other amounts in the same currency.
    ///
    /// Issue: <https://github.com/rustledger/rustledger/issues/268>
    #[test]
    fn test_interpolate_rounds_to_quantum() {
        // From issue #268:
        // 2026-01-02 * "..."
        //   Assets:Cash
        //   Assets:Abc                    12.3340 ABC {140.02 USD, 2025-01-01}
        //   Expenses:Abc                    -0.01 USD
        //
        // Cost: 12.3340 * 140.02 = 1727.006680 USD
        // Python rounds Cash to -1727.00 (2 decimal places from -0.01 USD)
        // Residual: 1727.006680 - 0.01 - 1727.00 = -0.003320 USD (within 0.005 tolerance)
        let txn = Transaction::new(date(2026, 1, 2), "Test")
            .with_posting(Posting::auto("Assets:Cash"))
            .with_posting(
                Posting::new("Assets:Abc", Amount::new(dec!(12.3340), "ABC")).with_cost(
                    rustledger_core::CostSpec::empty()
                        .with_number_per(dec!(140.02))
                        .with_currency("USD"),
                ),
            )
            .with_posting(Posting::new(
                "Expenses:Abc",
                Amount::new(dec!(-0.01), "USD"),
            ));

        let result = interpolate(&txn).expect("interpolation should succeed");

        // Check that Cash was filled
        assert_eq!(result.filled_indices, vec![0]);

        // The interpolated amount should be rounded to 2 decimal places
        // (matching the -0.01 USD in Expenses:Abc)
        let filled = &result.transaction.postings[0];
        let amount = get_amount(filled).expect("should have amount");
        assert_eq!(amount.currency, "USD");
        assert_eq!(
            amount.number,
            dec!(-1727.00),
            "should be -1727.00 USD (rounded to 2 decimal places)"
        );

        // The residual should be non-zero but small (within tolerance)
        let residual = result
            .residuals
            .get("USD")
            .copied()
            .unwrap_or(Decimal::ZERO);
        assert_eq!(
            residual,
            dec!(-0.003320),
            "residual should be -0.003320 USD"
        );
    }

    /// Test that interpolation uses the maximum scale when multiple amounts have different scales.
    #[test]
    fn test_interpolate_uses_max_scale() {
        // When we have amounts with different scales, use the maximum.
        // 0.1 USD (scale 1) and 0.001 USD (scale 3) -> interpolate to scale 3
        let txn = Transaction::new(date(2024, 1, 15), "Test")
            .with_posting(Posting::new("Expenses:A", Amount::new(dec!(0.1), "USD")))
            .with_posting(Posting::new("Expenses:B", Amount::new(dec!(0.001), "USD")))
            .with_posting(Posting::auto("Assets:Cash"));

        let result = interpolate(&txn).expect("interpolation should succeed");

        let filled = &result.transaction.postings[2];
        let amount = get_amount(filled).expect("should have amount");

        // The amount is exactly -0.101, which fits in 3 decimal places
        assert_eq!(amount.number, dec!(-0.101));
        // Scale should be 3 (the maximum of 1 and 3)
        assert_eq!(amount.number.scale(), 3);
    }

    /// Test that cost spec scale is used when other postings have lower scale.
    ///
    /// Issue: <https://github.com/rustledger/rustledger/issues/333>
    ///
    /// When a transaction has:
    /// - A cost spec with decimal places (e.g., {2800.01 CAD})
    /// - Other postings with fewer decimal places (e.g., 1 CAD)
    ///
    /// The interpolated amount should use the cost spec's scale, not the
    /// lower scale from other postings.
    #[test]
    fn test_interpolate_cost_scale_preserved() {
        // From issue #333:
        // 2026-01-19 * "Buy stock"
        //   Assets:Stock  1 CSU { 2800.01 CAD }
        //   Expenses:Commission  1 CAD
        //   Assets:Cash
        //
        // Cost: 1 * 2800.01 = 2800.01 CAD (scale 2)
        // Commission: 1 CAD (scale 0)
        // Without fix: Cash rounds to -2801.00 (scale 0), leaving 0.01 residual
        // With fix: Cash is -2801.01 (scale 2), transaction balances
        let txn = Transaction::new(date(2026, 1, 19), "Buy stock")
            .with_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(1), "CSU")).with_cost(
                    rustledger_core::CostSpec::empty()
                        .with_number_per(dec!(2800.01))
                        .with_currency("CAD"),
                ),
            )
            .with_posting(Posting::new(
                "Expenses:Commission",
                Amount::new(dec!(1), "CAD"),
            ))
            .with_posting(Posting::auto("Assets:Cash"));

        let result = interpolate(&txn).expect("interpolation should succeed");

        // Check that Cash was filled
        assert_eq!(result.filled_indices, vec![2]);

        // The interpolated amount should be -2801.01 (scale 2 from cost spec)
        let filled = &result.transaction.postings[2];
        let amount = get_amount(filled).expect("should have amount");
        assert_eq!(amount.currency, "CAD");
        assert_eq!(
            amount.number,
            dec!(-2801.01),
            "should be -2801.01 CAD (preserving cost spec precision)"
        );

        // Transaction should balance (no residual)
        let residual = result
            .residuals
            .get("CAD")
            .copied()
            .unwrap_or(Decimal::ZERO);
        assert!(
            residual.is_zero(),
            "CAD residual should be 0, got {residual}"
        );
    }

    // =========================================================================
    // Currency inference from cost basis tests
    // =========================================================================

    /// Test that zero-amount postings are removed when transaction balances perfectly.
    /// Test that zero-amount postings from balanced cost basis are preserved.
    ///
    /// When a transaction with cost basis balances to zero (e.g., cost equals cash),
    /// the empty posting is filled with 0 USD and preserved. Previously these were
    /// pruned, but that hid validation errors (see issue #877).
    ///
    /// Example:
    /// ```beancount
    /// Assets:Crypto    100 USDC {1.0 USD, 2022-04-16}
    /// Assets:Cash     -100 USD
    /// Income:Trading   ; <- filled with 0 USD, preserved
    /// ```
    #[test]
    fn test_interpolate_balanced_cost_preserves_zero_posting() {
        let txn = Transaction::new(date(2022, 4, 16), "Trade")
            .with_posting(
                Posting::new("Assets:Crypto", Amount::new(dec!(100), "USDC")).with_cost(
                    rustledger_core::CostSpec::empty()
                        .with_number_per(dec!(1.0))
                        .with_currency("USD"),
                ),
            )
            .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-100), "USD")))
            .with_posting(Posting::auto("Income:Trading"));

        let result = interpolate(&txn).expect("interpolation should succeed");

        // The zero-amount posting should be filled and preserved
        assert_eq!(
            result.filled_indices,
            vec![2],
            "zero-amount posting should be in filled_indices"
        );

        // Transaction should have all 3 postings
        assert_eq!(
            result.transaction.postings.len(),
            3,
            "zero-amount posting should be preserved in transaction"
        );

        // The filled posting should have 0 USD
        let filled = &result.transaction.postings[2];
        let amount = filled.units.as_ref().unwrap().as_amount().unwrap();
        assert!(amount.number.is_zero());
        assert_eq!(amount.currency, "USD");
    }

    /// Test that zero-amount postings from zero-cost basis are preserved.
    ///
    /// When a posting has a zero cost like `{0 USD}`, the empty posting
    /// is filled with 0 USD and preserved for validation.
    /// See issue #877.
    ///
    /// Example:
    /// ```beancount
    /// Assets:Crypto    100 TOKEN {0 USD}
    /// Income:Bonus     ; <- filled with 0 USD, preserved
    /// ```
    #[test]
    fn test_interpolate_zero_cost_preserves_zero_posting() {
        let txn = Transaction::new(date(2022, 4, 16), "Free tokens")
            .with_posting(
                Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
                    rustledger_core::CostSpec::empty()
                        .with_number_per(dec!(0))
                        .with_currency("USD"),
                ),
            )
            .with_posting(Posting::auto("Income:Bonus"));

        let result = interpolate(&txn).expect("interpolation should succeed");

        // The zero-amount posting should be preserved
        assert_eq!(
            result.filled_indices,
            vec![1],
            "zero-amount posting should be in filled_indices"
        );

        // Transaction should have both postings
        assert_eq!(
            result.transaction.postings.len(),
            2,
            "zero-amount posting should be preserved in transaction"
        );
    }

    /// Test that zero-amount postings from zero total cost are preserved.
    ///
    /// Example:
    /// ```beancount
    /// Assets:Crypto    100 TOKEN {{0 USD}}
    /// Income:Bonus     ; <- filled with 0 USD, preserved
    /// ```
    #[test]
    fn test_interpolate_zero_total_cost_preserves_zero_posting() {
        let txn = Transaction::new(date(2022, 4, 16), "Free tokens")
            .with_posting(
                Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
                    rustledger_core::CostSpec::empty()
                        .with_number_total(dec!(0))
                        .with_currency("USD"),
                ),
            )
            .with_posting(Posting::auto("Income:Bonus"));

        let result = interpolate(&txn).expect("interpolation should succeed");

        // The zero-amount posting should be preserved
        assert_eq!(
            result.filled_indices,
            vec![1],
            "zero-amount posting should be in filled_indices"
        );

        // Transaction should have both postings
        assert_eq!(
            result.transaction.postings.len(),
            2,
            "zero-amount posting should be preserved in transaction"
        );
    }

    /// Regression test for issue #877 (beancount/beancount#962).
    /// Zero-value interpolated postings must NOT be pruned, because pruning
    /// can hide validation errors (e.g., E1001 for unopened accounts).
    #[test]
    fn test_zero_value_posting_preserved_for_validation() {
        // An elided posting on an account that would interpolate to zero.
        // Even though the amount is zero, the posting must survive interpolation
        // so that downstream validation can detect the unopened account.
        let txn = Transaction::new(date(2022, 1, 1), "Test")
            .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(100), "USD")))
            .with_posting(Posting::new(
                "Expenses:Food",
                Amount::new(dec!(-100), "USD"),
            ))
            .with_posting(Posting::auto("Income:Unopened"));

        let result = interpolate(&txn).expect("interpolation should succeed");

        // The Income:Unopened posting must still be present
        assert_eq!(
            result.transaction.postings.len(),
            3,
            "zero-value elided posting must be preserved so validation can check the account"
        );

        // Verify the preserved posting is the one we expect
        let preserved = &result.transaction.postings[2];
        assert_eq!(preserved.account, "Income:Unopened");
        let amount = preserved.units.as_ref().unwrap().as_amount().unwrap();
        assert!(amount.number.is_zero());
    }
}
