//! Contains book keeping logics to process the input stream,
//! and convert them into a processed Transactions.

use annotate_snippets::{Annotation, AnnotationKind};
use bumpalo::collections as bcc;
use bumpalo::Bump;
use chrono::NaiveDate;
use rust_decimal::Decimal;

use crate::parse::ParsedSpan;
use crate::{
    report::eval::EvalError,
    syntax::{
        self,
        decoration::AsUndecorated,
        tracked::{Tracked, TrackedSpan},
    },
};

use super::{
    balance::{Balance, BalanceError},
    context::ReportContext,
    eval::{Amount, Evaluable, OwnedEvalError, PostingAmount, SingleAmount},
    price_db::{PriceEvent, PriceRepositoryBuilder, PriceSource},
    transaction::{Posting, Transaction},
};

/// Error related to transaction understanding.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum BookKeepError {
    #[error("failed to evaluate the expression: {0}")]
    EvalFailure(#[source] OwnedEvalError, TrackedSpan),
    #[error("failed balance assertion: {source}")]
    BalanceFailure {
        #[source]
        source: BalanceError,
        account: TrackedSpan,
        balance: TrackedSpan,
    },
    #[error("transaction cannot have multiple postings without constraints")]
    UndeduciblePostingAmount(Tracked<usize>, Tracked<usize>),
    #[error("transaction cannot have unbalanced postings: {0}")]
    UnbalancedPostings(String),
    #[error("balance assertion off by {diff}, computed balance is {computed}")]
    BalanceAssertionFailure {
        account_span: TrackedSpan,
        balance_span: TrackedSpan,
        computed: String,
        diff: String,
    },
    #[error("already registered account alias: {0}")]
    InvalidAccountAlias(String),
    #[error("already registered commodity alias: {0}")]
    InvalidCommodityAlias(String),
    #[error("posting without commodity should not have exchange")]
    ZeroAmountWithExchange(TrackedSpan),
    #[error("cost or lot exchange must not be zero")]
    ZeroExchangeRate(TrackedSpan),
    #[error("cost or lot exchange must have different commodity from the amount commodity")]
    ExchangeWithAmountCommodity {
        posting_amount: TrackedSpan,
        exchange: TrackedSpan,
    },
}

impl BookKeepError {
    /// Returns annotations corresponding to the given span.
    pub(super) fn annotations<'arena>(
        &self,
        bump: &'arena Bump,
        parsed_span: &ParsedSpan,
        text: &str,
    ) -> Vec<Annotation<'arena>> {
        match self {
            BookKeepError::EvalFailure(err, pos) => vec![AnnotationKind::Primary
                .span(parsed_span.resolve(pos))
                .label(bumpalo::format!(in &bump, "{}", err).into_bump_str())],
            BookKeepError::BalanceFailure {
                source: err,
                account,
                balance,
            } => vec![
                AnnotationKind::Primary
                    .span(parsed_span.resolve(balance))
                    .label(bumpalo::format!(in &bump, "{}", err).into_bump_str()),
                AnnotationKind::Context
                    .span(parsed_span.resolve(account))
                    .label(bumpalo::format!(in &bump, "{}", err.note()).into_bump_str()),
            ],
            BookKeepError::UndeduciblePostingAmount(first, second) => vec![
                AnnotationKind::Context
                    .span(parsed_span.resolve(&first.span()))
                    .label("first posting without constraints"),
                AnnotationKind::Primary
                    .span(parsed_span.resolve(&second.span()))
                    .label("cannot deduce this posting"),
            ],
            BookKeepError::BalanceAssertionFailure {
                balance_span,
                account_span,
                computed,
                ..
            } => {
                let msg = bumpalo::format!(
                    in &bump,
                    "computed balance: {}", computed,
                );
                vec![
                    AnnotationKind::Primary
                        .span(parsed_span.resolve(balance_span))
                        .label("not match the computed balance"),
                    AnnotationKind::Context
                        .span(parsed_span.resolve(account_span))
                        .label(msg.into_bump_str()),
                ]
            }
            BookKeepError::ZeroAmountWithExchange(exchange) => vec![AnnotationKind::Primary
                .span(parsed_span.resolve(exchange))
                .label("absolute zero posting should not have exchange")],
            BookKeepError::ZeroExchangeRate(exchange) => vec![AnnotationKind::Primary
                .span(parsed_span.resolve(exchange))
                .label("exchange with zero amount")],
            BookKeepError::ExchangeWithAmountCommodity {
                posting_amount,
                exchange,
            } => vec![
                AnnotationKind::Context
                    .span(parsed_span.resolve(posting_amount))
                    .label("posting amount"),
                AnnotationKind::Primary
                    .span(parsed_span.resolve(exchange))
                    .label("exchange cannot have the same commodity with posting"),
            ],
            _ => {
                // TODO: Add more detailed error into this.
                // Also, put these logic into BookKeepError.
                // https://github.com/xkikeg/okane/issues/189
                vec![AnnotationKind::Primary
                    .span(0..text.len())
                    .label("error occured")]
            }
        }
    }
}

