//! Transaction booking with lot matching.
//!
//! This module handles:
//! - Tracking inventory across transactions
//! - Matching sold lots against existing holdings
//! - Calculating capital gains/losses
//! - Filling in cost specs for lot reductions

// ratchet: fxhash-only — hot path; use FxHashMap/FxHashSet, not std SipHash collections (#1237).
use rustc_hash::{FxHashMap, FxHashSet};
use rustledger_core::{
    AccountedBookingError, Amount, BookingMethod, Cost, CostSpec, Directive, IncompleteAmount,
    Inventory, Position, Posting, ReductionScope, Transaction,
};
use thiserror::Error;

use crate::{InterpolationError, InterpolationResult, interpolate};

// Note: We no longer quantize calculated values during booking.
// Python beancount preserves full precision during booking and only
// rounds at display time. Premature rounding of per-unit costs (e.g.,
// from total cost / units) causes cost basis errors when selling.
// For example: 300.00 / 1.763 = 170.16505... should NOT be rounded
// to 170.17, because 1.763 * 170.17 = 300.00971 ≠ 300.00.

/// Errors that can occur during booking.
///
/// Inventory-level failures (insufficient units, no matching lot, ambiguous
/// match, currency mismatch) are unified under [`BookingError::Inventory`],
/// which carries an [`AccountedBookingError`] from `rustledger-core`. This
/// keeps the user-facing wording in **one place** so it cannot drift between
/// the booking layer and the validator — see #748 / #750.
#[derive(Debug, Clone, Error)]
pub enum BookingError {
    /// An inventory-level booking failure (insufficient units, no matching
    /// lot, ambiguous match, currency mismatch).
    ///
    /// `Display` is delegated to the inner [`AccountedBookingError`], which
    /// is the single canonical source of wording for booking errors. The
    /// pta-standards `reduction-exceeds-inventory` conformance test depends
    /// on this Display containing the literal substring `"not enough"`.
    #[error(transparent)]
    Inventory(AccountedBookingError),

    /// Interpolation failed after booking.
    #[error("interpolation failed: {0}")]
    Interpolation(#[from] InterpolationError),
}

/// Result of booking a single transaction.
#[derive(Debug, Clone)]
pub struct BookedTransaction {
    /// The transaction with costs filled in.
    pub transaction: Transaction,
    /// Capital gains/losses generated by this transaction.
    pub gains: Vec<CapitalGain>,
    /// Which posting indices had costs filled in.
    pub booked_indices: Vec<usize>,
}

/// A capital gain or loss from a lot sale.
#[derive(Debug, Clone)]
pub struct CapitalGain {
    /// The account holding the asset.
    pub account: rustledger_core::Account,
    /// The currency of the asset.
    pub currency: rustledger_core::Currency,
    /// The gain amount (positive) or loss (negative).
    pub amount: Amount,
    /// Cost basis of the sold lot.
    pub cost_basis: Amount,
    /// Sale proceeds.
    pub proceeds: Amount,
}

/// Booking engine that tracks inventory across transactions.
#[derive(Debug, Default)]
pub struct BookingEngine {
    /// Inventory per account.
    inventories: FxHashMap<rustledger_core::Account, Inventory>,
    /// Default booking method, used for accounts without an explicit
    /// booking method on their `open` directive.
    booking_method: BookingMethod,
    /// Per-account booking method overrides (from `open` directives).
    /// Looked up first, falling back to `booking_method` if absent.
    account_methods: FxHashMap<rustledger_core::Account, BookingMethod>,
}

impl BookingEngine {
    /// Create a new booking engine with default FIFO booking.
    #[must_use]
    pub fn new() -> Self {
        Self {
            inventories: FxHashMap::default(),
            booking_method: BookingMethod::Fifo,
            account_methods: FxHashMap::default(),
        }
    }

    /// Create a booking engine with a specific default booking method.
    #[must_use]
    pub fn with_method(method: BookingMethod) -> Self {
        Self {
            inventories: FxHashMap::default(),
            booking_method: method,
            account_methods: FxHashMap::default(),
        }
    }

    /// Register the booking method for a specific account.
    ///
    /// Call this for each `open` directive *before* booking transactions for
    /// that account, so the engine uses the per-account method (e.g. FIFO,
    /// LIFO, NONE) rather than the engine-wide default. Subsequent calls
    /// overwrite the previous method for the account.
    pub fn set_account_method(&mut self, account: rustledger_core::Account, method: BookingMethod) {
        self.account_methods.insert(account, method);
    }

    /// Scan a sequence of directives and register any per-account booking
    /// methods found on `open` directives. Open directives whose booking
    /// method is absent or fails to parse are silently ignored (they fall
    /// back to the engine-wide default).
    ///
    /// This is a convenience wrapper around [`Self::set_account_method`] for
    /// the common pipeline pattern of scanning all directives once before
    /// the booking loop. Call this before booking any transactions so the
    /// engine uses each account's declared method rather than the
    /// engine-wide default for every account.
    pub fn register_account_methods<'a, I>(&mut self, directives: I)
    where
        I: IntoIterator<Item = &'a rustledger_core::Directive>,
    {
        for directive in directives {
            if let rustledger_core::Directive::Open(open) = directive
                && let Some(method_str) = &open.booking
                && let Ok(method) = method_str.parse::<BookingMethod>()
            {
                self.set_account_method(open.account.clone(), method);
            }
        }
    }

    /// Resolve the booking method for an account, falling back to the
    /// engine-wide default if not registered.
    fn method_for(&self, account: &rustledger_core::Account) -> BookingMethod {
        self.account_methods
            .get(account)
            .copied()
            .unwrap_or(self.booking_method)
    }

    /// Get the inventory for an account.
    #[must_use]
    pub fn inventory(&self, account: &rustledger_core::Account) -> Option<&Inventory> {
        self.inventories.get(account)
    }

