//! Valuation plugin - track opaque fund values using synthetic commodities.
//!
//! This plugin allows specifying total investment account value over time and
//! creates an underlying fictional commodity whose price is set to match the
//! total value of the account.
//!
//! All incoming and outgoing transactions are converted into transactions
//! buying and selling this commodity at a calculated price.
//!
//! Usage:
//! ```beancount
//! plugin "beancount_lazy_plugins.valuation"
//!
//! 1970-01-01 open Assets:Fund:Total "FIFO"
//! 1970-01-01 open Income:Fund:PnL
//!
//! 1970-01-01 custom "valuation" "config"
//!     account: "Assets:Fund:Total"
//!     currency: "FUND_USD"
//!     pnlAccount: "Income:Fund:PnL"
//!
//! ; Assert total value
//! 2024-01-05 custom "valuation" Assets:Fund:Total 2345 USD
//! ```

use std::collections::{HashMap, HashSet};

use rust_decimal::Decimal;

use crate::types::{
    AmountData, CommodityData, CostData, DirectiveData, DirectiveWrapper, MetaValueData,
    PluginError, PluginErrorSeverity, PluginInput, PluginOp, PluginOutput, PostingData,
    PriceAnnotationData, PriceData, TransactionData,
};

use super::super::{NativePlugin, RegularPlugin};

const MAPPED_CURRENCY_PRECISION: u32 = 7;
const TAG_TO_ADD: &str = "valuation-applied";
const EPSILON: Decimal = Decimal::from_parts(1, 0, 0, false, 9); // 1e-9

/// Plugin for tracking opaque fund values.
pub struct ValuationPlugin;

/// Account mapping configuration.
#[derive(Clone, Debug)]
struct AccountConfig {
    account: String,
    currency: String,
    pnl_account: String,
}

/// A cost lot for FIFO tracking.
///
/// # Why this is NOT `rustledger_core::Inventory` (Phase-1 sweep Z3)
///
/// This plugin deliberately keeps a private lot list instead of reusing the
/// core inventory's booking-method reduction, because the two operations
/// are different in kind, not just in code:
///
/// - **Core `Inventory::reduce` is unit-denominated**: a reduction consumes
///   N units, matched against lots per booking method. This plugin's sells
///   are **value-denominated**: [`process_fifo_sell`] consumes lots against
///   a currency amount (`lot.units × current_price` vs the remaining value),
///   which core has no API for — porting would mean re-deriving exactly the
///   loop below on top of core types.
/// - The plugin applies its own **rounding policy** (`round_down` on sells,
///   `round_up` on buys, at `MAPPED_CURRENCY_PRECISION`) and computes `PnL`
///   against `last_price`; core reduction is exact and PnL-agnostic
///   (capital gains live in the booking engine).
/// - The plugin operates in the **DTO domain** (`PostingData`, string
///   numbers) on the plugin wire, not on core `Position`s.
///
/// Revisit only if core ever grows a value-denominated reduction — then
/// this loop is the candidate call site. For unit-denominated needs, use
/// `rustledger_core::Inventory`; do not extend this struct.
#[derive(Clone, Debug)]
struct CostLot {
    units: Decimal,
    cost_per_unit: Decimal,
    date: String,
}

/// State for a mapped account.
#[derive(Clone, Debug)]
struct AccountState {
    config: AccountConfig,
    lots: Vec<CostLot>,
    last_price: Decimal,
    total_units: Decimal,
}

impl AccountState {
    const fn new(config: AccountConfig) -> Self {
        Self {
            config,
            lots: Vec::new(),
            last_price: Decimal::ONE,
            total_units: Decimal::ZERO,
        }
    }
}