/// Adds a syntax transaction, and converts it into a processed Transaction.
pub fn add_transaction<'ctx>(
    ctx: &mut ReportContext<'ctx>,
    price_repos: &mut PriceRepositoryBuilder<'ctx>,
    bal: &mut Balance<'ctx>,
    txn: &syntax::tracked::Transaction,
) -> Result<Transaction<'ctx>, BookKeepError> {
    // First, process all postings, except the one without balance and amount,
    // which must be deduced later. And that should appear at most once.
    let mut postings = bcc::Vec::with_capacity_in(txn.posts.len(), ctx.arena);
    let mut unfilled: Option<Tracked<usize>> = None;
    let mut balance = Amount::default();
    let txn_payee: &'ctx str = ctx.arena.alloc_str(&txn.payee);
    for (i, posting) in txn.posts.iter().enumerate() {
        let posting = posting.as_undecorated();
        let account_span = posting.account.span();
        let account = ctx.accounts.ensure(posting.account.as_undecorated());
        // Per-posting `; Payee: ...` metadata overrides the transaction payee.
        let payee: &'ctx str = posting_payee_override(ctx, posting).unwrap_or(txn_payee);
        let (evaluated, price_event) = match process_posting(ctx, bal, txn.date, account, posting)?
        {
            (Some(x), y) => (x, y),
            (None, y) => {
                if let Some(first) = unfilled.replace(Tracked::new(i, account_span.clone())) {
                    return Err(BookKeepError::UndeduciblePostingAmount(
                        first,
                        Tracked::new(i, account_span.clone()),
                    ));
                } else {
                    // placeholder which will be replaced later.
                    // balance_delta is also zero as we don't know the value yet.
                    (
                        EvaluatedPosting {
                            amount: PostingAmount::zero(),
                            converted_amount: None,
                            balance_delta: PostingAmount::zero(),
                        },
                        y,
                    )
                }
            }
        };
        if let Some(event) = price_event {
            price_repos.insert_price(PriceSource::Ledger, event);
        }
        balance += evaluated.balance_delta;
        postings.push(Posting {
            account,
            payee,
            amount: evaluated.amount.into(),
            converted_amount: evaluated.converted_amount,
        });
    }
    if let Some(u) = unfilled {
        let u = *u.as_undecorated();
        // Note that deduced amount can be multi-commodity, neither SingleAmount nor PostingAmount.
        let deduced: Amount = balance.negate();
        postings[u].amount = deduced.clone();
        bal.add_amount(postings[u].account, deduced);
    } else {
        check_balance(ctx, price_repos, &mut postings, txn.date, balance)?;
    }
    Ok(Transaction {
        date: txn.date,
        postings: postings.into_boxed_slice(),
    })
}

/// Computed amount of [`Posting`],
struct EvaluatedPosting<'ctx> {
    /// Amount of the transaction.
    amount: PostingAmount<'ctx>,

    /// Converted amount, provided only when cost / lot annotation given.
    converted_amount: Option<SingleAmount<'ctx>>,

    /// Delta of balance within the transaction.
    balance_delta: PostingAmount<'ctx>,
}

fn map_eval_err<'ctx>(
    ctx: &mut ReportContext<'ctx>,
    e: EvalError<'ctx>,
    span: TrackedSpan,
) -> BookKeepError {
    BookKeepError::EvalFailure(e.into_owned(ctx), span)
}

fn process_posting<'ctx>(
    ctx: &mut ReportContext<'ctx>,
    bal: &mut Balance<'ctx>,
    date: NaiveDate,
    account: super::Account<'ctx>,
    posting: &syntax::tracked::Posting,
) -> Result<(Option<EvaluatedPosting<'ctx>>, Option<PriceEvent<'ctx>>), BookKeepError> {
    match (&posting.amount, &posting.balance) {
        // posting with just `Account`, we need to deduce from other postings.
        (None, None) => Ok((None, None)),
        // posting with `Account   = X`.
        (None, Some(balance_constraints)) => {
            let balance_span = balance_constraints.span();
            let current: PostingAmount = balance_constraints
                .as_undecorated()
                .eval_mut(ctx)
                .and_then(|x| x.try_into())
                .map_err(|e| map_eval_err(ctx, e, balance_span.clone()))?;
            let prev: PostingAmount = bal.set_partial(ctx, account, current).map_err(|e| {
                BookKeepError::BalanceFailure {
                    source: e,
                    account: posting.account.span(),
                    balance: balance_span.clone(),
                }
            })?;
            let amount = current
                .check_sub(prev)
                .map_err(|e| map_eval_err(ctx, e, balance_span))?;
            Ok((
                Some(EvaluatedPosting {
                    amount,
                    converted_amount: None,
                    balance_delta: amount,
                }),
                None,
            ))
        }
        // regular posting with `Account    X`, optionally with balance.
        (Some(syntax_amount), balance_constraints) => {
            let computed = ComputedPosting::compute_from_syntax(ctx, syntax_amount)?;
            let current = bal.add_posting_amount(account, computed.amount);
            if let Some(balance_constraints) = balance_constraints.as_ref() {
                let balance_span = balance_constraints.span();
                let expected: PostingAmount = balance_constraints
                    .as_undecorated()
                    .eval_mut(ctx)
                    .and_then(|x| x.try_into())
                    .map_err(|e| map_eval_err(ctx, e, balance_span.clone()))?;
                let diff = current.assert_balance(&expected);
                if !diff.is_absolute_zero() {
                    return Err(BookKeepError::BalanceAssertionFailure {
                        account_span: posting.account.span(),
                        balance_span: balance_constraints.span(),
                        computed: format!("{}", current.as_inline_display(ctx)),
                        diff: format!("{}", diff.as_inline_display(ctx)),
                    });
                }
            }
            let balance_delta = computed.calculate_balance_amount(ctx)?;
            Ok((
                Some(EvaluatedPosting {
                    amount: computed.amount,
                    converted_amount: computed.calculate_converted_amount(ctx)?,
                    balance_delta,
                }),
                posting_price_event(date, &computed)?,
            ))
        }
    }
}