    /// Book a transaction: fill in empty cost specs and calculate gains.
    ///
    /// This does NOT modify the internal inventories - call `apply` for that.
    ///
    /// When a reduction matches multiple lots (e.g., selling shares that were purchased
    /// across multiple buy transactions), the posting is expanded into multiple postings,
    /// one for each matched lot. This matches Python beancount's behavior.
    pub fn book(&self, txn: &Transaction) -> Result<BookedTransaction, BookingError> {
        // Fast path: if no postings have cost specs, no booking is needed.
        // This avoids expensive inventory cloning for simple transactions.
        let has_cost_specs = txn.postings.iter().any(|p| p.cost.is_some());
        if !has_cost_specs {
            return Ok(BookedTransaction {
                transaction: txn.clone(),
                gains: Vec::new(),
                booked_indices: Vec::new(),
            });
        }

        let mut result = txn.clone();
        let mut gains = Vec::new();
        let mut booked_indices: FxHashSet<usize> =
            FxHashSet::with_capacity_and_hasher(txn.postings.len(), Default::default());
        // Track posting expansions: (original_idx, expanded_postings)
        let mut expansions: Vec<(usize, Vec<rustledger_core::Spanned<Posting>>)> =
            Vec::with_capacity(txn.postings.len());

        // Create working copies of inventories for this transaction.
        // This allows us to track inventory changes across multiple postings
        // within the same transaction (e.g., main sale + fee posting).
        //
        // Clone only the inventories we actually need for this transaction's
        // accounts. Use `entry().or_insert_with(...)` so that a posting list
        // with repeated accounts (e.g., two postings on `Assets:Stock`) only
        // triggers one clone per unique account instead of cloning the same
        // inventory every time it appears. Without deduping, the optimization
        // would be silently undone by transactions that list the same
        // account more than once.
        let mut working_inventories: FxHashMap<rustledger_core::Account, Inventory> =
            FxHashMap::with_capacity_and_hasher(txn.postings.len(), Default::default());
        for posting in &txn.postings {
            if let Some(inv) = self.inventories.get(&posting.account) {
                working_inventories
                    .entry(posting.account.clone())
                    .or_insert_with(|| inv.clone());
            }
        }

        // First pass: identify postings that need lot matching (reductions)
        for (idx, posting) in txn.postings.iter().enumerate() {
            // Check if this is a reduction with a cost spec
            if let Some(IncompleteAmount::Complete(units)) = &posting.units
                && let Some(cost_spec) = &posting.cost
            {
                // Check if this is a reduction (units have opposite sign of inventory)
                // This handles both:
                // - Selling long positions (negative units, positive inventory)
                // - Closing short positions (positive units, negative inventory)
                if let Some(inv) = working_inventories.get_mut(&posting.account) {
                    // Check if these units reduce existing cost-bearing inventory lots.
                    // Only positions with a cost basis are considered; simple (no-cost)
                    // positions are ignored to avoid misclassifying augmentations.
                    //
                    // Under `option "booking_method" "NONE"` (issue #1182),
                    // reduction matching is skipped entirely: NONE means
                    // "accumulate positions without booking against
                    // existing lots." Otherwise the booker would replace
                    // the user-written `{{ total }}` cost spec with a
                    // FIFO-matched per-unit (line ~282 below), and the
                    // residual calculation downstream would weigh the
                    // posting by the matched lots' costs instead of the
                    // user's stated total — producing a phantom
                    // E3001 imbalance for ledgers that round-trip
                    // cleanly through Python beancount.
                    let method = self.method_for(&posting.account);
                    let is_reduction = method != BookingMethod::None
                        && inv.is_reduced_by(units, ReductionScope::CostBearingOnly);

                    if is_reduction {
                        // Use reduce (not try_reduce) to actually update the working inventory.
                        // This ensures subsequent postings in the same transaction see
                        // the updated inventory state (e.g., after first posting exhausts a lot).
                        //
                        // Booking errors (ambiguous match, no matching lot, insufficient
                        // units) are propagated so callers see them once. The full
                        // pipeline path in `rustledger check` filters failed transactions
                        // out of the validator's input to avoid double-reporting against
                        // the validator's independent lot-matching pass.
                        // (`method` is resolved above next to the NONE-method gate.)
                        let booking_result = inv
                            .reduce(units, Some(cost_spec), method)
                            .map_err(|e| convert_core_booking_error(e, &posting.account))?;
                        {
                            // Check if multiple lots were matched
                            if booking_result.matched.len() > 1 {
                                // Expand single posting into multiple postings
                                let mut expanded = Vec::new();
                                for matched_pos in &booking_result.matched {
                                    let mut new_posting = posting.clone();
                                    // Set units to the matched portion with NEGATED sign
                                    // (matched_pos.units has the inventory sign, but we need
                                    // the reduction sign which is opposite)
                                    let expanded_units = rustledger_core::Amount::new(
                                        -matched_pos.units.number, // Negate: inventory→reduction
                                        matched_pos.units.currency.clone(),
                                    );
                                    new_posting.units =
                                        Some(IncompleteAmount::Complete(expanded_units));
                                    // Set cost from the matched lot
                                    if let Some(cost) = &matched_pos.cost {
                                        new_posting.cost = Some(CostSpec {
                                            number: Some(rustledger_core::CostNumber::PerUnit {
                                                value: cost.number,
                                            }),
                                            currency: Some(cost.currency.clone()),
                                            date: cost.date,
                                            label: cost.label.clone(),
                                            merge: false,
                                        });
                                    }
                                    expanded.push(new_posting);
                                }
                                expansions.push((idx, expanded));
                                booked_indices.insert(idx);
                            } else if let Some(cost_basis) = &booking_result.cost_basis {
                                // Single lot match - update posting in place
                                let per_unit = cost_basis.number / units.number.abs();
                                // Use new_calculated since per_unit is computed from total/units
                                let matched_cost =
                                    Cost::new_calculated(per_unit, cost_basis.currency.clone())
                                        .with_date_opt(
                                            booking_result
                                                .matched
                                                .first()
                                                .and_then(|p| p.cost.as_ref())
                                                .and_then(|c| c.date),
                                        );

                                // Update posting with filled cost
                                result.postings[idx].cost = Some(CostSpec {
                                    number: Some(rustledger_core::CostNumber::PerUnit {
                                        value: matched_cost.number,
                                    }),
                                    currency: Some(matched_cost.currency.clone()),
                                    date: matched_cost.date,
                                    label: None,
                                    merge: false,
                                });
                                booked_indices.insert(idx);
                            }

                            // Calculate capital gain if there's a price
                            if let Some(cost_basis) = &booking_result.cost_basis
                                && let Some(price) = &posting.price
                                && let Some(amt) =
                                    price.amount.as_ref().and_then(IncompleteAmount::as_amount)
                            {
                                let sale_price = match price.kind {
                                    rustledger_core::PriceKind::Unit => {
                                        amt.number * units.number.abs()
                                    }
                                    rustledger_core::PriceKind::Total => amt.number,
                                };

                                let gain_amount = sale_price - cost_basis.number;
                                if !gain_amount.is_zero() {
                                    gains.push(CapitalGain {
                                        account: posting.account.clone(),
                                        currency: units.currency.clone(),
                                        amount: Amount::new(gain_amount, &cost_basis.currency),
                                        cost_basis: cost_basis.clone(),
                                        proceeds: Amount::new(sale_price, &cost_basis.currency),
                                    });
                                }
                            }
                        }
                    }
                    // If not a reduction: fall through to augmentation code below
                }

                if let Some(rustledger_core::CostNumber::Total { value: total }) = cost_spec.number
                {
                    // Augmentation with total cost — convert to the
                    // post-booking `PerUnitFromTotal` shape:
                    //   `1.763 VIIIX {{300.00 USD}}` → derived per-unit
                    //   170.165… with total 300.00 preserved.
                    // The preserved total is load-bearing for
                    // precision-preserving residual math (#1026) —
                    // division-then-multiplication at the
                    // `rust_decimal` 28-digit ceiling loses precision.
                    if let Some(currency) = &cost_spec.currency
                        && !units.number.is_zero()
                    {
                        let per_unit = total / units.number.abs();
                        result.postings[idx].cost = Some(CostSpec {
                            number: Some(rustledger_core::CostNumber::PerUnitFromTotal(
                                rustledger_core::BookedCost::new(per_unit, total, units.number),
                            )),
                            currency: Some(currency.clone()),
                            // Fill in transaction date if no date specified
                            date: cost_spec.date.or(Some(txn.date)),
                            label: cost_spec.label.clone(),
                            merge: cost_spec.merge,
                        });
                        booked_indices.insert(idx);
                    }
                }

                // Fill in dates and currencies for augmentations (not already booked)
                if !booked_indices.contains(&idx) && cost_spec.number.is_some() {
                    // Cost spec has a number but may be missing date or currency
                    // Fill in missing parts from price annotation, other postings, and transaction date
                    let inferred_currency = cost_spec.currency.clone().or_else(|| {
                        // First try price annotation on this posting.
                        // `kind` (Unit vs Total) doesn't change the currency,
                        // so it's irrelevant here — we just want whatever
                        // currency the price names, complete or incomplete.
                        posting
                                .price
                                .as_ref()
                                .and_then(|p| p.amount.as_ref())
                                .and_then(|inc| inc.currency().map(Into::into))
                                // Then try inferring from other postings in the transaction
                                .or_else(|| crate::infer_cost_currency_from_postings(txn))
                    });

                    // Check if this is a reduction (opposite sign exists in inventory)
                    // Reductions get their date from matched lot, augmentations get txn date
                    let is_reduction = self.inventories.get(&posting.account).is_some_and(|inv| {
                        inv.is_reduced_by(units, ReductionScope::CostBearingOnly)
                    });

                    // Fill in date for augmentations only (not reductions)
                    let inferred_date = if is_reduction {
                        None // Reductions get their date from matched lot
                    } else {
                        cost_spec.date.or(Some(txn.date))
                    };

                    // Only update if we actually inferred something
                    if inferred_currency.is_some() || inferred_date.is_some() {
                        result.postings[idx].cost = Some(CostSpec {
                            number: cost_spec.number,
                            currency: inferred_currency.or_else(|| cost_spec.currency.clone()),
                            date: inferred_date.or(cost_spec.date),
                            label: cost_spec.label.clone(),
                            merge: cost_spec.merge,
                        });
                    }
                }
            }
        }

        // Apply posting expansions (replace single postings with multiple)
        // Build new postings Vec in one O(n) pass instead of O(n²) remove+insert
        if !expansions.is_empty() {
            // Sort expansions by index for forward iteration
            expansions.sort_by_key(|(idx, _)| *idx);

            let mut new_postings = Vec::with_capacity(
                result.postings.len() + expansions.iter().map(|(_, e)| e.len()).sum::<usize>(),
            );
            let mut expansion_iter = expansions.into_iter().peekable();

            for (idx, posting) in result.postings.into_iter().enumerate() {
                if expansion_iter
                    .peek()
                    .is_some_and(|(exp_idx, _)| *exp_idx == idx)
                {
                    // Replace this posting with expanded postings
                    let (_, expanded) = expansion_iter.next().unwrap();
                    new_postings.extend(expanded);
                } else {
                    // Keep original posting
                    new_postings.push(posting);
                }
            }
            result.postings = new_postings;
        }

        // NOTE: Price normalization (@@→@) is NOT done here to preserve exact
        // total prices for precise residual calculation. Call `normalize_prices()`
        // on the transaction after validation to convert total prices to per-unit.

        Ok(BookedTransaction {
            transaction: result,
            gains,
            booked_indices: booked_indices.into_iter().collect(),
        })
    }