impl NativePlugin for ValuationPlugin {
    fn name(&self) -> &'static str {
        "valuation"
    }

    fn description(&self) -> &'static str {
        "Track opaque fund values using synthetic commodities"
    }

    fn process(&self, input: PluginInput) -> PluginOutput {
        let mut errors: Vec<PluginError> = Vec::new();
        let mut ops: Vec<PluginOp> = Vec::with_capacity(input.directives.len());

        // Track state per account
        let mut account_states: HashMap<String, AccountState> = HashMap::new();

        // Track which commodities already exist
        let mut commodities_present: HashSet<String> = HashSet::new();

        // Track last date for commodity directive generation
        let mut last_date: Option<String> = None;

        // First pass: collect configs and existing commodities
        for directive in &input.directives {
            match &directive.data {
                DirectiveData::Custom(custom) => {
                    if custom.custom_type == "valuation"
                        && !custom.values.is_empty()
                        && matches!(custom.values.first(), Some(MetaValueData::String(s)) if s == "config")
                        && let Some(config) = parse_config(&custom.metadata)
                    {
                        account_states.insert(config.account.clone(), AccountState::new(config));
                    }
                }
                DirectiveData::Commodity(commodity) => {
                    commodities_present.insert(commodity.currency.clone());
                }
                _ => {}
            }
        }

        // Second pass: process directives in order
        for (i, directive) in input.directives.into_iter().enumerate() {
            last_date = Some(directive.date.clone());

            match &directive.data {
                DirectiveData::Transaction(txn) => {
                    // Check if any posting is on a mapped account
                    let has_mapped_posting = txn
                        .postings
                        .iter()
                        .any(|p| account_states.contains_key(&p.account));

                    if !has_mapped_posting {
                        ops.push(PluginOp::Keep(i));
                        continue;
                    }

                    // Transform the transaction
                    let (transformed, new_directives, new_errors) = transform_transaction(
                        &directive,
                        txn,
                        &mut account_states,
                        &mut commodities_present,
                    );

                    // Add any price directives generated as Inserts.
                    for new_d in new_directives {
                        ops.push(PluginOp::Insert(new_d));
                    }
                    errors.extend(new_errors);
                    ops.push(PluginOp::Modify(i, transformed));
                }
                DirectiveData::Custom(custom)
                    if custom.custom_type == "valuation" && !custom.values.is_empty() =>
                {
                    // Check if this is a config (pass through) or a valuation assertion
                    if matches!(custom.values.first(), Some(MetaValueData::String(s)) if s == "config")
                    {
                        ops.push(PluginOp::Keep(i));
                        continue;
                    }

                    // This is a valuation assertion — replace it with the
                    // synthesized directives (Delete + Inserts).
                    let (new_directives, new_errors) =
                        process_valuation_assertion(&directive, custom, &mut account_states);

                    ops.push(PluginOp::Delete(i));
                    for new_d in new_directives {
                        ops.push(PluginOp::Insert(new_d));
                    }
                    errors.extend(new_errors);
                }
                DirectiveData::Custom(_) => {
                    ops.push(PluginOp::Keep(i));
                }
                DirectiveData::Commodity(commodity) => {
                    commodities_present.insert(commodity.currency.clone());
                    ops.push(PluginOp::Keep(i));
                }
                _ => {
                    ops.push(PluginOp::Keep(i));
                }
            }
        }

        // Generate commodity directives for synthetic currencies that don't exist
        // Use the last transaction date, not 1970-01-01
        if let Some(date) = last_date {
            for state in account_states.values() {
                if !commodities_present.contains(&state.config.currency) {
                    ops.push(PluginOp::Insert(DirectiveWrapper {
                        directive_type: "commodity".to_string(),
                        date: date.clone(),
                        filename: Some("<valuation>".to_string()),
                        lineno: Some(0),
                        data: DirectiveData::Commodity(CommodityData {
                            currency: state.config.currency.clone(),
                            metadata: vec![],
                        }),
                    }));
                    // Only add once
                    commodities_present.insert(state.config.currency.clone());
                }
            }
        }

        PluginOutput { ops, errors }
    }
}

impl RegularPlugin for ValuationPlugin {}

/// Parse config metadata into `AccountConfig`.
fn parse_config(metadata: &[(String, MetaValueData)]) -> Option<AccountConfig> {
    let account = get_meta_string(metadata, "account")?;
    let currency = get_meta_string(metadata, "currency")?;
    let pnl_account = get_meta_string(metadata, "pnlAccount")?;
    Some(AccountConfig {
        account,
        currency,
        pnl_account,
    })
}

/// Get a string value from metadata.
fn get_meta_string(metadata: &[(String, MetaValueData)], key: &str) -> Option<String> {
    for (k, v) in metadata {
        if k == key {
            match v {
                MetaValueData::String(s) => return Some(s.clone()),
                MetaValueData::Account(a) => return Some(a.clone()),
                _ => {}
            }
        }
    }
    None
}