/// Pre-computed [syntax::Posting].
struct ComputedPosting<'ctx> {
    amount_span: TrackedSpan,
    amount: PostingAmount<'ctx>,
    cost: Option<Exchange<'ctx>>,
    lot: Option<Exchange<'ctx>>,
}

impl<'ctx> ComputedPosting<'ctx> {
    /// Computes the given [`syntax::tracked::PostingAmount`] and creates an instance.
    fn compute_from_syntax(
        ctx: &mut ReportContext<'ctx>,
        syntax_amount: &syntax::tracked::PostingAmount<'_>,
    ) -> Result<Self, BookKeepError> {
        let amount_span = syntax_amount.amount.span();
        let amount: PostingAmount = syntax_amount
            .amount
            .as_undecorated()
            .eval_mut(ctx)
            .and_then(|x| x.try_into())
            .map_err(|e| map_eval_err(ctx, e, amount_span.clone()))?;
        let cost = posting_cost_exchange(syntax_amount)
            .map(|exchange| Exchange::try_from_syntax(ctx, syntax_amount, &amount, exchange))
            .transpose()?;
        let lot = posting_lot_exchange(syntax_amount)
            .map(|exchange| Exchange::try_from_syntax(ctx, syntax_amount, &amount, exchange))
            .transpose()?;
        Ok(ComputedPosting {
            amount_span,
            amount,
            cost,
            lot,
        })
    }

    fn calculate_converted_amount(
        &self,
        ctx: &mut ReportContext<'ctx>,
    ) -> Result<Option<SingleAmount<'ctx>>, BookKeepError> {
        let Some(rate) = self.cost.as_ref().or(self.lot.as_ref()) else {
            return Ok(None);
        };
        let amount = self
            .amount
            .try_into()
            .map_err(|e| map_eval_err(ctx, e, self.amount_span.clone()))?;
        Ok(Some(rate.exchange(amount)))
    }

    /// Returns the amount which sums up to the balance within the transaction.
    fn calculate_balance_amount(
        &self,
        ctx: &mut ReportContext<'ctx>,
    ) -> Result<PostingAmount<'ctx>, BookKeepError> {
        // Actually, there's no point to compute cost if lot price is provided.
        // Example: if you sell a X with cost p Y lot q Y.
        //   Broker          -a X {{q Y}} @@ p Y
        //   Broker           p Y
        //   Income   (-(p - q) Y)
        //
        // if you set the first posting amount t,
        // t + p Y - (p - q) Y = 0
        // t = -q Y
        // so actually cost is pointless in this case.
        match self.lot.as_ref().or(self.cost.as_ref()) {
            Some(x) => {
                let amount = self
                    .amount
                    .try_into()
                    .map_err(|e| map_eval_err(ctx, e, self.amount_span.clone()))?;
                Ok(x.exchange(amount).into())
            }
            None => Ok(self.amount),
        }
    }
}

/// Adds the price of the commodity in the posting into PriceRepositoryBuilder.
fn posting_price_event<'ctx>(
    date: NaiveDate,
    computed: &ComputedPosting<'ctx>,
) -> Result<Option<PriceEvent<'ctx>>, BookKeepError> {
    let exchange = match computed.cost.as_ref().or(computed.lot.as_ref()) {
        None => return Ok(None),
        Some(exchange) => exchange,
    };
    Ok(Some(match computed.amount {
        // TODO: here we can record --market commodities.
        PostingAmount::Zero => {
            unreachable!("Given Exchange is set None, this must be SingleAmount.")
        }
        PostingAmount::Single(amount) => match exchange {
            Exchange::Rate(rate) => PriceEvent {
                price_x: SingleAmount::from_value(amount.commodity, Decimal::ONE),
                price_y: *rate,
                date,
            },
            Exchange::Total(total) => PriceEvent {
                price_x: amount.abs(),
                price_y: *total,
                date,
            },
        },
    }))
}

/// Pre-copmuted syntax::Exchange.
enum Exchange<'ctx> {
    Total(SingleAmount<'ctx>),
    Rate(SingleAmount<'ctx>),
}

impl<'ctx> Exchange<'ctx> {
    fn is_zero(&self) -> bool {
        match self {
            Exchange::Total(x) => x.value.is_zero(),
            Exchange::Rate(x) => x.value.is_zero(),
        }
    }

    fn try_from_syntax<'a>(
        ctx: &mut ReportContext<'ctx>,
        syntax_amount: &syntax::tracked::PostingAmount<'a>,
        posting_amount: &PostingAmount<'ctx>,
        exchange: &syntax::tracked::Tracked<syntax::Exchange<'a>>,
    ) -> Result<Exchange<'ctx>, BookKeepError> {
        let (rate_commodity, rate) = match exchange.as_undecorated() {
            syntax::Exchange::Rate(rate) => {
                let rate: SingleAmount<'ctx> = rate
                    .eval_mut(ctx)
                    .and_then(|x| x.try_into())
                    .map_err(|e| map_eval_err(ctx, e, exchange.span()))?;
                (rate.commodity, Exchange::Rate(rate))
            }
            syntax::Exchange::Total(rate) => {
                let rate: SingleAmount<'ctx> = rate
                    .eval_mut(ctx)
                    .and_then(|x| x.try_into())
                    .map_err(|e| map_eval_err(ctx, e, exchange.span()))?;
                (rate.commodity, Exchange::Total(rate))
            }
        };
        if rate.is_zero() {
            return Err(BookKeepError::ZeroExchangeRate(exchange.span()));
        }
        match posting_amount {
            PostingAmount::Zero => Err(BookKeepError::ZeroAmountWithExchange(exchange.span())),
            PostingAmount::Single(amount) => {
                if amount.commodity == rate_commodity {
                    Err(BookKeepError::ExchangeWithAmountCommodity {
                        posting_amount: syntax_amount.amount.span(),
                        exchange: exchange.span(),
                    })
                } else {
                    Ok(rate)
                }
            }
        }
    }

    /// Given the exchange rate and the amount, returns the converted amount.
    /// This purely relies on syntax written posting with lot or cost information.
    fn exchange(&self, amount: SingleAmount<'ctx>) -> SingleAmount<'ctx> {
        match self {
            Exchange::Rate(rate) => *rate * amount.value,
            Exchange::Total(abs) => abs.with_sign_of(amount),
        }
    }
}