    /// Apply a transaction to the inventories (update balances).
    pub fn apply(&mut self, txn: &Transaction) {
        for posting in &txn.postings {
            if let Some(IncompleteAmount::Complete(units)) = &posting.units {
                // Resolve the per-account booking method before mutably
                // borrowing the inventories map.
                let method = self.method_for(&posting.account);
                let inv = self.inventories.entry(posting.account.clone()).or_default();

                // Determine if this is a reduction: units reduce inventory when
                // signs differ for the same currency. Only cost-bearing positions
                // are considered, so simple (no-cost) positions don't trigger
                // false reduction detection.
                //
                // NONE booking (issue #1182) treats every posting as an
                // augmentation. Mixed-sign positions accumulate in the
                // inventory — Python beancount's semantic for the NONE
                // method, which the parallel guard in `book()` mirrors.
                let is_reduction = method != BookingMethod::None
                    && posting.cost.is_some()
                    && inv.is_reduced_by(units, ReductionScope::CostBearingOnly);

                if is_reduction {
                    // Reduce from inventory
                    let _ = inv.reduce(units, posting.cost.as_ref(), method);
                } else {
                    // Add to inventory
                    let position = if let Some(cost_spec) = &posting.cost {
                        // Resolve per-unit cost: PerUnit/PerUnitFromTotal
                        // carry it directly; Total needs the
                        // total/|units| division.
                        let per_unit_cost = match cost_spec.number {
                            Some(rustledger_core::CostNumber::PerUnit { value: per }) => Some(per),
                            Some(rustledger_core::CostNumber::PerUnitFromTotal(b)) => {
                                Some(b.per_unit)
                            }
                            Some(rustledger_core::CostNumber::Total { value: total })
                                if !units.number.is_zero() =>
                            {
                                Some(total / units.number.abs())
                            }
                            Some(rustledger_core::CostNumber::Total { value: _ }) | None => None,
                        };

                        // Infer cost currency from price annotation or other postings.
                        // `kind` doesn't affect the currency — see the parallel
                        // block above for the same pattern.
                        let cost_currency = cost_spec.currency.clone().or_else(|| {
                            posting
                                .price
                                .as_ref()
                                .and_then(|p| p.amount.as_ref())
                                .and_then(|inc| inc.currency().map(Into::into))
                                .or_else(|| crate::infer_cost_currency_from_postings(txn))
                        });

                        if let (Some(per_unit), Some(currency)) = (per_unit_cost, cost_currency) {
                            Position::with_cost(
                                units.clone(),
                                Cost::new(per_unit, currency)
                                    .with_date_opt(cost_spec.date.or(Some(txn.date)))
                                    .with_label_opt(cost_spec.label.clone()),
                            )
                        } else {
                            Position::simple(units.clone())
                        }
                    } else {
                        Position::simple(units.clone())
                    };
                    inv.add(position);
                }
            }
        }
    }