/// Transform a transaction that has postings on mapped accounts.
fn transform_transaction(
    directive: &DirectiveWrapper,
    txn: &TransactionData,
    account_states: &mut HashMap<String, AccountState>,
    _commodities_present: &mut HashSet<String>,
) -> (DirectiveWrapper, Vec<DirectiveWrapper>, Vec<PluginError>) {
    let mut new_directives: Vec<DirectiveWrapper> = Vec::new();
    let errors: Vec<PluginError> = Vec::new();
    let mut new_postings: Vec<PostingData> = Vec::new();

    for posting in &txn.postings {
        if let Some(state) = account_states.get_mut(&posting.account) {
            // This is a mapped account posting
            let Some(ref units) = posting.units else {
                new_postings.push(posting.clone());
                continue;
            };

            let Ok(units_number) = units.number.parse::<Decimal>() else {
                new_postings.push(posting.clone());
                continue;
            };

            // Check for @@ total price annotation
            if let Some(ref price_annot) = posting.price
                && price_annot.is_total
            {
                // Handle @@ price annotation - generates 3 postings
                let (postings, price_directive) = handle_total_price_posting(
                    posting,
                    units_number,
                    &units.currency,
                    price_annot,
                    state,
                    &directive.date,
                    directive,
                );
                if let Some(pd) = price_directive {
                    new_directives.push(pd);
                }
                new_postings.extend(postings);
                continue;
            }

            // Generate initial price directive if this is the first transaction
            if state.lots.is_empty() && state.total_units == Decimal::ZERO {
                new_directives.push(DirectiveWrapper {
                    directive_type: "price".to_string(),
                    date: directive.date.clone(),
                    filename: directive.filename.clone(),
                    lineno: directive.lineno,
                    data: DirectiveData::Price(PriceData {
                        currency: state.config.currency.clone(),
                        amount: AmountData {
                            number: format_decimal(state.last_price),
                            currency: units.currency.clone(),
                        },
                        metadata: vec![],
                    }),
                });
            }

            if units_number > Decimal::ZERO {
                // INFLOW: Convert to synthetic currency
                let synthetic_units =
                    round_up(units_number / state.last_price, MAPPED_CURRENCY_PRECISION);

                // Add to lots
                state.lots.push(CostLot {
                    units: synthetic_units,
                    cost_per_unit: state.last_price,
                    date: directive.date.clone(),
                });
                state.total_units += synthetic_units;

                // Create posting with cost basis
                new_postings.push(PostingData {
                    account: posting.account.clone(),
                    units: Some(AmountData {
                        number: format_decimal_fixed(synthetic_units, MAPPED_CURRENCY_PRECISION),
                        currency: state.config.currency.clone(),
                    }),
                    cost: Some(CostData {
                        number: Some(rustledger_plugin_types::CostNumberData::PerUnit {
                            value: format_decimal(state.last_price),
                        }),
                        currency: Some(units.currency.clone()),
                        date: Some(directive.date.clone()),
                        label: None,
                        merge: false,
                    }),
                    price: None,
                    flag: posting.flag.clone(),
                    metadata: posting.metadata.clone(),
                    span: None,
                });
            } else {
                // OUTFLOW: FIFO sell from lots
                let amount_to_sell = -units_number;
                let (sell_postings, total_pnl) = process_fifo_sell(
                    state,
                    amount_to_sell,
                    &posting.account,
                    &units.currency,
                    &posting.flag,
                    &posting.metadata,
                );

                // Add PnL posting first (negative PnL = gain)
                if total_pnl != Decimal::ZERO {
                    new_postings.push(PostingData {
                        account: state.config.pnl_account.clone(),
                        units: Some(AmountData {
                            number: format_decimal(-total_pnl),
                            currency: units.currency.clone(),
                        }),
                        cost: None,
                        price: None,
                        flag: None,
                        metadata: vec![],
                        span: None,
                    });
                }

                // Add the sell postings
                new_postings.extend(sell_postings);
            }
        } else {
            // Not a mapped account, pass through
            new_postings.push(posting.clone());
        }
    }

    // Create modified transaction with tag
    let mut new_tags = txn.tags.clone();
    if !new_tags.contains(&TAG_TO_ADD.to_string()) {
        new_tags.push(TAG_TO_ADD.to_string());
    }

    let transformed = DirectiveWrapper {
        directive_type: "transaction".to_string(),
        date: directive.date.clone(),
        filename: directive.filename.clone(),
        lineno: directive.lineno,
        data: DirectiveData::Transaction(TransactionData {
            flag: txn.flag.clone(),
            payee: txn.payee.clone(),
            narration: txn.narration.clone(),
            tags: new_tags,
            links: txn.links.clone(),
            metadata: txn.metadata.clone(),
            postings: new_postings,
        }),
    };

    (transformed, new_directives, errors)
}