/// Checks if the posting amounts sum to zero.
fn check_balance<'ctx>(
    ctx: &ReportContext<'ctx>,
    price_repos: &mut PriceRepositoryBuilder<'ctx>,
    postings: &mut bcc::Vec<'ctx, Posting<'ctx>>,
    date: NaiveDate,
    balance: Amount<'ctx>,
) -> Result<(), BookKeepError> {
    log::trace!(
        "balance before rounding in txn: {}",
        balance.as_inline_display(ctx)
    );
    let balance = balance.round(ctx);
    if balance.is_zero() {
        return Ok(());
    }
    if let Some((a1, a2)) = balance.maybe_pair() {
        // fill in converted amount.
        for p in postings.iter_mut() {
            let amount: Result<SingleAmount<'_>, _> = (&p.amount).try_into();
            if let Ok(amount) = amount {
                // amount can be PostingAmount::Zero, or even multi commodities (in rare cases).
                // so we ignore the error.
                if a1.commodity == amount.commodity {
                    p.converted_amount = Some(SingleAmount::from_value(
                        a2.commodity,
                        (a2.value / a1.value).abs() * amount.value,
                    ));
                } else if a2.commodity == amount.commodity {
                    p.converted_amount = Some(SingleAmount::from_value(
                        a1.commodity,
                        (a1.value / a2.value).abs() * amount.value,
                    ));
                }
            }
        }
        // deduced price is logged to price repository.
        price_repos.insert_price(
            PriceSource::Ledger,
            PriceEvent {
                date,
                price_x: a1.abs(),
                price_y: a2.abs(),
            },
        );
        return Ok(());
    }
    if !balance.is_zero() {
        return Err(BookKeepError::UnbalancedPostings(format!(
            "{}",
            balance.as_inline_display(ctx)
        )));
    }
    Ok(())
}

/// Returns cost Exchange of the posting.
#[inline]
fn posting_cost_exchange<'a, 'ctx>(
    posting_amount: &'a syntax::tracked::PostingAmount<'ctx>,
) -> Option<&'a syntax::tracked::Tracked<syntax::Exchange<'ctx>>> {
    posting_amount.cost.as_ref()
}

/// Returns lot exchange of the posting.
#[inline]
fn posting_lot_exchange<'a, 'ctx>(
    posting_amount: &'a syntax::tracked::PostingAmount<'ctx>,
) -> Option<&'a syntax::tracked::Tracked<syntax::Exchange<'ctx>>> {
    posting_amount.lot.price.as_ref()
}