    /// Book and interpolate a transaction.
    ///
    /// This fills in empty cost specs, then interpolates any missing amounts.
    pub fn book_and_interpolate(
        &self,
        txn: &Transaction,
    ) -> Result<InterpolationResult, BookingError> {
        // First book (fill in costs)
        let booked = self.book(txn)?;

        // Then interpolate (fill in missing amounts)
        let result = interpolate(&booked.transaction)?;

        Ok(result)
    }
}

/// Convert a core inventory `BookingError` into the booking-layer error,
/// attaching the account context that the core layer doesn't carry.
///
/// All inventory-level failures funnel into a single
/// [`BookingError::Inventory`] variant. The user-facing wording lives in the
/// `Display` impl on [`AccountedBookingError`] so it cannot drift between
/// the booking layer and the validator (#748 / #750).
fn convert_core_booking_error(
    err: rustledger_core::BookingError,
    account: &rustledger_core::Account,
) -> BookingError {
    BookingError::Inventory(err.with_account(account.clone()))
}

/// Book and interpolate a list of transactions.
///
/// This processes transactions in order, tracking inventory to enable
/// proper lot matching and capital gains calculation.
pub fn book_transactions(
    transactions: &[Transaction],
    method: BookingMethod,
) -> Vec<Result<InterpolationResult, BookingError>> {
    let mut engine = BookingEngine::with_method(method);
    let mut results = Vec::with_capacity(transactions.len());

    for txn in transactions {
        let result = engine.book_and_interpolate(txn);
        if let Ok(ref interpolated) = result {
            // Apply the booked transaction (with filled-in costs), not the original
            engine.apply(&interpolated.transaction);
        }
        results.push(result);
    }

    results
}

/// Outcome of booking an entire ledger in one shot — see [`book`].
#[derive(Debug, Clone)]
pub struct LedgerBookResult {
    /// Successfully booked directives, in the **original input order**.
    /// Every `Transaction` has its cost specs filled and elided amounts
    /// interpolated; all other directive kinds pass through unchanged.
    pub booked: Vec<Directive>,
    /// Directives whose `Transaction` failed to book, in original input
    /// order, paired with the error. They are left in their pre-booking
    /// shape so a caller can still surface the user's original input.
    pub failed: Vec<(Directive, BookingError)>,
}

/// Book and interpolate every transaction in a ledger in one shot.
///
/// This is the standalone equivalent of the loader's internal booking
/// pass. Transactions are processed in **booking order** — sorted by
/// `(date, priority, has_cost_reduction)` — so lot matching and
/// capital-gains tracking observe inventory in the correct sequence, while
/// the returned [`LedgerBookResult::booked`] / [`LedgerBookResult::failed`]
/// vectors preserve the caller's **original input order**. Non-transaction
/// directives pass through untouched. Per-account booking methods declared
/// via `Open ... "METHOD"` are honored; `method` is the fallback for
/// accounts that declare none.
///
/// Booking is a pure function of its inputs, so calling it twice on the
/// same `(directives, method)` yields equal results — this is the booking
/// half of the #1235 pipeline-boundary invariants.
#[must_use]
pub fn book(directives: &[Directive], method: BookingMethod) -> LedgerBookResult {
    let mut engine = BookingEngine::with_method(method);
    engine.register_account_methods(directives.iter());

    // Stable sort into booking order. Display order — `(date, priority,
    // file position)` — is already encoded in the input's positional order,
    // and a stable sort keeps that as the tiebreak.
    let mut order: Vec<usize> = (0..directives.len()).collect();
    order.sort_by_key(|&i| {
        let d = &directives[i];
        (d.date(), d.priority(), d.has_cost_reduction())
    });

    // Book in booking order, recording each transaction's outcome against
    // its original index so the result can be reassembled in input order.
    let mut booked_txns: Vec<Option<Transaction>> = directives.iter().map(|_| None).collect();
    let mut booking_errors: Vec<Option<BookingError>> = directives.iter().map(|_| None).collect();
    for &i in &order {
        if let Directive::Transaction(txn) = &directives[i] {
            match engine.book_and_interpolate(txn) {
                Ok(result) => {
                    // Apply the booked transaction (filled-in costs), not
                    // the original, so subsequent lot matching is correct.
                    engine.apply(&result.transaction);
                    booked_txns[i] = Some(result.transaction);
                }
                Err(e) => booking_errors[i] = Some(e),
            }
        }
    }

    // Reassemble in original input order, partitioning failures out.
    let mut booked = Vec::with_capacity(directives.len());
    let mut failed = Vec::new();
    for (i, directive) in directives.iter().enumerate() {
        if let Some(e) = booking_errors[i].take() {
            failed.push((directive.clone(), e));
        } else if let Some(txn) = booked_txns[i].take() {
            booked.push(Directive::Transaction(txn));
        } else {
            booked.push(directive.clone());
        }
    }

    LedgerBookResult { booked, failed }
}

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

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