/// Handle posting with @@ total price annotation.
/// Returns the new postings and optionally a price directive.
fn handle_total_price_posting(
    posting: &PostingData,
    units_number: Decimal,
    units_currency: &str,
    price_annot: &PriceAnnotationData,
    state: &mut AccountState,
    date: &str,
    _directive: &DirectiveWrapper,
) -> (Vec<PostingData>, Option<DirectiveWrapper>) {
    let mut postings = Vec::new();

    // Get the total price amount
    let Some(ref price_amount) = price_annot.amount else {
        return (vec![posting.clone()], None);
    };

    let Ok(total_price) = price_amount.number.parse::<Decimal>() else {
        return (vec![posting.clone()], None);
    };

    // Calculate per-unit price
    let per_unit_price = total_price / units_number;

    // 1. Original posting with @ per_unit price
    postings.push(PostingData {
        account: posting.account.clone(),
        units: Some(AmountData {
            number: format_decimal(units_number),
            currency: units_currency.to_string(),
        }),
        cost: None,
        price: Some(PriceAnnotationData {
            is_total: false,
            amount: Some(AmountData {
                number: format_decimal(per_unit_price),
                currency: price_amount.currency.clone(),
            }),
            number: None,
            currency: None,
        }),
        flag: posting.flag.clone(),
        metadata: posting.metadata.clone(),
        span: None,
    });

    // 2. Reversal posting
    postings.push(PostingData {
        account: posting.account.clone(),
        units: Some(AmountData {
            number: format_decimal(-units_number),
            currency: units_currency.to_string(),
        }),
        cost: None,
        price: None,
        flag: None,
        metadata: vec![],
        span: None,
    });

    // 3. Synthetic currency posting
    let synthetic_units = round_up(units_number / state.last_price, MAPPED_CURRENCY_PRECISION);

    // Add to lots
    state.lots.push(CostLot {
        units: synthetic_units,
        cost_per_unit: state.last_price,
        date: date.to_string(),
    });
    state.total_units += synthetic_units;

    postings.push(PostingData {
        account: posting.account.clone(),
        units: Some(AmountData {
            number: format_decimal_fixed(synthetic_units, MAPPED_CURRENCY_PRECISION),
            currency: state.config.currency.clone(),
        }),
        cost: Some(CostData {
            number: Some(rustledger_plugin_types::CostNumberData::PerUnit {
                value: format_decimal(state.last_price),
            }),
            currency: Some(units_currency.to_string()),
            date: Some(date.to_string()),
            label: None,
            merge: false,
        }),
        price: None,
        flag: None,
        metadata: vec![],
        span: None,
    });

    (postings, None)
}