/// Returns the per-posting payee override (`; Payee: <name>` metadata), if any,
/// interned into the report arena.
fn posting_payee_override<'ctx>(
    ctx: &ReportContext<'ctx>,
    posting: &syntax::tracked::Posting,
) -> Option<&'ctx str> {
    posting.metadata.iter().find_map(|m| match m {
        syntax::Metadata::KeyValueTag {
            key,
            value: syntax::MetadataValue::Text(t),
        } if key.as_ref() == "Payee" => Some(ctx.arena.alloc_str(t.as_ref()) as &'ctx str),
        _ => None,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    use bumpalo::Bump;
    use chrono::NaiveDate;
    use indoc::indoc;
    use maplit::hashmap;
    use pretty_assertions::assert_eq;
    use rust_decimal_macros::dec;

    use crate::{
        parse::{self, testing::expect_parse_ok},
        syntax::tracked::TrackedSpan,
    };

    fn parse_transaction(input: &'_ str) -> syntax::tracked::Transaction<'_> {
        let (_, ret) = expect_parse_ok(parse::transaction::transaction, input);
        ret
    }

    #[test]
    fn add_transaction_fails_with_inconsistent_balance() {
        let arena = Bump::new();
        let mut ctx = ReportContext::new(&arena);
        let mut bal = Balance::default();
        bal.add_posting_amount(
            ctx.accounts.ensure("Account 1"),
            PostingAmount::from_value(ctx.commodities.ensure("JPY"), dec!(1000)),
        );
        let input = indoc! {"
            2024/08/01 Sample
              Account 2
              Account 1      200 JPY = 1300 JPY
        "};
        let txn = parse_transaction(input);
        let mut price_repos = PriceRepositoryBuilder::default();

        let got_err = add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).unwrap_err();

        assert!(
            matches!(got_err, BookKeepError::BalanceAssertionFailure { .. }),
            "unexpected got_err: {:?}",
            got_err
        );
    }

    #[test]
    fn add_transaction_fails_with_inconsistent_absolute_zero_balance() {
        let arena = Bump::new();
        let mut ctx = ReportContext::new(&arena);
        let mut bal = Balance::default();
        bal.add_posting_amount(
            ctx.accounts.ensure("Account 1"),
            PostingAmount::from_value(ctx.commodities.ensure("JPY"), dec!(1000)),
        );
        let input = indoc! {"
            2024/08/01 Sample
              Account 2
              Account 1      0 CHF = 0 ; must fail because of 1000 JPY
        "};
        let txn = parse_transaction(input);
        let mut price_repos = PriceRepositoryBuilder::default();

        let got_err = add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).unwrap_err();

        assert!(
            matches!(got_err, BookKeepError::BalanceAssertionFailure { .. }),
            "unexpected got_err: {:?}",
            got_err
        );
    }

    #[test]
    fn add_transaction_maintains_balance() {
        let arena = Bump::new();
        let mut ctx = ReportContext::new(&arena);
        let mut bal = Balance::default();
        bal.add_posting_amount(
            ctx.accounts.ensure("Account 1"),
            PostingAmount::from_value(ctx.commodities.ensure("JPY"), dec!(1000)),
        );
        bal.add_posting_amount(
            ctx.accounts.ensure("Account 1"),
            PostingAmount::from_value(ctx.commodities.ensure("EUR"), dec!(123)),
        );
        bal.add_posting_amount(
            ctx.accounts.ensure("Account 2"),
            PostingAmount::from_value(ctx.commodities.ensure("EUR"), dec!(1)),
        );
        bal.add_posting_amount(
            ctx.accounts.ensure("Account 4"),
            PostingAmount::from_value(ctx.commodities.ensure("CHF"), dec!(10)),
        );
        let input = indoc! {"
            2024/08/01 Sample
              Account 1      200 JPY = 1200 JPY
              Account 2        0 JPY = 0 JPY
              Account 2     -100 JPY = -100 JPY
              Account 2     -100 JPY = -200 JPY
              Account 3     2.00 CHF @ 150 JPY
              Account 4              = -300 JPY
        "};
        let txn = parse_transaction(input);
        let mut price_repos = PriceRepositoryBuilder::default();

        let _ = add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");

        let want_balance: Balance = hashmap! {
            ctx.accounts.ensure("Account 1") =>
                Amount::from_iter([
                    (ctx.commodities.ensure("JPY"), dec!(1200)),
                    (ctx.commodities.ensure("EUR"), dec!(123)),
                ]),
            ctx.accounts.ensure("Account 2") =>
                Amount::from_iter([
                    (ctx.commodities.ensure("JPY"), dec!(-200)),
                    (ctx.commodities.ensure("EUR"), dec!(1)),
                ]),
            ctx.accounts.ensure("Account 3") =>
                Amount::from_value(ctx.commodities.ensure("CHF"), dec!(2)),
            ctx.accounts.ensure("Account 4") =>
                Amount::from_iter([
                    (ctx.commodities.ensure("JPY"), dec!(-300)),
                    (ctx.commodities.ensure("CHF"), dec!(10)),
                ]),
        }
        .into_iter()
        .collect();
        assert_eq!(want_balance.into_vec(), bal.into_vec());
    }

    #[test]
    fn add_transaction_emits_transaction_with_postings() {
        let arena = Bump::new();
        let mut ctx = ReportContext::new(&arena);
        let mut bal = Balance::default();
        let input = indoc! {"
            2024/08/01 Sample
              Account 1      200 JPY = 200 JPY  ; Payee: Posting 1
              Account 2     -100 JPY = -100 JPY  ; Payee: Posting 2
              Account 2     -100 JPY = -200 JPY  ; Payee: Posting 3
        "};
        let txn = parse_transaction(input);
        let mut price_repos = PriceRepositoryBuilder::default();

        let got =
            add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");

        let want = Transaction {
            date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
            postings: bcc::Vec::from_iter_in(
                [
                    Posting {
                        account: ctx.accounts.ensure("Account 1"),
                        payee: "Posting 1",
                        amount: Amount::from_value(ctx.commodities.ensure("JPY"), dec!(200)),
                        converted_amount: None,
                    },
                    Posting {
                        account: ctx.accounts.ensure("Account 2"),
                        payee: "Posting 2",
                        amount: Amount::from_value(ctx.commodities.ensure("JPY"), dec!(-100)),
                        converted_amount: None,
                    },
                    Posting {
                        account: ctx.accounts.ensure("Account 2"),
                        payee: "Posting 3",
                        amount: Amount::from_value(ctx.commodities.ensure("JPY"), dec!(-100)),
                        converted_amount: None,
                    },
                ],
                &arena,
            )
            .into_boxed_slice(),
        };
        assert_eq!(want, got);
    }

    #[test]
    fn add_transaction_emits_transaction_with_deduce_and_balance_concern() {
        let arena = Bump::new();
        let mut ctx = ReportContext::new(&arena);
        let mut bal = Balance::default();
        let jpy = ctx.commodities.ensure("JPY");
        let usd = ctx.commodities.ensure("USD");
        bal.add_posting_amount(
            ctx.accounts.ensure("Account 1"),
            PostingAmount::from_value(jpy, dec!(1000)),
        );
        bal.add_posting_amount(
            ctx.accounts.ensure("Account 1"),
            PostingAmount::from_value(usd, dec!(123)),
        );
        bal.add_posting_amount(
            ctx.accounts.ensure("Account 2"),
            PostingAmount::from_value(jpy, dec!(-100)),
        );
        bal.add_posting_amount(
            ctx.accounts.ensure("Account 2"),
            PostingAmount::from_value(usd, dec!(-30)),
        );
        bal.add_posting_amount(
            ctx.accounts.ensure("Account 3"),
            PostingAmount::from_value(jpy, dec!(-150)),
        );
        let input = indoc! {"
            2024/08/01 Sample
              Account 1              = 1200 JPY  ; Payee: Posting 1
              Account 2              = 0 JPY  ; Payee: Posting 2
              Account 3              = 0  ; Payee: Posting 3
              Account 4  ; Payee: Posting 4
        "};
        let txn = parse_transaction(input);
        let mut price_repos = PriceRepositoryBuilder::default();

        let got =
            add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");

        let want = Transaction {
            date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
            postings: bcc::Vec::from_iter_in(
                [
                    Posting {
                        account: ctx.accounts.ensure("Account 1"),
                        payee: "Posting 1",
                        amount: Amount::from_value(jpy, dec!(200)),
                        converted_amount: None,
                    },
                    Posting {
                        account: ctx.accounts.ensure("Account 2"),
                        payee: "Posting 2",
                        amount: Amount::from_value(jpy, dec!(100)),
                        converted_amount: None,
                    },
                    Posting {
                        account: ctx.accounts.ensure("Account 3"),
                        payee: "Posting 3",
                        amount: Amount::from_value(jpy, dec!(150)),
                        converted_amount: None,
                    },
                    Posting {
                        account: ctx.accounts.ensure("Account 4"),
                        payee: "Posting 4",
                        amount: Amount::from_value(jpy, dec!(-450)),
                        converted_amount: None,
                    },
                ],
                &arena,
            )
            .into_boxed_slice(),
        };
        assert_eq!(want, got);
    }

    #[test]
    fn add_transaction_deduced_amount_contains_multi_commodity() {
        let arena = Bump::new();
        let mut ctx = ReportContext::new(&arena);
        let mut bal = Balance::default();
        let jpy = ctx.commodities.ensure("JPY");
        let chf = ctx.commodities.ensure("CHF");
        let eur = ctx.commodities.ensure("EUR");
        let input = indoc! {"
            2024/08/01 Sample
              Account 1         1200 JPY  ; Payee: Posting 1
              Account 2         234 EUR  ; Payee: Posting 2
              Account 3         34.56 CHF  ; Payee: Posting 3
              Account 4  ; Payee: Posting 4
        "};
        let txn = parse_transaction(input);
        let mut price_repos = PriceRepositoryBuilder::default();
        let got =
            add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");
        let want = Transaction {
            date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
            postings: bcc::Vec::from_iter_in(
                [
                    Posting {
                        account: ctx.accounts.ensure("Account 1"),
                        payee: "Posting 1",
                        amount: Amount::from_value(jpy, dec!(1200)),
                        converted_amount: None,
                    },
                    Posting {
                        account: ctx.accounts.ensure("Account 2"),
                        payee: "Posting 2",
                        amount: Amount::from_value(eur, dec!(234)),
                        converted_amount: None,
                    },
                    Posting {
                        account: ctx.accounts.ensure("Account 3"),
                        payee: "Posting 3",
                        amount: Amount::from_value(chf, dec!(34.56)),
                        converted_amount: None,
                    },
                    Posting {
                        account: ctx.accounts.ensure("Account 4"),
                        payee: "Posting 4",
                        amount: Amount::from_iter([
                            (jpy, dec!(-1200)),
                            (eur, dec!(-234)),
                            (chf, dec!(-34.56)),
                        ]),
                        converted_amount: None,
                    },
                ],
                &arena,
            )
            .into_boxed_slice(),
        };
        assert_eq!(want, got);
    }

    #[test]
    fn add_transaction_fails_when_two_posting_does_not_have_amount() {
        let input = indoc! {"
            2024/08/01 Sample
              Account 1 ; no amount
              Account 2 ; no amount
        "};
        let txn = parse_transaction(input);
        let arena = Bump::new();
        let mut ctx = ReportContext::new(&arena);
        let mut bal = Balance::default();
        let mut price_repos = PriceRepositoryBuilder::default();

        let got =
            add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect_err("must fail");

        assert_eq!(
            got,
            BookKeepError::UndeduciblePostingAmount(
                Tracked::new(0, TrackedSpan::new(20..29)),
                Tracked::new(1, TrackedSpan::new(44..53))
            )
        );
    }
    #[test]
    fn add_transaction_fails_when_posting_has_zero_cost() {
        let input = indoc! {"
            2024/08/01 Sample
              Account 1            1 AAPL @ 0 USD
              Account 2          100 USD
        "};
        let txn = parse_transaction(input);
        let arena = Bump::new();
        let mut ctx = ReportContext::new(&arena);
        let mut bal = Balance::default();
        let mut price_repos = PriceRepositoryBuilder::default();

        let got =
            add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect_err("must fail");

        assert_eq!(
            got,
            BookKeepError::ZeroExchangeRate(TrackedSpan::new(48..55))
        );
    }

    #[test]
    fn add_transaction_balances_with_lot() {
        let arena = Bump::new();
        let mut ctx = ReportContext::new(&arena);
        let mut bal = Balance::default();
        let input = indoc! {"
            2024/08/01 Sample
              Account 1             12 OKANE {100 JPY}  ; Payee: Posting 1
              Account 2         -1,200 JPY  ; Payee: Posting 2
        "};
        let date = NaiveDate::from_ymd_opt(2024, 8, 1).unwrap();
        let txn = parse_transaction(input);
        let mut price_repos = PriceRepositoryBuilder::default();

        let got =
            add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");

        let okane = ctx.commodities.resolve("OKANE").unwrap();
        let jpy = ctx.commodities.resolve("JPY").unwrap();
        let want = Transaction {
            date,
            postings: bcc::Vec::from_iter_in(
                [
                    Posting {
                        account: ctx.accounts.ensure("Account 1"),
                        payee: "Posting 1",
                        amount: Amount::from_value(okane, dec!(12)),
                        converted_amount: Some(SingleAmount::from_value(jpy, dec!(1200))),
                    },
                    Posting {
                        account: ctx.accounts.ensure("Account 2"),
                        payee: "Posting 2",
                        amount: Amount::from_value(jpy, dec!(-1200)),
                        converted_amount: None,
                    },
                ],
                &arena,
            )
            .into_boxed_slice(),
        };
        assert_eq!(want, got);

        let want_prices = vec![
            PriceEvent {
                date,
                price_x: SingleAmount::from_value(okane, dec!(1)),
                price_y: SingleAmount::from_value(jpy, dec!(100)),
            },
            PriceEvent {
                date,
                price_x: SingleAmount::from_value(jpy, dec!(1)),
                price_y: SingleAmount::from_value(okane, dec!(1) / dec!(100)),
            },
        ];
        assert_eq!(want_prices, price_repos.to_events());
    }

    #[test]
    fn add_transaction_balances_with_price() {
        let arena = Bump::new();
        let mut ctx = ReportContext::new(&arena);
        let mut bal = Balance::default();
        let input = indoc! {"
            2024/08/01 Sample
              Account 1             12 OKANE @@ (12 * 100 JPY)  ; Payee: Posting 1
              Account 2         -1,200 JPY  ; Payee: Posting 2
        "};
        let txn = parse_transaction(input);
        let mut price_repos = PriceRepositoryBuilder::default();
        let okane = ctx.commodities.ensure("OKANE");
        let jpy = ctx.commodities.ensure("JPY");

        let got =
            add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");

        let want = Transaction {
            date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
            postings: bcc::Vec::from_iter_in(
                [
                    Posting {
                        account: ctx.accounts.ensure("Account 1"),
                        payee: "Posting 1",
                        amount: Amount::from_value(okane, dec!(12)),
                        converted_amount: Some(SingleAmount::from_value(jpy, dec!(1200))),
                    },
                    Posting {
                        account: ctx.accounts.ensure("Account 2"),
                        payee: "Posting 2",
                        amount: Amount::from_value(jpy, dec!(-1200)),
                        converted_amount: None,
                    },
                ],
                &arena,
            )
            .into_boxed_slice(),
        };
        assert_eq!(want, got);
    }

    #[test]
    fn add_transaction_balances_with_lot_and_price() {
        let arena = Bump::new();
        let mut ctx = ReportContext::new(&arena);
        let mut bal = Balance::default();
        let input = indoc! {"
            2024/08/01 Sample
              Account 1            -12 OKANE {100 JPY} @ 120 JPY  ; Payee: Posting 1
              Account 2          1,440 JPY  ; Payee: Posting 2
              Income              -240 JPY  ; Payee: Posting 3
        "};
        let date = NaiveDate::from_ymd_opt(2024, 8, 1).unwrap();
        let txn = parse_transaction(input);
        let mut price_repos = PriceRepositoryBuilder::default();

        let got =
            add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");

        let okane = ctx.commodities.resolve("OKANE").unwrap();
        let jpy = ctx.commodities.resolve("JPY").unwrap();
        let want = Transaction {
            date,
            postings: bcc::Vec::from_iter_in(
                [
                    Posting {
                        account: ctx.accounts.ensure("Account 1"),
                        payee: "Posting 1",
                        amount: Amount::from_value(okane, dec!(-12)),
                        converted_amount: Some(SingleAmount::from_value(jpy, dec!(-1440))),
                    },
                    Posting {
                        account: ctx.accounts.ensure("Account 2"),
                        payee: "Posting 2",
                        amount: Amount::from_value(jpy, dec!(1440)),
                        converted_amount: None,
                    },
                    Posting {
                        account: ctx.accounts.ensure("Income"),
                        payee: "Posting 3",
                        amount: Amount::from_value(jpy, dec!(-240)),
                        converted_amount: None,
                    },
                ],
                &arena,
            )
            .into_boxed_slice(),
        };
        assert_eq!(want, got);

        let want_prices = vec![
            PriceEvent {
                date,
                price_x: SingleAmount::from_value(okane, dec!(1)),
                price_y: SingleAmount::from_value(jpy, dec!(120)),
            },
            PriceEvent {
                date,
                price_x: SingleAmount::from_value(jpy, dec!(1)),
                price_y: SingleAmount::from_value(okane, dec!(1) / dec!(120)),
            },
        ];
        assert_eq!(want_prices, price_repos.to_events());
    }

    #[test]
    fn add_transaction_deduces_price_info() {
        let arena = Bump::new();
        let mut ctx = ReportContext::new(&arena);
        let mut bal = Balance::default();
        let input = indoc! {"
            2024/08/01 Sample
              Account 1            -12 OKANE  ; Payee: Posting 1
              Account 2          1,000 JPY  ; Payee: Posting 2
              Account 3            440 JPY  ; Payee: Posting 3
        "};
        let date = NaiveDate::from_ymd_opt(2024, 8, 1).unwrap();
        let txn = parse_transaction(input);
        let mut price_repos = PriceRepositoryBuilder::default();

        let got =
            add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");

        let okane = ctx.commodities.resolve("OKANE").unwrap();
        let jpy = ctx.commodities.resolve("JPY").unwrap();
        let want = Transaction {
            date,
            postings: bcc::Vec::from_iter_in(
                [
                    Posting {
                        account: ctx.accounts.ensure("Account 1"),
                        payee: "Posting 1",
                        amount: Amount::from_value(okane, dec!(-12)),
                        converted_amount: Some(SingleAmount::from_value(jpy, dec!(-1440))),
                    },
                    Posting {
                        account: ctx.accounts.ensure("Account 2"),
                        payee: "Posting 2",
                        amount: Amount::from_value(jpy, dec!(1000)),
                        converted_amount: Some(SingleAmount::from_value(
                            okane,
                            dec!(8.333333333333333333333333300),
                        )),
                    },
                    Posting {
                        account: ctx.accounts.ensure("Account 3"),
                        payee: "Posting 3",
                        amount: Amount::from_value(jpy, dec!(440)),
                        converted_amount: Some(SingleAmount::from_value(
                            okane,
                            dec!(3.6666666666666666666666666520),
                        )),
                    },
                ],
                &arena,
            )
            .into_boxed_slice(),
        };
        assert_eq!(want, got);

        let want_prices = vec![
            PriceEvent {
                date,
                price_x: SingleAmount::from_value(okane, dec!(1)),
                price_y: SingleAmount::from_value(jpy, dec!(120)),
            },
            PriceEvent {
                date,
                price_x: SingleAmount::from_value(jpy, dec!(1)),
                price_y: SingleAmount::from_value(okane, dec!(1) / dec!(120)),
            },
        ];
        assert_eq!(want_prices, price_repos.to_events());
    }

    #[test]
    fn add_transaction_balances_minor_diff() {
        let arena = Bump::new();
        let mut ctx = ReportContext::new(&arena);
        let chf = ctx.commodities.insert("CHF").unwrap();
        ctx.commodities
            .set_format(chf, "20,000.00".parse().unwrap());
        let mut bal = Balance::default();
        let input = indoc! {"
            2020/08/08 Petrol Station
              Expenses:Travel:Petrol       30.33 EUR @ 1.0902 CHF
              Expenses:Commissions          1.50 CHF  ; Payee: Bank
              Expenses:Commissions          0.06 EUR @ 1.0902 CHF  ; Payee: Bank
              Expenses:Commissions          0.07 CHF  ; Payee: Bank
              Assets:Banks                -34.70 CHF
        "};
        let date = NaiveDate::from_ymd_opt(2020, 8, 8).unwrap();
        let txn = parse_transaction(input);
        let mut price_repos = PriceRepositoryBuilder::default();

        let got =
            add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");
        let eur = ctx.commodities.resolve("EUR").unwrap();
        let want = Transaction {
            date,
            postings: bcc::Vec::from_iter_in(
                [
                    Posting {
                        account: ctx.accounts.ensure("Expenses:Travel:Petrol"),
                        payee: "Petrol Station",
                        amount: Amount::from_value(eur, dec!(30.33)),
                        converted_amount: Some(SingleAmount::from_value(chf, dec!(33.065766))),
                    },
                    Posting {
                        account: ctx.accounts.ensure("Expenses:Commissions"),
                        payee: "Bank",
                        amount: Amount::from_value(chf, dec!(1.50)),
                        converted_amount: None,
                    },
                    Posting {
                        account: ctx.accounts.ensure("Expenses:Commissions"),
                        payee: "Bank",
                        amount: Amount::from_value(eur, dec!(0.06)),
                        converted_amount: Some(SingleAmount::from_value(chf, dec!(0.065412))),
                    },
                    Posting {
                        account: ctx.accounts.ensure("Expenses:Commissions"),
                        payee: "Bank",
                        amount: Amount::from_value(chf, dec!(0.07)),
                        converted_amount: None,
                    },
                    Posting {
                        account: ctx.accounts.ensure("Assets:Banks"),
                        payee: "Petrol Station",
                        amount: Amount::from_value(chf, dec!(-34.70)),
                        converted_amount: None,
                    },
                ],
                &arena,
            )
            .into_boxed_slice(),
        };
        assert_eq!(want, got);

        let want_prices = vec![
            PriceEvent {
                date,
                price_x: SingleAmount::from_value(chf, dec!(1)),
                price_y: SingleAmount::from_value(eur, Decimal::ONE / dec!(1.0902)),
            },
            PriceEvent {
                date,
                price_x: SingleAmount::from_value(chf, dec!(1)),
                price_y: SingleAmount::from_value(eur, Decimal::ONE / dec!(1.0902)),
            },
            PriceEvent {
                date,
                price_x: SingleAmount::from_value(eur, dec!(1)),
                price_y: SingleAmount::from_value(chf, dec!(1.0902)),
            },
            PriceEvent {
                date,
                price_x: SingleAmount::from_value(eur, dec!(1)),
                price_y: SingleAmount::from_value(chf, dec!(1.0902)),
            },
        ];
        assert_eq!(want_prices, price_repos.to_events());
    }
}