    #[test]
    fn test_book_simple_buy() {
        let mut engine = BookingEngine::new();

        // Buy 10 AAPL at $150
        let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit {
                            value: dec!(150.00),
                        })
                        .with_currency("USD"),
                ),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(-1500.00), "USD"),
            ));

        engine.apply(&buy);

        // Check inventory
        let inv = engine.inventory(&"Assets:Stock".into()).unwrap();
        assert_eq!(inv.units("AAPL"), dec!(10));
    }

    #[test]
    fn test_book_sell_with_gain() {
        let mut engine = BookingEngine::new();

        // Buy 10 AAPL at $150
        let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit {
                            value: dec!(150.00),
                        })
                        .with_currency("USD"),
                ),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(-1500.00), "USD"),
            ));

        engine.apply(&buy);

        // Sell 5 AAPL at $175 with empty cost (needs lot matching)
        let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
                    .with_cost(CostSpec::empty()) // Empty - needs lot matching
                    .with_price(PriceAnnotation::unit(Amount::new(dec!(175.00), "USD"))),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(875.00), "USD"),
            ))
            .with_synthesized_posting(Posting::auto("Income:CapitalGains")); // Elided

        // Check inventory before sell
        let inv = engine.inventory(&"Assets:Stock".into()).unwrap();
        eprintln!("Inventory before sell: {inv:?}");

        let booked = engine.book(&sell).unwrap();
        eprintln!(
            "Booked: gains={:?}, indices={:?}",
            booked.gains, booked.booked_indices
        );
        eprintln!("Booked transaction: {:?}", booked.transaction);

        // Check that gain was calculated
        assert_eq!(
            booked.gains.len(),
            1,
            "Expected 1 gain, got {:?}",
            booked.gains
        );
        let gain = &booked.gains[0];
        // Gain = 5 * (175 - 150) = 125
        assert_eq!(gain.amount.number, dec!(125));
    }

    #[test]
    fn test_book_with_total_cost() {
        let mut engine = BookingEngine::new();

        // Buy 1.763 VIIIX with total cost of 300 USD (like healthequity file)
        let buy = Transaction::new(date(2016, 1, 16), "Buy stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(1.763), "VIIIX")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::Total {
                            value: dec!(300.00),
                        })
                        .with_currency("USD"),
                ),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(-300.00), "USD"),
            ));

        engine.apply(&buy);

        // Check inventory
        let inv = engine.inventory(&"Assets:Stock".into()).unwrap();
        eprintln!("Inventory after total cost buy: {inv:?}");
        assert_eq!(inv.units("VIIIX"), dec!(1.763));

        // Check cost was calculated correctly (300/1.763 ≈ 170.16)
        let pos = inv.positions().next().unwrap();
        assert!(pos.cost.is_some(), "Expected cost on position");
        eprintln!("Position cost: {:?}", pos.cost);
    }

    #[test]
    fn test_book_total_cost_then_sell() {
        // Test that book() correctly handles total cost syntax and preserves
        // full precision for accurate capital gains calculation.
        let mut engine = BookingEngine::new();

        // Buy 1.763 VIIIX with total cost {{300.00 USD}}
        let buy = Transaction::new(date(2016, 1, 16), "Buy stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(1.763), "VIIIX")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::Total {
                            value: dec!(300.00),
                        })
                        .with_currency("USD"),
                ),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(-300.00), "USD"),
            ));

        // Use book() to test the booking path with total cost
        let booked_buy = engine.book(&buy).unwrap();
        engine.apply(&booked_buy.transaction);

        // Check that per-unit cost was calculated (300/1.763)
        let buy_posting = &booked_buy.transaction.postings[0];
        assert!(buy_posting.cost.is_some());
        let cost_spec = buy_posting.cost.as_ref().unwrap();
        // Booking should have converted the user-written Total into
        // the post-booking PerUnitFromTotal shape — the per-unit value
        // is computed for lot tracking and the total is preserved for
        // exact residual math.
        assert!(matches!(
            cost_spec.number,
            Some(rustledger_core::CostNumber::PerUnitFromTotal(_))
        ));

        // Sell all shares at $191 per unit
        let sell = Transaction::new(date(2016, 6, 15), "Sell stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(-1.763), "VIIIX"))
                    .with_cost(CostSpec::empty())
                    .with_price(PriceAnnotation::unit(Amount::new(dec!(191.00), "USD"))),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(336.73), "USD"), // 1.763 * 191 = 336.733
            ))
            .with_synthesized_posting(Posting::auto("Income:CapitalGains"));

        let booked_sell = engine.book(&sell).unwrap();

        // Capital gain should be: 336.73 - 300.00 = 36.73
        // With full precision preserved, this should be accurate
        assert_eq!(booked_sell.gains.len(), 1);
        let gain = &booked_sell.gains[0];
        // The gain should be close to 36.73 (sale proceeds - cost basis)
        // Sale: 1.763 * 191 = 336.733, Cost: 300.00, Gain ≈ 36.73
        eprintln!("Capital gain: {:?}", gain.amount);
    }

    #[test]
    fn test_cost_spec_currency_inference() {
        let mut engine = BookingEngine::new();

        // Create SELLOPT: -1 AAPL {40.0} @ 0.4 USD
        // This has cost number (40.0) but NO cost currency - should infer from price
        let sell = Transaction::new(date(2022, 6, 17), "SELLOPT")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(-1), "AAPL"))
                    .with_cost(
                        CostSpec::empty().with_number(rustledger_core::CostNumber::PerUnit {
                            value: dec!(40.0),
                        }),
                    )
                    .with_price(PriceAnnotation::unit(Amount::new(dec!(0.4), "USD"))),
            )
            .with_synthesized_posting(Posting::new("Assets:Stock", Amount::new(dec!(40.0), "USD")));

        eprintln!("SELLOPT posting.cost = {:?}", sell.postings[0].cost);
        eprintln!("SELLOPT posting.price = {:?}", sell.postings[0].price);

        engine.apply(&sell);

        let inv = engine.inventory(&"Assets:Stock".into()).unwrap();
        eprintln!("Inventory after SELLOPT: {inv:?}");

        // Check that the AAPL position has cost with USD currency
        let aapl_pos = inv
            .positions()
            .find(|p| p.units.currency.as_ref() == "AAPL")
            .expect("Should have AAPL position");

        eprintln!("AAPL position: {aapl_pos:?}");

        assert!(aapl_pos.cost.is_some(), "AAPL position should have cost");
        let cost = aapl_pos.cost.as_ref().unwrap();
        assert_eq!(cost.currency.as_ref(), "USD", "Cost currency should be USD");
        assert_eq!(cost.number, dec!(40.0), "Cost number should be 40.0");
    }

    #[test]
    fn test_booking_engine_with_method() {
        // Test that with_method creates engine with specified booking method
        let engine = BookingEngine::with_method(BookingMethod::Lifo);
        assert!(engine.inventories.is_empty());

        // Also test default is FIFO
        let default_engine = BookingEngine::new();
        assert!(default_engine.inventories.is_empty());
    }

    #[test]
    fn test_book_sell_with_total_price() {
        let mut engine = BookingEngine::new();

        // Buy 10 AAPL at $150
        let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit {
                            value: dec!(150.00),
                        })
                        .with_currency("USD"),
                ),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(-1500.00), "USD"),
            ));

        engine.apply(&buy);

        // Sell 5 AAPL with total price annotation (not per-unit)
        // Total price = $875 for 5 shares = $175/share
        let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
                    .with_cost(CostSpec::empty())
                    .with_price(PriceAnnotation::total(Amount::new(dec!(875.00), "USD"))),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(875.00), "USD"),
            ))
            .with_synthesized_posting(Posting::auto("Income:CapitalGains"));

        let booked = engine.book(&sell).unwrap();

        // Check that gain was calculated correctly
        // Gain = 875 - (5 * 150) = 875 - 750 = 125
        assert_eq!(booked.gains.len(), 1, "Expected 1 gain");
        let gain = &booked.gains[0];
        assert_eq!(gain.amount.number, dec!(125));
    }

    #[test]
    fn test_book_transactions_multiple() {
        // Buy 10 AAPL at $150
        let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit {
                            value: dec!(150.00),
                        })
                        .with_currency("USD"),
                ),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(-1500.00), "USD"),
            ));

        // Sell 5 AAPL
        let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
                    .with_cost(CostSpec::empty())
                    .with_price(PriceAnnotation::unit(Amount::new(dec!(175.00), "USD"))),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(875.00), "USD"),
            ))
            .with_synthesized_posting(Posting::auto("Income:CapitalGains"));

        let transactions = vec![buy, sell];
        let results = book_transactions(&transactions, BookingMethod::Fifo);

        assert_eq!(results.len(), 2);
        assert!(results[0].is_ok());
        assert!(results[1].is_ok());
    }

    #[test]
    fn test_book_augmentation_not_reduction() {
        let mut engine = BookingEngine::new();

        // First, add existing inventory with positive AAPL
        let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit {
                            value: dec!(150.00),
                        })
                        .with_currency("USD"),
                ),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(-1500.00), "USD"),
            ));

        engine.apply(&buy);

        // Now try to book another buy (augmentation, not reduction)
        // This has empty cost but same sign as inventory, so it's not a reduction
        let another_buy = Transaction::new(date(2024, 2, 15), "Buy more")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(5), "AAPL"))
                    .with_cost(CostSpec::empty()), // Empty cost but augmentation
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(-750.00), "USD"),
            ));

        // Should not error - just skip lot matching for augmentation
        let booked = engine.book(&another_buy).unwrap();
        assert!(
            booked.booked_indices.is_empty(),
            "Augmentation should not have booked indices"
        );
    }

    #[test]
    fn test_book_no_inventory_for_account() {
        let engine = BookingEngine::new();

        // Try to book a sell without any prior inventory
        let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
                    .with_cost(CostSpec::empty()),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(875.00), "USD"),
            ));

        // Should succeed but with no booked indices (no inventory to match against)
        let booked = engine.book(&sell).unwrap();
        assert!(
            booked.booked_indices.is_empty(),
            "No inventory means no lot matching"
        );
    }

    #[test]
    fn test_book_zero_gain() {
        let mut engine = BookingEngine::new();

        // Buy 10 AAPL at $150
        let buy = Transaction::new(date(2024, 1, 15), "Buy stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit {
                            value: dec!(150.00),
                        })
                        .with_currency("USD"),
                ),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(-1500.00), "USD"),
            ));

        engine.apply(&buy);

        // Sell at same price - zero gain
        let sell = Transaction::new(date(2024, 6, 15), "Sell stock")
            .with_synthesized_posting(
                Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
                    .with_cost(CostSpec::empty())
                    .with_price(PriceAnnotation::unit(Amount::new(dec!(150.00), "USD"))),
            )
            .with_synthesized_posting(Posting::new(
                "Assets:Cash",
                Amount::new(dec!(750.00), "USD"),
            ));

        let booked = engine.book(&sell).unwrap();

        // Zero gain should not be added to gains vector
        assert!(booked.gains.is_empty(), "Zero gain should not be recorded");
    }

    /// Test cost currency inference from other postings (issue #230).
    ///
    /// When a cost is specified without a currency (e.g., `{1}`), the currency
    /// should be inferred from simple postings in the same transaction.
    #[test]
    fn test_cost_currency_inference_from_other_postings() {
        let mut engine = BookingEngine::new();

        // Opening balance with cost without currency - should infer USD from other posting
        // 2026-01-01 * "Opening balance"
        //   Assets:Abc   1 ABC {1}           <- no currency, should infer USD
        //   Equity:Opening-Balances -1 USD
        let open = Transaction::new(date(2026, 1, 1), "Opening balance")
            .with_synthesized_posting(
                Posting::new("Assets:Abc", Amount::new(dec!(1), "ABC")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1) }),
                ), // No currency!
            )
            .with_synthesized_posting(Posting::new(
                "Equity:Opening-Balances",
                Amount::new(dec!(-1), "USD"),
            ));

        // Book and apply the opening
        let booked = engine.book(&open).unwrap();
        engine.apply(&booked.transaction);

        // Check that the cost spec was filled in with USD
        let cost_spec = booked.transaction.postings[0].cost.as_ref().unwrap();
        assert_eq!(
            cost_spec.currency.as_deref(),
            Some("USD"),
            "Cost currency should be inferred as USD from other posting"
        );

        // Check inventory has the position with correct cost
        let inv = engine.inventory(&"Assets:Abc".into()).unwrap();
        let pos = inv.positions().next().unwrap();
        assert!(pos.cost.is_some(), "Position should have cost");
        let cost = pos.cost.as_ref().unwrap();
        assert_eq!(cost.currency.as_ref(), "USD", "Cost currency should be USD");
        assert_eq!(cost.number, dec!(1), "Cost number should be 1");

        // Now sell with explicit cost currency - should match the lot
        // 2026-01-02 * "Sale"
        //   Assets:Abc  -1 ABC {1 USD}
        //   Expenses:Abc
        let sell = Transaction::new(date(2026, 1, 2), "Sale")
            .with_synthesized_posting(
                Posting::new("Assets:Abc", Amount::new(dec!(-1), "ABC")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1) })
                        .with_currency("USD"),
                ),
            )
            .with_synthesized_posting(Posting::auto("Expenses:Abc"));

        // This should succeed - the lot with {1 USD} should be found
        let booked_sell = engine.book(&sell).unwrap();

        // Check that the lot was matched
        assert!(
            !booked_sell.booked_indices.is_empty(),
            "Sale should match the lot created in opening"
        );
    }

    #[test]
    fn test_multi_posting_crosses_lot_boundary() {
        // Regression test: Multiple postings in the same transaction reducing
        // the same commodity should correctly track inventory state across postings.
        // Previously, each posting would see the original inventory instead of
        // the updated state after processing previous postings.

        let mut engine = BookingEngine::new();

        // Create two lots of ADA with different costs
        // Lot 1: 100 ADA at $0.50 (2021-01-01)
        let buy1 = Transaction::new(date(2021, 1, 1), "Buy lot 1")
            .with_synthesized_posting(
                Posting::new("Assets:Crypto", Amount::new(dec!(100), "ADA")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0.50) })
                        .with_currency("USD")
                        .with_date(date(2021, 1, 1)),
                ),
            )
            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-50), "USD")));
        engine.apply(&buy1);

        // Lot 2: 100 ADA at $0.52 (2022-05-19)
        let buy2 = Transaction::new(date(2022, 5, 19), "Buy lot 2")
            .with_synthesized_posting(
                Posting::new("Assets:Crypto", Amount::new(dec!(100), "ADA")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0.52) })
                        .with_currency("USD")
                        .with_date(date(2022, 5, 19)),
                ),
            )
            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-52), "USD")));
        engine.apply(&buy2);

        // Verify initial inventory: 200 ADA total
        let inv = engine.inventory(&"Assets:Crypto".into()).unwrap();
        assert_eq!(inv.units("ADA"), dec!(200));

        // Consume half of lot 1 first
        let sell1 = Transaction::new(date(2022, 5, 20), "Sell 50 ADA")
            .with_synthesized_posting(
                Posting::new("Assets:Crypto", Amount::new(dec!(-50), "ADA"))
                    .with_cost(CostSpec::empty()),
            )
            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(25), "USD")));
        let booked1 = engine.book(&sell1).unwrap();
        engine.apply(&booked1.transaction);

        // Verify: 150 ADA remaining (50 in lot 1, 100 in lot 2)
        let inv = engine.inventory(&"Assets:Crypto".into()).unwrap();
        assert_eq!(inv.units("ADA"), dec!(150));

        // Now the critical test: TWO postings in the same transaction
        // that together cross the lot boundary.
        // - Posting 1: -75 ADA {} → takes 50 from lot 1 + 25 from lot 2
        // - Posting 2: -5 ADA {} → should take from lot 2 (continuing)
        let sell2 = Transaction::new(date(2022, 5, 22), "Sell 80 ADA (multi-posting)")
            .with_synthesized_posting(
                Posting::new("Assets:Crypto", Amount::new(dec!(-75), "ADA"))
                    .with_cost(CostSpec::empty()),
            )
            .with_synthesized_posting(
                Posting::new("Assets:Crypto", Amount::new(dec!(-5), "ADA"))
                    .with_cost(CostSpec::empty()),
            )
            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(42), "USD")));

        // This should succeed - the bug was that the second posting would fail
        // with "No matching lot" because it was trying to match against lot 1
        // which was already exhausted by the first posting.
        let booked2 = engine.book(&sell2);
        assert!(
            booked2.is_ok(),
            "Multi-posting transaction should succeed: {:?}",
            booked2.err()
        );

        // Apply and verify final inventory: 70 ADA remaining (all in lot 2)
        engine.apply(&booked2.unwrap().transaction);
        let inv = engine.inventory(&"Assets:Crypto".into()).unwrap();
        assert_eq!(
            inv.units("ADA"),
            dec!(70),
            "Should have 70 ADA remaining in lot 2"
        );
    }

    #[test]
    fn test_book_no_cost_specs_fast_path() {
        // Test that the fast path for transactions without cost specs
        // returns correct empty gains and booked_indices.
        let engine = BookingEngine::new();

        // Simple expense transaction with no cost specs
        let txn = Transaction::new(date(2024, 1, 15), "Groceries")
            .with_synthesized_posting(Posting::new("Expenses:Food", Amount::new(dec!(50), "USD")))
            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-50), "USD")));

        let result = engine.book(&txn).unwrap();

        // Fast path should return empty gains and booked_indices
        assert!(result.gains.is_empty(), "Should have no capital gains");
        assert!(
            result.booked_indices.is_empty(),
            "Should have no booked indices"
        );

        // Transaction should be unchanged
        assert_eq!(result.transaction.postings.len(), 2);
        assert_eq!(
            result.transaction.postings[0].units,
            Some(IncompleteAmount::Complete(Amount::new(dec!(50), "USD")))
        );
    }

    /// Regression test for #748.
    ///
    /// The pta-standards `reduction-exceeds-inventory` conformance test
    /// asserts on `error_contains: ["not enough"]`. PR #745 made the booking
    /// layer propagate `InsufficientUnits` directly to the user instead of
    /// letting the validator's "Not enough units in ..." message win, which
    /// dropped the "not enough" phrasing. This test pins the user-facing
    /// Display string so the conformance assertion (and any downstream user
    /// tooling that greps the message) cannot regress silently again.
    ///
    /// After #750, the canonical Display lives on
    /// [`rustledger_core::AccountedBookingError`] and `BookingError::Inventory`
    /// delegates to it transparently — so this test exercises the same path
    /// the validator and `cmd/check.rs` use.

    // =========================================================================
    // Regression test for issue #875 / beancount#889
    //
    // Scenario: buy stock with cost, sell without cost spec (leaves a simple
    // negative position), then buy more with cost spec. The third transaction
    // must succeed as an augmentation, not fail as a reduction.
    // =========================================================================

    #[test]
    fn test_augmentation_after_sell_without_cost_spec() {
        // Regression test for issue #875 / beancount#889.
        //
        // Before the fix, the sell-without-cost-spec left a -25 HOOG simple
        // position, causing the subsequent buy-with-cost-spec to be
        // misclassified as a reduction (because is_reduced_by saw opposite
        // signs without distinguishing cost-bearing vs simple positions).
        let mut engine = BookingEngine::new();

        // 2024-01-10: Buy 100 HOOG {1.50 EUR}
        let buy1 = Transaction::new(date(2024, 1, 10), "Buy 100 HOOG")
            .with_synthesized_posting(
                Posting::new("Assets:Stocks", Amount::new(dec!(100), "HOOG")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1.50) })
                        .with_currency("EUR"),
                ),
            )
            .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(-150), "EUR")));

        engine.apply(&buy1);

        // 2024-01-15: Sell 25 HOOG without cost spec (price-only)
        let sell = Transaction::new(date(2024, 1, 15), "Sell 25 HOOG without cost spec")
            .with_synthesized_posting(
                Posting::new("Assets:Stocks", Amount::new(dec!(-25), "HOOG"))
                    .with_price(PriceAnnotation::unit(Amount::new(dec!(1.60), "EUR"))),
            )
            .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(40), "EUR")));

        engine.apply(&sell);

        // 2024-01-20: Buy 50 more HOOG {1.70 EUR} - this MUST succeed
        let buy2 = Transaction::new(date(2024, 1, 20), "Buy 50 more HOOG - should succeed")
            .with_synthesized_posting(
                Posting::new("Assets:Stocks", Amount::new(dec!(50), "HOOG")).with_cost(
                    CostSpec::empty()
                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1.70) })
                        .with_currency("EUR"),
                ),
            )
            .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(-85), "EUR")));

        // This should NOT fail. Before the fix, the engine would see the
        // -25 HOOG simple position and try to reduce, which would fail
        // because the cost spec wouldn't match any existing lot.
        let result = engine.book(&buy2);
        assert!(
            result.is_ok(),
            "Buy with cost spec after sell without cost spec should succeed as augmentation, \
             but got error: {:?}",
            result.err()
        );

        let booked = result.unwrap();
        engine.apply(&booked.transaction);

        // Verify final inventory state
        let inv = engine.inventory(&"Assets:Stocks".into()).unwrap();
        // 100 (original) - 25 (sold simple) + 50 (new lot) = 125 HOOG total
        assert_eq!(inv.units("HOOG"), dec!(125));
    }

    #[test]
    fn test_insufficient_units_display_contains_not_enough() {
        let err = BookingError::Inventory(
            rustledger_core::BookingError::InsufficientUnits {
                currency: "AAPL".into(),
                requested: dec!(15),
                available: dec!(10),
            }
            .with_account("Assets:Stock".into()),
        );
        let rendered = format!("{err}");
        assert!(
            rendered.contains("not enough"),
            "InsufficientUnits Display must contain 'not enough' for beancount \
             compatibility (#748). Got: {rendered}"
        );
        assert!(
            rendered.contains("Assets:Stock"),
            "InsufficientUnits Display must include the account name. Got: {rendered}"
        );
        assert!(
            rendered.contains("15") && rendered.contains("10"),
            "InsufficientUnits Display must include requested and available amounts. Got: {rendered}"
        );
    }

    /// Helper: does any posting still have an unfilled (elided) amount?
    fn has_elided_posting(txn: &Transaction) -> bool {
        txn.postings.iter().any(|p| p.units.is_none())
    }

    #[test]
    fn book_interpolates_elided_posting_and_preserves_order() {
        use rustledger_core::Open;

        let directives = vec![
            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
            Directive::Transaction(
                Transaction::new(date(2024, 1, 15), "Lunch")
                    .with_synthesized_posting(Posting::new(
                        "Expenses:Food",
                        Amount::new(dec!(50.00), "USD"),
                    ))
                    .with_synthesized_posting(Posting::auto("Assets:Cash")),
            ),
        ];

        let result = book(&directives, BookingMethod::Strict);
        assert!(result.failed.is_empty(), "nothing should fail to book");
        assert_eq!(result.booked.len(), 3, "all directives preserved");

        // Order preserved: the two Opens come first, unchanged.
        assert_eq!(result.booked[0], directives[0]);
        assert_eq!(result.booked[1], directives[1]);

        // The transaction's elided posting got filled in.
        let Directive::Transaction(booked_txn) = &result.booked[2] else {
            panic!("third directive should still be a transaction");
        };
        assert!(
            !has_elided_posting(booked_txn),
            "the auto posting should have been interpolated"
        );
    }

    #[test]
    fn book_is_deterministic() {
        use rustledger_core::Open;

        let directives = vec![
            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
            Directive::Transaction(
                Transaction::new(date(2024, 1, 15), "Buy")
                    .with_synthesized_posting(
                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
                            .with_price(PriceAnnotation::unit(Amount::new(dec!(150.00), "USD"))),
                    )
                    .with_synthesized_posting(Posting::auto("Assets:Cash")),
            ),
        ];

        let first = book(&directives, BookingMethod::Strict);
        let second = book(&directives, BookingMethod::Strict);
        assert_eq!(
            first.booked, second.booked,
            "booking the same ledger twice must produce identical output"
        );
    }

    #[test]
    fn book_partitions_failed_transaction() {
        use rustledger_core::Open;

        // Buy a lot at $150, then sell against a cost basis ($200) that
        // matches no existing lot. Under Strict this is a no-matching-lot
        // error, so the sell is partitioned into `failed`.
        let buy_cost = CostSpec::empty()
            .with_number(rustledger_core::CostNumber::PerUnit {
                value: dec!(150.00),
            })
            .with_currency("USD");
        let sell_cost = CostSpec::empty()
            .with_number(rustledger_core::CostNumber::PerUnit {
                value: dec!(200.00),
            })
            .with_currency("USD");

        let directives = vec![
            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
            Directive::Transaction(
                Transaction::new(date(2024, 1, 10), "Buy")
                    .with_synthesized_posting(
                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
                            .with_cost(buy_cost),
                    )
                    .with_synthesized_posting(Posting::new(
                        "Assets:Cash",
                        Amount::new(dec!(-1500.00), "USD"),
                    )),
            ),
            Directive::Transaction(
                Transaction::new(date(2024, 1, 15), "Sell at phantom cost basis")
                    .with_synthesized_posting(
                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
                            .with_cost(sell_cost),
                    )
                    .with_synthesized_posting(Posting::new(
                        "Assets:Cash",
                        Amount::new(dec!(1000.00), "USD"),
                    )),
            ),
        ];

        let result = book(&directives, BookingMethod::Strict);
        assert_eq!(result.failed.len(), 1, "the mismatched sell should fail");
        // The two Opens and the successful buy survive; the sell is dropped.
        assert_eq!(result.booked.len(), 3, "Opens + buy remain in booked");
        assert!(
            !result.booked.iter().any(|d| matches!(
                d,
                Directive::Transaction(t) if t.narration.as_ref() == "Sell at phantom cost basis"
            )),
            "failed sell must not appear in booked"
        );
    }
}