/// Process FIFO sell and return postings and total `PnL`.
fn process_fifo_sell(
    state: &mut AccountState,
    amount_to_sell: Decimal,
    account: &str,
    currency: &str,
    flag: &Option<String>,
    metadata: &[(String, MetaValueData)],
) -> (Vec<PostingData>, Decimal) {
    let mut postings = Vec::new();
    let mut remaining = amount_to_sell;
    let mut total_pnl = Decimal::ZERO;
    let current_price = state.last_price;

    while remaining > EPSILON && !state.lots.is_empty() {
        let lot = &mut state.lots[0];
        let lot_value_at_current_price = lot.units * current_price;

        if lot_value_at_current_price <= remaining + EPSILON {
            // Sell entire lot
            let units_to_sell = lot.units;
            let pnl = (current_price - lot.cost_per_unit) * units_to_sell;
            total_pnl += pnl;

            // Round down for sells
            let rounded_units = round_down(units_to_sell, MAPPED_CURRENCY_PRECISION);

            postings.push(PostingData {
                account: account.to_string(),
                units: Some(AmountData {
                    number: format_decimal_fixed(-rounded_units, MAPPED_CURRENCY_PRECISION),
                    currency: state.config.currency.clone(),
                }),
                cost: Some(CostData {
                    number: Some(rustledger_plugin_types::CostNumberData::PerUnit {
                        value: format_decimal(lot.cost_per_unit),
                    }),
                    currency: Some(currency.to_string()),
                    date: Some(lot.date.clone()),
                    label: None,
                    merge: false,
                }),
                price: Some(PriceAnnotationData {
                    is_total: false,
                    amount: Some(AmountData {
                        number: format_decimal(current_price),
                        currency: currency.to_string(),
                    }),
                    number: None,
                    currency: None,
                }),
                flag: flag.clone(),
                metadata: if postings.is_empty() {
                    metadata.to_vec()
                } else {
                    vec![]
                },
                span: None,
            });

            // Same reasoning as the partial branch: the posting reported
            // `rounded_units`, so that is what leaves the running total. With
            // the partial branch fixed, `lot.units` is already at
            // `MAPPED_CURRENCY_PRECISION` and this is a no-op — but it keeps
            // the two branches saying the same thing, and it is still correct
            // for a lot carrying more precision than the posting can express.
            state.total_units -= rounded_units;
            remaining -= lot_value_at_current_price;
            state.lots.remove(0);
        } else {
            // Partial sell from this lot
            let units_to_sell = remaining / current_price;
            let pnl = (current_price - lot.cost_per_unit) * units_to_sell;
            total_pnl += pnl;

            let rounded_units = round_down(units_to_sell, MAPPED_CURRENCY_PRECISION);

            postings.push(PostingData {
                account: account.to_string(),
                units: Some(AmountData {
                    number: format_decimal_fixed(-rounded_units, MAPPED_CURRENCY_PRECISION),
                    currency: state.config.currency.clone(),
                }),
                cost: Some(CostData {
                    number: Some(rustledger_plugin_types::CostNumberData::PerUnit {
                        value: format_decimal(lot.cost_per_unit),
                    }),
                    currency: Some(currency.to_string()),
                    date: Some(lot.date.clone()),
                    label: None,
                    merge: false,
                }),
                price: Some(PriceAnnotationData {
                    is_total: false,
                    amount: Some(AmountData {
                        number: format_decimal(current_price),
                        currency: currency.to_string(),
                    }),
                    number: None,
                    currency: None,
                }),
                flag: flag.clone(),
                metadata: if postings.is_empty() {
                    metadata.to_vec()
                } else {
                    vec![]
                },
                span: None,
            });

            // Decrement by what was POSTED, not by the unrounded quotient.
            //
            // The posting above reports `rounded_units`; decrementing the lot
            // by the full-precision `units_to_sell` lets the plugin's idea of
            // the lot drift below the balance the ledger actually shows. The
            // drift is at most 1e-7 per partial sell and is invisible until a
            // later full sell posts `lot.units` — by then already short — and
            // leaves a residual position that should not exist (#2018).
            //
            // The buy path has always done it this way: it rounds once and
            // uses that single value for both the lot and the posting.
            lot.units -= rounded_units;
            state.total_units -= rounded_units;
            remaining = Decimal::ZERO;
        }
    }

    (postings, total_pnl)
}

/// Process a valuation assertion custom directive.
fn process_valuation_assertion(
    directive: &DirectiveWrapper,
    custom: &crate::types::CustomData,
    account_states: &mut HashMap<String, AccountState>,
) -> (Vec<DirectiveWrapper>, Vec<PluginError>) {
    let mut new_directives: Vec<DirectiveWrapper> = Vec::new();
    let mut errors: Vec<PluginError> = Vec::new();

    // Parse the valuation: custom "valuation" Account Amount
    if custom.values.len() < 2 {
        new_directives.push(directive.clone());
        return (new_directives, errors);
    }

    let account = match &custom.values[0] {
        MetaValueData::Account(a) => a.clone(),
        MetaValueData::String(s) => s.clone(),
        _ => {
            new_directives.push(directive.clone());
            return (new_directives, errors);
        }
    };

    let Some(state) = account_states.get_mut(&account) else {
        errors.push(PluginError {
            message: format!("No valuation config for account {account}"),
            source_file: directive.filename.clone(),
            line_number: directive.lineno,
            severity: PluginErrorSeverity::Error,
        });
        new_directives.push(directive.clone());
        return (new_directives, errors);
    };

    let Some((valuation_amount, valuation_currency)) = parse_valuation_amount(&custom.values[1])
    else {
        new_directives.push(directive.clone());
        return (new_directives, errors);
    };

    // Get current balance in synthetic units
    let last_balance = state.total_units;

    if last_balance.abs() < EPSILON {
        errors.push(PluginError {
            message: format!("Valuation called on empty account {account}"),
            source_file: directive.filename.clone(),
            line_number: directive.lineno,
            severity: PluginErrorSeverity::Error,
        });
        new_directives.push(directive.clone());
        return (new_directives, errors);
    }

    // Calculate new price
    let calculated_price = valuation_amount / last_balance;
    state.last_price = calculated_price;

    // Create metadata for lastBalance and calculatedPrice
    let mut new_metadata = custom.metadata.clone();
    new_metadata.push((
        "lastBalance".to_string(),
        MetaValueData::Number(format_decimal(last_balance)),
    ));
    new_metadata.push((
        "calculatedPrice".to_string(),
        MetaValueData::Number(format_decimal(calculated_price)),
    ));

    // Add modified custom directive
    new_directives.push(DirectiveWrapper {
        directive_type: "custom".to_string(),
        date: directive.date.clone(),
        filename: directive.filename.clone(),
        lineno: directive.lineno,
        data: DirectiveData::Custom(crate::types::CustomData {
            custom_type: custom.custom_type.clone(),
            values: custom.values.clone(),
            metadata: new_metadata.clone(),
        }),
    });

    // Add price directive with same metadata
    new_directives.push(DirectiveWrapper {
        directive_type: "price".to_string(),
        date: directive.date.clone(),
        filename: directive.filename.clone(),
        lineno: directive.lineno,
        data: DirectiveData::Price(PriceData {
            currency: state.config.currency.clone(),
            amount: AmountData {
                number: format_decimal(calculated_price),
                currency: valuation_currency,
            },
            metadata: vec![
                (
                    "lastBalance".to_string(),
                    MetaValueData::Number(format_decimal(last_balance)),
                ),
                (
                    "calculatedPrice".to_string(),
                    MetaValueData::Number(format_decimal(calculated_price)),
                ),
            ],
        }),
    });

    (new_directives, errors)
}

/// Parse a valuation amount from a `MetaValueData`.
fn parse_valuation_amount(value: &MetaValueData) -> Option<(Decimal, String)> {
    match value {
        MetaValueData::Amount(amount) => amount
            .number
            .parse::<Decimal>()
            .ok()
            .map(|n| (n, amount.currency.clone())),
        _ => None,
    }
}

/// Round up with given precision.
/// Python `decimal`'s `ROUND_UP`: away from zero, NOT toward +infinity.
///
/// The upstream plugin rounds a cash INFLOW with `ROUND_UP` and an OUTFLOW
/// with `ROUND_DOWN` (`valuation/__init__.py`), so the fund never credits
/// more units than the cash bought. `ceil`/`floor` agree with Python for
/// positive values and are exactly INVERTED for negative ones, which is the
/// outflow path — so an outflow of `-6000/11` rounded to `-545.4545455`
/// here against beancount's `-545.4545454`.
///
/// That one digit is the whole of the "1E-7 residue" this fixture is known
/// for: beancount's three legs sum to `0.0000001` because of its own
/// truncation, and rledger's summed to zero. It is not a `rust_decimal`
/// precision limit — these are ten-digit values.
fn round_up(value: Decimal, decimals: u32) -> Decimal {
    let scale = Decimal::new(1, decimals);
    let scaled = value / scale;
    // Away from zero.
    let rounded = if value.is_sign_negative() {
        scaled.floor()
    } else {
        scaled.ceil()
    };
    rounded * scale
}

/// Round down with given precision.
/// Python `decimal`'s `ROUND_DOWN`: toward zero — see [`round_up`].
fn round_down(value: Decimal, decimals: u32) -> Decimal {
    let scale = Decimal::new(1, decimals);
    (value / scale).trunc() * scale
}

/// Format a decimal number, stripping trailing zeros.
fn format_decimal(d: Decimal) -> String {
    let s = d.to_string();
    if s.contains('.') {
        s.trim_end_matches('0').trim_end_matches('.').to_string()
    } else {
        s
    }
}

/// Format a decimal with fixed precision (for synthetic amounts).
/// Render at EXACTLY `decimals` places, matching Python's
/// `round(Decimal, decimals)`.
///
/// This used to `trim_end_matches('0')`, which does the opposite of what its
/// own comment claimed ("keep at least 7 decimal places") — it stripped every
/// trailing zero, so an inflow of `1000` emitted `1000` where beancount emits
/// `1000.0000000`. The values were equal; the scale was not, and the scale is
/// what the position and its cost render at.
fn format_decimal_fixed(d: Decimal, decimals: u32) -> String {
    format!("{:.1$}", d.round_dp(decimals), decimals as usize)
}

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

    #[test]
    fn test_valuation_config_parsing() {
        let metadata = vec![
            (
                "account".to_string(),
                MetaValueData::String("Assets:Fund".to_string()),
            ),
            (
                "currency".to_string(),
                MetaValueData::String("FUND_USD".to_string()),
            ),
            (
                "pnlAccount".to_string(),
                MetaValueData::String("Income:Fund:PnL".to_string()),
            ),
        ];

        let config = parse_config(&metadata);
        assert!(config.is_some());
        let config = config.unwrap();
        assert_eq!(config.account, "Assets:Fund");
        assert_eq!(config.currency, "FUND_USD");
        assert_eq!(config.pnl_account, "Income:Fund:PnL");
    }

    /// A partial sell must move the lot by exactly what it posted, so a later
    /// full sell closes the lot to zero instead of leaving a phantom residual.
    ///
    /// Regression for #2018. `units_to_sell` is `remaining / price`, which can
    /// carry more precision than `MAPPED_CURRENCY_PRECISION`; the posting is
    /// rounded but the lot used to be decremented by the unrounded quotient.
    /// The gap is at most 1e-7 per partial sell and is invisible until the
    /// close-out posts a lot balance that is already short.
    ///
    /// The numbers mirror `tests_data_some_fund_example.beancount`: 500 units
    /// bought at 1, then sold at a price that does not divide evenly, then the
    /// remainder sold. Before the fix the two postings summed to -500.0000000
    /// against a 500 lot only because the lot had silently shrunk; beancount's
    /// plugin posts -374.9999999 and -125.0000001.
    #[test]
    fn partial_sell_leaves_no_phantom_residual() {
        let mut state = AccountState::new(AccountConfig {
            account: "Assets:Fund:Total".to_string(),
            currency: "FUND_USD".to_string(),
            pnl_account: "Income:Fund:PnL".to_string(),
        });
        state.lots.push(CostLot {
            units: Decimal::from(500),
            cost_per_unit: Decimal::ONE,
            date: "2024-01-10".to_string(),
        });
        state.total_units = Decimal::from(500);
        // 1200/1125 — the price that produces a non-terminating quotient.
        state.last_price = Decimal::from(1200) / Decimal::from(1125);

        let posted = |ps: &[PostingData]| -> Decimal {
            ps.iter()
                .filter(|p| p.account == "Assets:Fund:Total")
                .filter_map(|p| p.units.as_ref())
                .map(|a| a.number.parse::<Decimal>().expect("posted units parse"))
                .sum()
        };

        let (first, _) = process_fifo_sell(
            &mut state,
            Decimal::from(400),
            "Assets:Fund:Total",
            "USD",
            &None,
            &[],
        );
        let sold_first = posted(&first);

        // Sell everything that remains, at whatever price.
        let remaining_value = state.total_units * state.last_price;
        let (second, _) = process_fifo_sell(
            &mut state,
            remaining_value,
            "Assets:Fund:Total",
            "USD",
            &None,
            &[],
        );
        let sold_second = posted(&second);

        assert_eq!(
            sold_first + sold_second,
            Decimal::from(-500),
            "the two sells must dispose of exactly the 500 units bought; a \
             shortfall here is the #2018 residual"
        );
        assert!(
            state.lots.is_empty(),
            "the lot should be gone, found {:?}",
            state.lots
        );
        assert_eq!(
            state.total_units,
            Decimal::ZERO,
            "running total must close to zero, not to a 1e-7 remainder"
        );
    }

    /// `format_decimal_fixed` renders EXACTLY `decimals` places.
    ///
    /// It used to `trim_end_matches('0')` — the opposite of its own comment —
    /// so an inflow of `1000` emitted `1000` where beancount emits
    /// `1000.0000000`. Asserts the full string, not a prefix or a length:
    /// the failure mode was a SHORTER rendering of an equal value, which a
    /// `starts_with` or a parse-and-compare would both accept.
    #[test]
    fn format_decimal_fixed_pads_to_exactly_the_requested_places() {
        assert_eq!(
            format_decimal_fixed(Decimal::new(1000, 0), 7),
            "1000.0000000"
        );
        assert_eq!(format_decimal_fixed(Decimal::new(0, 0), 7), "0.0000000");
        // Already at 7dp: unchanged.
        assert_eq!(
            format_decimal_fixed(Decimal::new(5_454_545_454, 7), 7),
            "545.4545454"
        );
        // More precision than asked for: rounded to exactly 7.
        assert_eq!(
            format_decimal_fixed(Decimal::new(54_545_454_549, 8), 7),
            "545.4545455"
        );
        // Negative keeps its sign and its padding.
        assert_eq!(
            format_decimal_fixed(Decimal::new(-1000, 0), 7),
            "-1000.0000000"
        );
    }

    #[test]
    fn test_round_up() {
        // `ROUND_UP` is AWAY FROM ZERO, not toward +infinity. The two agree on
        // positives, which is all this test covered while the helper used
        // `ceil` — so the direction could be wrong for negatives with the
        // suite green. Reference values from CPython's `decimal`.
        let value = Decimal::new(12_345_678, 8); // 0.12345678
        assert_eq!(round_up(value, 7), Decimal::new(1_234_568, 7));
        assert!(round_up(value, 7) >= value, "positives grow");

        let negative = Decimal::new(-12_345_678, 8); // -0.12345678
        assert_eq!(
            round_up(negative, 7),
            Decimal::new(-1_234_568, 7),
            "away from zero, so a negative gets MORE negative",
        );
        // Deliberately not `>= negative`: that assertion encodes the
        // toward-+infinity reading this helper is not.
        assert!(round_up(negative, 7).abs() >= negative.abs());
    }

    #[test]
    fn test_round_down() {
        // `ROUND_DOWN` is TOWARD ZERO — see `test_round_up`. This is the
        // direction the sell path takes, and the one that made the
        // `cool_fund` close-out differ from beancount in the last digit.
        let value = Decimal::new(12_345_678, 8); // 0.12345678
        assert_eq!(round_down(value, 7), Decimal::new(1_234_567, 7));
        assert!(round_down(value, 7) <= value, "positives shrink");

        let negative = Decimal::new(-12_345_678, 8); // -0.12345678
        assert_eq!(
            round_down(negative, 7),
            Decimal::new(-1_234_567, 7),
            "toward zero, so a negative gets LESS negative",
        );
        assert!(round_down(negative, 7).abs() <= negative.abs());
    }

    #[test]
    fn test_fifo_lot_tracking() {
        let config = AccountConfig {
            account: "Assets:Fund".to_string(),
            currency: "FUND_USD".to_string(),
            pnl_account: "Income:PnL".to_string(),
        };

        let mut state = AccountState::new(config);

        // Add first lot at price 1.0
        state.lots.push(CostLot {
            units: Decimal::new(1000, 0),
            cost_per_unit: Decimal::ONE,
            date: "2024-01-10".to_string(),
        });
        state.total_units = Decimal::new(1000, 0);

        // Update price to 0.8
        state.last_price = Decimal::new(8, 1);

        // Add second lot at price 0.8
        let second_units = Decimal::new(500, 0) / state.last_price; // 625
        state.lots.push(CostLot {
            units: second_units,
            cost_per_unit: state.last_price,
            date: "2024-01-13".to_string(),
        });
        state.total_units += second_units;

        assert_eq!(state.lots.len(), 2);
        assert_eq!(state.lots[0].cost_per_unit, Decimal::ONE);
        assert_eq!(state.lots[1].cost_per_unit, Decimal::new(8, 1));
    }

    #[test]
    fn test_format_decimal() {
        assert_eq!(format_decimal(Decimal::new(12345, 4)), "1.2345");
        assert_eq!(format_decimal(Decimal::new(10000, 4)), "1");
        assert_eq!(format_decimal(Decimal::new(12300, 4)), "1.23");
    }

    #[test]
    fn test_format_decimal_fixed() {
        let d = Decimal::new(1000, 0); // 1000
        let formatted = format_decimal_fixed(d, 7);
        assert!(formatted.starts_with("1000."));
    }
}
