//! Directive types representing all beancount directives.
//!
//! Beancount has 12 directive types that can appear in a ledger file:
//!
//! - [`Transaction`] - The most common directive, recording transfers between accounts
//! - [`Balance`] - Assert that an account has a specific balance
//! - [`Open`] - Open an account for use
//! - [`Close`] - Close an account
//! - [`Commodity`] - Declare a commodity/currency
//! - [`Pad`] - Automatically pad an account to match a balance assertion
//! - [`Event`] - Record a life event
//! - [`Query`] - Store a named BQL query
//! - [`Note`] - Add a note to an account
//! - [`Document`] - Link a document to an account
//! - [`Price`] - Record a price for a commodity
//! - [`Custom`] - Custom directive type

use crate::NaiveDate;
use rust_decimal::Decimal;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use std::fmt;

use crate::intern::InternedStr;
#[cfg(feature = "rkyv")]
use crate::intern::{AsDecimal, AsInternedStr, AsNaiveDate, AsOptionInternedStr};
use crate::{Amount, CostSpec, IncompleteAmount};

/// Metadata value types.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
    feature = "rkyv",
    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub enum MetaValue {
    /// String value
    String(String),
    /// Account reference
    Account(crate::Account),
    /// Currency code
    Currency(crate::Currency),
    /// Tag reference
    Tag(crate::Tag),
    /// Link reference
    Link(crate::Link),
    /// Date value
    Date(#[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))] NaiveDate),
    /// Numeric value
    Number(#[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))] Decimal),
    /// Boolean value
    Bool(bool),
    /// Amount value
    Amount(Amount),
    /// Null/None value
    None,
    /// Integer value (e.g. beancount `key: 42`, distinct from `42.0`).
    ///
    /// MUST remain the LAST variant: rkyv encodes enum discriminants by
    /// declaration order, so appending keeps the existing variants' discriminants
    /// stable. `CACHE_VERSION` (rustledger-loader) was bumped when this was added.
    Int(i64),
}

impl fmt::Display for MetaValue {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::String(s) => write!(f, "\"{}\"", crate::format::escape_string(s)),
            Self::Account(a) => write!(f, "{a}"),
            Self::Currency(c) => write!(f, "{c}"),
            Self::Tag(t) => write!(f, "#{t}"),
            Self::Link(l) => write!(f, "^{l}"),
            Self::Date(d) => write!(f, "{d}"),
            Self::Number(n) => write!(f, "{n}"),
            Self::Bool(b) => write!(f, "{b}"),
            Self::Amount(a) => write!(f, "{a}"),
            Self::None => write!(f, "None"),
            Self::Int(i) => write!(f, "{i}"),
        }
    }
}

/// Metadata is a key-value map attached to directives and postings.
pub type Metadata = FxHashMap<String, MetaValue>;

/// The spellings of a boolean accepted in ledger source.
///
/// Stated ONCE because a boolean can arrive two ways — `option "x" "TRUE"` and
/// `x: TRUE` metadata — and they are the same concept to a ledger author. Two
/// separate vocabularies would mean `render_commas: YES` grouping numerals
/// while `option "render_commas" "YES"` warns and does nothing.
///
/// `None` means "not recognizably boolean": the option parser turns that into
/// an E7002 warning, and metadata leaves the default in place.
#[must_use]
pub fn parse_bool_word(word: &str) -> Option<bool> {
    if word.eq_ignore_ascii_case("true") || word == "1" {
        Some(true)
    } else if word.eq_ignore_ascii_case("false") || word == "0" {
        Some(false)
    } else {
        None
    }
}

/// Read a boolean-valued metadata entry.
///
/// `TRUE` is an uppercase bare word, so depending on context the parser may
/// classify it as a boolean, a currency, or a string rather than one canonical
/// variant. Accepting all three keeps `render_commas: TRUE` working however it
/// lexes, instead of silently ignoring the declaration — the failure mode that
/// makes a display option look broken. The VALUE vocabulary is
/// [`parse_bool_word`]'s, shared with the option parser.
#[must_use]
pub fn meta_value_as_bool(value: &MetaValue) -> Option<bool> {
    match value {
        MetaValue::Bool(b) => Some(*b),
        MetaValue::String(s) => parse_bool_word(s),
        MetaValue::Currency(c) => parse_bool_word(c),
        _ => None,
    }
}

/// Try to interpret a [`MetaValue`] as a non-negative integer ≤ `u32::MAX`.
///
/// Used by the `precision` metadata feature on `commodity` directives (issue
/// #991). Shared between the loader (which silently skips invalid values and
/// falls back to inferred precision) and the validator (which surfaces the
/// problem as an `InvalidPrecisionMetadata` warning), so both paths agree on
/// what counts as valid.
///
/// # Errors
///
/// Returns a human-readable explanation when the value is not a number,
/// is negative, has a fractional part, or is out of `u32` range.
#[must_use = "ignoring the result silently drops invalid `precision:` metadata; the loader expects to skip invalid values, the validator expects to surface them"]
pub fn parse_precision_meta(value: &MetaValue) -> Result<u32, String> {
    use rust_decimal::prelude::ToPrimitive;
    match value {
        // `precision: 2` now parses as an integer metadata value.
        MetaValue::Int(i) => u32::try_from(*i).map_err(|_| {
            if *i < 0 {
                format!("expected a non-negative integer, got {i}")
            } else {
                format!(
                    "value {i} exceeds the maximum supported precision ({})",
                    u32::MAX
                )
            }
        }),
        // `precision: 2.0` (an explicit decimal) is still accepted if integral.
        MetaValue::Number(n) => {
            if n.is_sign_negative() {
                return Err(format!("expected a non-negative integer, got {n}"));
            }
            if !n.fract().is_zero() {
                return Err(format!("expected an integer, got {n}"));
            }
            n.to_u32().ok_or_else(|| {
                format!(
                    "value {n} exceeds the maximum supported precision ({})",
                    u32::MAX
                )
            })
        }
        _ => Err(format!(
            "expected a non-negative integer, got {} value",
            meta_value_kind(value)
        )),
    }
}

const fn meta_value_kind(v: &MetaValue) -> &'static str {
    match v {
        MetaValue::String(_) => "string",
        MetaValue::Account(_) => "account",
        MetaValue::Currency(_) => "currency",
        MetaValue::Tag(_) => "tag",
        MetaValue::Link(_) => "link",
        MetaValue::Date(_) => "date",
        MetaValue::Number(_) => "number",
        MetaValue::Bool(_) => "bool",
        MetaValue::Amount(_) => "amount",
        MetaValue::None => "none",
        MetaValue::Int(_) => "int",
    }
}

/// A posting within a transaction.
///
/// Postings represent the individual legs of a transaction. Each posting
/// specifies an account and optionally an amount, cost, and price.
///
/// When the units are `None`, the entire amount will be inferred by the
/// interpolation algorithm to balance the transaction. When units is
/// `Some(IncompleteAmount)`, it may still have missing components that
/// need to be filled in.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
    feature = "rkyv",
    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Posting {
    /// The account for this posting
    pub account: crate::Account,
    /// The units (may be incomplete or None for auto-calculated postings)
    pub units: Option<IncompleteAmount>,
    /// Cost specification for the position.
    ///
    /// Boxed because it is absent on the overwhelming majority of postings
    /// and `CostSpec` is the largest field by far. Inline, `Posting` was 280
    /// bytes and a plain two-posting expense carried ~190 bytes of `None`.
    /// rkyv mirrors the in-memory layout, so that padding was also written to
    /// and validated from the parse cache — the archive ran 5.2x the size of
    /// the ledger it came from. Use `as_deref()` to read through it.
    pub cost: Option<Box<CostSpec>>,
    /// Price annotation (`@` or `@@`). Boxed for the same reason as
    /// [`Self::cost`].
    pub price: Option<Box<PriceAnnotation>>,
    /// Whether this posting has the "!" flag
    pub flag: Option<char>,
    /// Posting metadata
    pub meta: Metadata,
    /// Comments that appear before this posting (one per line)
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub comments: Vec<String>,
    /// Trailing comment(s) on the same line as the posting
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub trailing_comments: Vec<String>,
}

impl Posting {
    /// Create a new posting with the given account and complete units.
    #[must_use]
    pub fn new(account: impl Into<crate::Account>, units: Amount) -> Self {
        Self {
            account: account.into(),
            units: Some(IncompleteAmount::Complete(units)),
            cost: None,
            price: None,
            flag: None,
            meta: Metadata::default(),
            comments: Vec::new(),
            trailing_comments: Vec::new(),
        }
    }

    /// Create a new posting with an incomplete amount.
    #[must_use]
    pub fn with_incomplete(account: impl Into<crate::Account>, units: IncompleteAmount) -> Self {
        Self {
            account: account.into(),
            units: Some(units),
            cost: None,
            price: None,
            flag: None,
            meta: Metadata::default(),
            comments: Vec::new(),
            trailing_comments: Vec::new(),
        }
    }

    /// Create a posting without any amount (to be fully interpolated).
    #[must_use]
    pub fn auto(account: impl Into<crate::Account>) -> Self {
        Self {
            account: account.into(),
            units: None,
            cost: None,
            price: None,
            flag: None,
            meta: Metadata::default(),
            comments: Vec::new(),
            trailing_comments: Vec::new(),
        }
    }

    /// Get the complete amount if available.
    #[must_use]
    pub fn amount(&self) -> Option<&Amount> {
        self.units.as_ref().and_then(|u| u.as_amount())
    }

    /// Add a cost specification.
    ///
    /// Consuming builder — takes `self` by value and returns `Posting`.
    /// To apply this to a `Spanned<Posting>` while preserving its source
    /// location, use [`crate::Spanned::map`]:
    ///
    /// ```no_run
    /// # use rustledger_core::{Posting, Amount, CostSpec, CostNumber, Spanned};
    /// # use rust_decimal_macros::dec;
    /// # let cost = CostSpec { number: Some(CostNumber::PerUnit { value: dec!(150) }),
    /// #     currency: Some("USD".into()), date: None, label: None, merge: false };
    /// let spanned: Spanned<Posting> = Spanned::synthesized(
    ///     Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
    /// );
    /// let with_cost: Spanned<Posting> = spanned.map(|p| p.with_cost(cost));
    /// ```
    #[must_use]
    pub fn with_cost(mut self, cost: CostSpec) -> Self {
        self.cost = Some(Box::new(cost));
        self
    }

    /// Add a price annotation.
    ///
    /// See [`Self::with_cost`] for the `Spanned<Posting>` pattern.
    #[must_use]
    pub fn with_price(mut self, price: PriceAnnotation) -> Self {
        self.price = Some(Box::new(price));
        self
    }

    /// Add a flag.
    ///
    /// See [`Self::with_cost`] for the `Spanned<Posting>` pattern.
    #[must_use]
    pub const fn with_flag(mut self, flag: char) -> Self {
        self.flag = Some(flag);
        self
    }

    /// Check if this posting has an amount.
    #[must_use]
    pub const fn has_units(&self) -> bool {
        self.units.is_some()
    }
}

impl fmt::Display for Posting {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "  ")?;
        if let Some(flag) = self.flag {
            write!(f, "{flag} ")?;
        }
        write!(f, "{}", self.account)?;
        if let Some(units) = &self.units {
            write!(f, "  {units}")?;
        }
        if let Some(cost) = &self.cost {
            write!(f, " {cost}")?;
        }
        if let Some(price) = &self.price {
            write!(f, " {price}")?;
        }
        // Posting-level metadata
        for (key, value) in &self.meta {
            write!(f, "\n    {key}: {value}")?;
        }
        Ok(())
    }
}

/// Whether a price annotation is per-unit (`@`) or total (`@@`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
    feature = "rkyv",
    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub enum PriceKind {
    /// Per-unit price (`@`).
    Unit,
    /// Total price for the posting (`@@`).
    Total,
}

impl fmt::Display for PriceKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(match self {
            Self::Unit => "@",
            Self::Total => "@@",
        })
    }
}

/// Price annotation for a posting (`@` or `@@`).
///
/// Factored along its two orthogonal axes (#1167):
/// - `kind`: per-unit (`@`) vs total (`@@`)
/// - `amount`: present (possibly with missing number or currency that
///   interpolation will fill) vs absent (the bare `@`/`@@` sigil
///   without any amount)
///
/// Pre-#1167 these axes were flattened into a 6-variant enum
/// (`Unit/Total/UnitIncomplete/TotalIncomplete/UnitEmpty/TotalEmpty`),
/// which forced every consumer to write six match arms even when only
/// one axis mattered.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
    feature = "rkyv",
    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct PriceAnnotation {
    /// Per-unit (`@`) or total (`@@`).
    pub kind: PriceKind,
    /// The price amount, or `None` for the bare-sigil form.
    /// `IncompleteAmount::Complete` indicates the parser saw a full
    /// number+currency; the other variants indicate one of those
    /// pieces was elided and will be filled by interpolation.
    pub amount: Option<IncompleteAmount>,
}

impl PriceAnnotation {
    /// Per-unit (`@`) price with a complete amount.
    #[must_use]
    pub const fn unit(amount: Amount) -> Self {
        Self {
            kind: PriceKind::Unit,
            amount: Some(IncompleteAmount::Complete(amount)),
        }
    }

    /// Total (`@@`) price with a complete amount.
    #[must_use]
    pub const fn total(amount: Amount) -> Self {
        Self {
            kind: PriceKind::Total,
            amount: Some(IncompleteAmount::Complete(amount)),
        }
    }

    /// Per-unit (`@`) price with an incomplete amount.
    #[must_use]
    pub const fn unit_incomplete(amount: IncompleteAmount) -> Self {
        Self {
            kind: PriceKind::Unit,
            amount: Some(amount),
        }
    }

    /// Total (`@@`) price with an incomplete amount.
    #[must_use]
    pub const fn total_incomplete(amount: IncompleteAmount) -> Self {
        Self {
            kind: PriceKind::Total,
            amount: Some(amount),
        }
    }

    /// Bare per-unit sigil (`@`) with no amount.
    #[must_use]
    pub const fn unit_empty() -> Self {
        Self {
            kind: PriceKind::Unit,
            amount: None,
        }
    }

    /// Bare total sigil (`@@`) with no amount.
    #[must_use]
    pub const fn total_empty() -> Self {
        Self {
            kind: PriceKind::Total,
            amount: None,
        }
    }

    /// Get the complete amount if available.
    #[must_use]
    pub fn amount(&self) -> Option<&Amount> {
        self.amount.as_ref().and_then(IncompleteAmount::as_amount)
    }

    /// Check if this is a per-unit price (`@` vs `@@`).
    #[must_use]
    pub const fn is_unit(&self) -> bool {
        matches!(self.kind, PriceKind::Unit)
    }
}

impl fmt::Display for PriceAnnotation {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match &self.amount {
            Some(amt) => write!(f, "{} {amt}", self.kind),
            None => write!(f, "{}", self.kind),
        }
    }
}

/// Directive ordering priority for sorting.
///
/// When directives have the same date, they are sorted by type priority
/// to ensure proper processing order.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum DirectivePriority {
    /// Open accounts first so they exist before use
    Open = 0,
    /// Commodities declared before use
    Commodity = 1,
    /// Padding before balance assertions
    Pad = 2,
    /// Balance assertions checked at start of day
    Balance = 3,
    /// Main entries
    Transaction = 4,
    /// Annotations after transactions
    Note = 5,
    /// Attachments after transactions
    Document = 6,
    /// State changes
    Event = 7,
    /// Queries defined after data
    Query = 8,
    /// Prices at end of day
    Price = 9,
    /// Accounts closed after all activity
    Close = 10,
    /// User extensions last
    Custom = 11,
}

/// All directive types in beancount.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
    feature = "rkyv",
    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub enum Directive {
    /// Transaction directive - records transfers between accounts
    Transaction(Transaction),
    /// Balance assertion - asserts an account balance at a point in time
    Balance(Balance),
    /// Open account - opens an account for use
    Open(Open),
    /// Close account - closes an account
    Close(Close),
    /// Commodity declaration - declares a currency/commodity
    Commodity(Commodity),
    /// Pad directive - auto-pad an account to match a balance
    Pad(Pad),
    /// Event directive - records a life event
    Event(Event),
    /// Query directive - stores a named BQL query
    Query(Query),
    /// Note directive - adds a note to an account
    Note(Note),
    /// Document directive - links a document to an account
    Document(Document),
    /// Price directive - records a commodity price
    Price(Price),
    /// Custom directive - custom user-defined directive
    Custom(Custom),
}

impl Directive {
    /// Get the date of this directive.
    #[must_use]
    pub const fn date(&self) -> NaiveDate {
        match self {
            Self::Transaction(t) => t.date,
            Self::Balance(b) => b.date,
            Self::Open(o) => o.date,
            Self::Close(c) => c.date,
            Self::Commodity(c) => c.date,
            Self::Pad(p) => p.date,
            Self::Event(e) => e.date,
            Self::Query(q) => q.date,
            Self::Note(n) => n.date,
            Self::Document(d) => d.date,
            Self::Price(p) => p.date,
            Self::Custom(c) => c.date,
        }
    }

    /// Get the metadata of this directive.
    #[must_use]
    pub const fn meta(&self) -> &Metadata {
        match self {
            Self::Transaction(t) => &t.meta,
            Self::Balance(b) => &b.meta,
            Self::Open(o) => &o.meta,
            Self::Close(c) => &c.meta,
            Self::Commodity(c) => &c.meta,
            Self::Pad(p) => &p.meta,
            Self::Event(e) => &e.meta,
            Self::Query(q) => &q.meta,
            Self::Note(n) => &n.meta,
            Self::Document(d) => &d.meta,
            Self::Price(p) => &p.meta,
            Self::Custom(c) => &c.meta,
        }
    }

    /// Check if this is a transaction.
    #[must_use]
    pub const fn is_transaction(&self) -> bool {
        matches!(self, Self::Transaction(_))
    }

    /// Get as a transaction, if this is one.
    #[must_use]
    pub const fn as_transaction(&self) -> Option<&Transaction> {
        match self {
            Self::Transaction(t) => Some(t),
            _ => None,
        }
    }

    /// Get the directive type name.
    #[must_use]
    pub const fn type_name(&self) -> &'static str {
        match self {
            Self::Transaction(_) => "transaction",
            Self::Balance(_) => "balance",
            Self::Open(_) => "open",
            Self::Close(_) => "close",
            Self::Commodity(_) => "commodity",
            Self::Pad(_) => "pad",
            Self::Event(_) => "event",
            Self::Query(_) => "query",
            Self::Note(_) => "note",
            Self::Document(_) => "document",
            Self::Price(_) => "price",
            Self::Custom(_) => "custom",
        }
    }

    /// Get the sorting priority for this directive.
    ///
    /// Used to determine order when directives have the same date.
    #[must_use]
    pub const fn priority(&self) -> DirectivePriority {
        match self {
            Self::Open(_) => DirectivePriority::Open,
            Self::Commodity(_) => DirectivePriority::Commodity,
            Self::Pad(_) => DirectivePriority::Pad,
            Self::Balance(_) => DirectivePriority::Balance,
            Self::Transaction(_) => DirectivePriority::Transaction,
            Self::Note(_) => DirectivePriority::Note,
            Self::Document(_) => DirectivePriority::Document,
            Self::Event(_) => DirectivePriority::Event,
            Self::Query(_) => DirectivePriority::Query,
            Self::Price(_) => DirectivePriority::Price,
            Self::Close(_) => DirectivePriority::Close,
            Self::Custom(_) => DirectivePriority::Custom,
        }
    }
}

/// Sort directives by date, then type priority.
///
/// This is a stable sort, so directives sharing a date and type keep their
/// file order — which is the order they are booked in, matching Python's
/// `(date, type_priority, lineno)`.
pub fn sort_directives(directives: &mut [Directive]) {
    directives.sort_by_cached_key(booking_sort_key);
}

/// The canonical booking-order sort key: `(date, priority)`.
///
/// Deliberately WITHOUT a reduction tiebreak. Same-date directives book in
/// file order (the sorts through this key are stable), which is what Python
/// does — its `entry_sortkey` is `(date, type_priority, lineno)` and has no
/// notion of augmentations going first.
///
/// This key used to carry a third component, `has_cost_reduction`, which
/// floated cost augmentations ahead of the reductions matching against them
/// so that lots would exist when matched (#841). That was a workaround for a
/// missing behavior rather than an ordering problem, and the behavior has
/// since landed: a cost-bearing posting is a REDUCTION only when the account
/// already holds the opposite sign in that currency
/// ([`crate::Inventory::is_booking_reduction`], #1560, mirroring Python's
/// `balance.is_reduced_by`). Otherwise it is an augmentation and simply opens
/// a negative lot for a later posting to close. #841's ledger passes on that
/// path alone, with no reordering — and reordering actively broke ledgers
/// Python accepts (#2093), because moving a same-date augmentation ahead of a
/// reduction changes which lots an ambiguous match can see.
///
/// This is the SINGLE source of the booking order. [`sort_directives`] sorts a
/// slice in place by it, and both `book()` (rustledger-booking) and
/// `run_booking()` (rustledger-loader) key an index permutation through it
/// (they preserve the input order for reassembly, so they cannot sort the slice
/// directly). Change a booking-order tiebreak here only.
#[must_use]
pub const fn booking_sort_key(d: &Directive) -> (NaiveDate, DirectivePriority) {
    (d.date(), d.priority())
}

/// A transaction directive.
///
/// Transactions are the most common directive type. They record transfers
/// between accounts and must balance (sum of all postings equals zero).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
    feature = "rkyv",
    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Transaction {
    /// Transaction date
    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
    pub date: NaiveDate,
    /// Transaction flag (* or !)
    pub flag: char,
    /// Payee (optional)
    #[cfg_attr(feature = "rkyv", rkyv(with = AsOptionInternedStr))]
    pub payee: Option<InternedStr>,
    /// Narration (description)
    #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
    pub narration: InternedStr,
    /// Tags attached to this transaction
    pub tags: Vec<crate::Tag>,
    /// Links attached to this transaction
    pub links: Vec<crate::Link>,
    /// Transaction metadata
    pub meta: Metadata,
    /// Postings (account entries), each wrapped with its source span and
    /// file ID. Parser-emitted postings carry the byte range of the
    /// posting line (from leading indent through trailing same-line
    /// comment, not including following metadata lines); programmatically
    /// constructed postings use [`crate::Spanned::synthesized`] which
    /// pairs [`crate::Span::ZERO`] with [`crate::SYNTHESIZED_FILE_ID`].
    pub postings: Vec<crate::Spanned<Posting>>,
    /// Comments that appear after all postings
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub trailing_comments: Vec<String>,
}

impl Transaction {
    /// Create a new transaction.
    #[must_use]
    pub fn new(date: NaiveDate, narration: impl Into<InternedStr>) -> Self {
        Self {
            date,
            flag: '*',
            payee: None,
            narration: narration.into(),
            tags: Vec::new(),
            links: Vec::new(),
            meta: Metadata::default(),
            postings: Vec::new(),
            trailing_comments: Vec::new(),
        }
    }

    /// Set the flag.
    #[must_use]
    pub const fn with_flag(mut self, flag: char) -> Self {
        self.flag = flag;
        self
    }

    /// Set the payee.
    #[must_use]
    pub fn with_payee(mut self, payee: impl Into<InternedStr>) -> Self {
        self.payee = Some(payee.into());
        self
    }

    /// Add a tag.
    #[must_use]
    pub fn with_tag(mut self, tag: impl Into<crate::Tag>) -> Self {
        self.tags.push(tag.into());
        self
    }

    /// Add a link.
    #[must_use]
    pub fn with_link(mut self, link: impl Into<crate::Link>) -> Self {
        self.links.push(link.into());
        self
    }

    /// Add a posting that already carries source location metadata.
    /// Use this when constructing transactions from source (e.g.
    /// a parser implementation) or when round-tripping through a
    /// plugin that preserves spans.
    #[must_use]
    pub fn with_posting(mut self, posting: crate::Spanned<Posting>) -> Self {
        self.postings.push(posting);
        self
    }

    /// Add a programmatically-built posting (no source representation),
    /// wrapping it with [`crate::Spanned::synthesized`]. Use this from
    /// test fixtures, CLI commands, and importers that build directives
    /// in memory rather than parsing them. The name is intentionally
    /// longer than [`Self::with_posting`] so that synthesis is visible
    /// at the call site — silently dropping a real span is the foot-gun
    /// this rename prevents.
    #[must_use]
    pub fn with_synthesized_posting(mut self, posting: Posting) -> Self {
        self.postings.push(crate::Spanned::synthesized(posting));
        self
    }

    /// Check if this transaction is marked as complete (*).
    #[must_use]
    pub const fn is_complete(&self) -> bool {
        self.flag == '*'
    }

    /// Check if this transaction is marked as incomplete/pending (!).
    #[must_use]
    pub const fn is_incomplete(&self) -> bool {
        self.flag == '!'
    }

    /// Check if this transaction is marked as pending (!).
    /// Alias for `is_incomplete`.
    #[must_use]
    pub const fn is_pending(&self) -> bool {
        self.flag == '!'
    }

    /// Check if this is a summarization transaction (S).
    #[must_use]
    pub const fn is_summarization(&self) -> bool {
        self.flag == 'S'
    }

    /// Check if this is a transfer transaction (T).
    #[must_use]
    pub const fn is_transfer(&self) -> bool {
        self.flag == 'T'
    }

    /// Check if this is a currency conversion transaction (C).
    #[must_use]
    pub const fn is_conversion(&self) -> bool {
        self.flag == 'C'
    }

    /// Check if this is an unrealized gains transaction (U).
    #[must_use]
    pub const fn is_unrealized(&self) -> bool {
        self.flag == 'U'
    }

    /// Check if this is a return/dividend transaction (R).
    #[must_use]
    pub const fn is_return(&self) -> bool {
        self.flag == 'R'
    }

    /// Check if this is a merge transaction (M).
    #[must_use]
    pub const fn is_merge(&self) -> bool {
        self.flag == 'M'
    }

    /// Check if this transaction is bookmarked (#).
    #[must_use]
    pub const fn is_bookmarked(&self) -> bool {
        self.flag == '#'
    }

    /// Check if this transaction needs investigation (?).
    #[must_use]
    pub const fn needs_investigation(&self) -> bool {
        self.flag == '?'
    }

    /// Check if the given character is a valid transaction flag.
    #[must_use]
    pub const fn is_valid_flag(flag: char) -> bool {
        matches!(
            flag,
            '*' | '!' | 'P' | 'S' | 'T' | 'C' | 'U' | 'R' | 'M' | '#' | '?' | '%' | '&'
        )
    }
}

impl fmt::Display for Transaction {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} {} ", self.date, self.flag)?;
        if let Some(payee) = &self.payee {
            write!(f, "\"{}\" ", crate::format::escape_string(payee))?;
        }
        write!(f, "\"{}\"", crate::format::escape_string(&self.narration))?;
        for tag in &self.tags {
            write!(f, " #{tag}")?;
        }
        for link in &self.links {
            write!(f, " ^{link}")?;
        }
        // Transaction-level metadata
        for (key, value) in &self.meta {
            write!(f, "\n  {key}: {value}")?;
        }
        for posting in &self.postings {
            write!(f, "\n{posting}")?;
        }
        Ok(())
    }
}

/// A balance assertion directive.
///
/// Asserts that an account has a specific balance at the beginning of a date.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
    feature = "rkyv",
    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Balance {
    /// Assertion date
    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
    pub date: NaiveDate,
    /// Account to check
    pub account: crate::Account,
    /// Expected amount
    pub amount: Amount,
    /// Tolerance (if explicitly specified)
    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsDecimal>))]
    pub tolerance: Option<Decimal>,
    /// Metadata
    pub meta: Metadata,
}

impl Balance {
    /// Create a new balance assertion.
    #[must_use]
    pub fn new(date: NaiveDate, account: impl Into<crate::Account>, amount: Amount) -> Self {
        Self {
            date,
            account: account.into(),
            amount,
            tolerance: None,
            meta: Metadata::default(),
        }
    }

    /// Set explicit tolerance.
    #[must_use]
    pub const fn with_tolerance(mut self, tolerance: Decimal) -> Self {
        self.tolerance = Some(tolerance);
        self
    }

    /// Set metadata.
    #[must_use]
    pub fn with_meta(mut self, meta: Metadata) -> Self {
        self.meta = meta;
        self
    }
}

impl fmt::Display for Balance {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} balance {} {}", self.date, self.account, self.amount)?;
        if let Some(tol) = self.tolerance {
            write!(f, " ~ {tol}")?;
        }
        Ok(())
    }
}

/// An open account directive.
///
/// Opens an account for use. Accounts must be opened before they can be used.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
    feature = "rkyv",
    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Open {
    /// Date account was opened
    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
    pub date: NaiveDate,
    /// Account name (e.g., "Assets:Bank:Checking")
    pub account: crate::Account,
    /// Allowed currencies (empty = any currency allowed)
    pub currencies: Vec<crate::Currency>,
    /// Booking method for this account
    pub booking: Option<String>,
    /// Metadata
    pub meta: Metadata,
}

impl Open {
    /// Create a new open directive.
    #[must_use]
    pub fn new(date: NaiveDate, account: impl Into<crate::Account>) -> Self {
        Self {
            date,
            account: account.into(),
            currencies: Vec::new(),
            booking: None,
            meta: Metadata::default(),
        }
    }

    /// Set allowed currencies.
    #[must_use]
    pub fn with_currencies(mut self, currencies: Vec<crate::Currency>) -> Self {
        self.currencies = currencies;
        self
    }

    /// Set booking method.
    #[must_use]
    pub fn with_booking(mut self, booking: impl Into<String>) -> Self {
        self.booking = Some(booking.into());
        self
    }

    /// Set metadata.
    #[must_use]
    pub fn with_meta(mut self, meta: Metadata) -> Self {
        self.meta = meta;
        self
    }
}

impl fmt::Display for Open {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} open {}", self.date, self.account)?;
        if !self.currencies.is_empty() {
            let currencies: Vec<&str> = self
                .currencies
                .iter()
                .map(crate::Currency::as_str)
                .collect();
            write!(f, " {}", currencies.join(","))?;
        }
        if let Some(booking) = &self.booking {
            write!(f, " \"{}\"", crate::format::escape_string(booking))?;
        }
        Ok(())
    }
}

/// A close account directive.
///
/// Closes an account. The account should have zero balance when closed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
    feature = "rkyv",
    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Close {
    /// Date account was closed
    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
    pub date: NaiveDate,
    /// Account name
    pub account: crate::Account,
    /// Metadata
    pub meta: Metadata,
}

impl Close {
    /// Create a new close directive.
    #[must_use]
    pub fn new(date: NaiveDate, account: impl Into<crate::Account>) -> Self {
        Self {
            date,
            account: account.into(),
            meta: Metadata::default(),
        }
    }

    /// Set metadata.
    #[must_use]
    pub fn with_meta(mut self, meta: Metadata) -> Self {
        self.meta = meta;
        self
    }
}

impl fmt::Display for Close {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} close {}", self.date, self.account)
    }
}

/// A commodity declaration directive.
///
/// Declares a commodity/currency that can be used in the ledger.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
    feature = "rkyv",
    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Commodity {
    /// Declaration date
    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
    pub date: NaiveDate,
    /// Currency/commodity code (e.g., "USD", "AAPL")
    pub currency: crate::Currency,
    /// Metadata
    pub meta: Metadata,
}

impl Commodity {
    /// Create a new commodity declaration.
    #[must_use]
    pub fn new(date: NaiveDate, currency: impl Into<crate::Currency>) -> Self {
        Self {
            date,
            currency: currency.into(),
            meta: Metadata::default(),
        }
    }

    /// Set metadata.
    #[must_use]
    pub fn with_meta(mut self, meta: Metadata) -> Self {
        self.meta = meta;
        self
    }
}

impl fmt::Display for Commodity {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} commodity {}", self.date, self.currency)
    }
}

/// A pad directive.
///
/// Automatically inserts a transaction to pad an account to match
/// a subsequent balance assertion.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
    feature = "rkyv",
    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Pad {
    /// Pad date
    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
    pub date: NaiveDate,
    /// Account to pad
    pub account: crate::Account,
    /// Source account for padding (e.g., Equity:Opening-Balances)
    pub source_account: crate::Account,
    /// Metadata
    pub meta: Metadata,
}

impl Pad {
    /// Create a new pad directive.
    #[must_use]
    pub fn new(
        date: NaiveDate,
        account: impl Into<crate::Account>,
        source_account: impl Into<crate::Account>,
    ) -> Self {
        Self {
            date,
            account: account.into(),
            source_account: source_account.into(),
            meta: Metadata::default(),
        }
    }

    /// Set metadata.
    #[must_use]
    pub fn with_meta(mut self, meta: Metadata) -> Self {
        self.meta = meta;
        self
    }
}

impl fmt::Display for Pad {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{} pad {} {}",
            self.date, self.account, self.source_account
        )
    }
}

/// An event directive.
///
/// Records a life event (e.g., location changes, employment changes).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
    feature = "rkyv",
    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Event {
    /// Event date
    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
    pub date: NaiveDate,
    /// Event type (e.g., "location", "employer")
    pub event_type: String,
    /// Event value
    pub value: String,
    /// Metadata
    pub meta: Metadata,
}

impl Event {
    /// Create a new event directive.
    #[must_use]
    pub fn new(date: NaiveDate, event_type: impl Into<String>, value: impl Into<String>) -> Self {
        Self {
            date,
            event_type: event_type.into(),
            value: value.into(),
            meta: Metadata::default(),
        }
    }

    /// Set metadata.
    #[must_use]
    pub fn with_meta(mut self, meta: Metadata) -> Self {
        self.meta = meta;
        self
    }
}

impl fmt::Display for Event {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{} event \"{}\" \"{}\"",
            self.date,
            crate::format::escape_string(&self.event_type),
            crate::format::escape_string(&self.value)
        )
    }
}

/// A query directive.
///
/// Stores a named BQL query that can be referenced later.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
    feature = "rkyv",
    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Query {
    /// Query date
    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
    pub date: NaiveDate,
    /// Query name
    pub name: String,
    /// BQL query string
    pub query: String,
    /// Metadata
    pub meta: Metadata,
}

impl Query {
    /// Create a new query directive.
    #[must_use]
    pub fn new(date: NaiveDate, name: impl Into<String>, query: impl Into<String>) -> Self {
        Self {
            date,
            name: name.into(),
            query: query.into(),
            meta: Metadata::default(),
        }
    }

    /// Set metadata.
    #[must_use]
    pub fn with_meta(mut self, meta: Metadata) -> Self {
        self.meta = meta;
        self
    }
}

impl fmt::Display for Query {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{} query \"{}\" \"{}\"",
            self.date,
            crate::format::escape_string(&self.name),
            crate::format::escape_string(&self.query)
        )
    }
}

/// A note directive.
///
/// Adds a note/comment to an account on a specific date.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
    feature = "rkyv",
    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Note {
    /// Note date
    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
    pub date: NaiveDate,
    /// Account
    pub account: crate::Account,
    /// Note text
    pub comment: String,
    /// Metadata
    pub meta: Metadata,
}

impl Note {
    /// Create a new note directive.
    #[must_use]
    pub fn new(
        date: NaiveDate,
        account: impl Into<crate::Account>,
        comment: impl Into<String>,
    ) -> Self {
        Self {
            date,
            account: account.into(),
            comment: comment.into(),
            meta: Metadata::default(),
        }
    }

    /// Set metadata.
    #[must_use]
    pub fn with_meta(mut self, meta: Metadata) -> Self {
        self.meta = meta;
        self
    }
}

impl fmt::Display for Note {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{} note {} \"{}\"",
            self.date,
            self.account,
            crate::format::escape_string(&self.comment)
        )
    }
}

/// A document directive.
///
/// Links an external document file to an account.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
    feature = "rkyv",
    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Document {
    /// Document date
    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
    pub date: NaiveDate,
    /// Account
    pub account: crate::Account,
    /// File path to the document
    pub path: String,
    /// Tags
    pub tags: Vec<crate::Tag>,
    /// Links
    pub links: Vec<crate::Link>,
    /// Metadata
    pub meta: Metadata,
}

impl Document {
    /// Create a new document directive.
    #[must_use]
    pub fn new(
        date: NaiveDate,
        account: impl Into<crate::Account>,
        path: impl Into<String>,
    ) -> Self {
        Self {
            date,
            account: account.into(),
            path: path.into(),
            tags: Vec::new(),
            links: Vec::new(),
            meta: Metadata::default(),
        }
    }

    /// Add a tag.
    #[must_use]
    pub fn with_tag(mut self, tag: impl Into<crate::Tag>) -> Self {
        self.tags.push(tag.into());
        self
    }

    /// Add a link.
    #[must_use]
    pub fn with_link(mut self, link: impl Into<crate::Link>) -> Self {
        self.links.push(link.into());
        self
    }

    /// Set metadata.
    #[must_use]
    pub fn with_meta(mut self, meta: Metadata) -> Self {
        self.meta = meta;
        self
    }
}

impl fmt::Display for Document {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{} document {} \"{}\"",
            self.date,
            self.account,
            crate::format::escape_string(&self.path)
        )
    }
}

/// A price directive.
///
/// Records the price of a commodity in another currency.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
    feature = "rkyv",
    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Price {
    /// Price date
    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
    pub date: NaiveDate,
    /// Currency being priced
    pub currency: crate::Currency,
    /// Price amount (in another currency)
    pub amount: Amount,
    /// Metadata
    pub meta: Metadata,
}

impl Price {
    /// Create a new price directive.
    #[must_use]
    pub fn new(date: NaiveDate, currency: impl Into<crate::Currency>, amount: Amount) -> Self {
        Self {
            date,
            currency: currency.into(),
            amount,
            meta: Metadata::default(),
        }
    }

    /// Set metadata.
    #[must_use]
    pub fn with_meta(mut self, meta: Metadata) -> Self {
        self.meta = meta;
        self
    }
}

impl fmt::Display for Price {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} price {} {}", self.date, self.currency, self.amount)
    }
}

/// A custom directive.
///
/// User-defined directive type for extensions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
    feature = "rkyv",
    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Custom {
    /// Custom directive date
    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
    pub date: NaiveDate,
    /// Custom type name (e.g., "budget", "autopay")
    pub custom_type: String,
    /// Values/arguments for this custom directive
    pub values: Vec<MetaValue>,
    /// Metadata
    pub meta: Metadata,
}

impl Custom {
    /// Create a new custom directive.
    #[must_use]
    pub fn new(date: NaiveDate, custom_type: impl Into<String>) -> Self {
        Self {
            date,
            custom_type: custom_type.into(),
            values: Vec::new(),
            meta: Metadata::default(),
        }
    }

    /// Add a value.
    #[must_use]
    pub fn with_value(mut self, value: MetaValue) -> Self {
        self.values.push(value);
        self
    }

    /// Set metadata.
    #[must_use]
    pub fn with_meta(mut self, meta: Metadata) -> Self {
        self.meta = meta;
        self
    }
}

impl fmt::Display for Custom {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{} custom \"{}\"",
            self.date,
            crate::format::escape_string(&self.custom_type)
        )?;
        for value in &self.values {
            write!(f, " {value}")?;
        }
        Ok(())
    }
}

impl fmt::Display for Directive {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Transaction(t) => write!(f, "{t}"),
            Self::Balance(b) => write!(f, "{b}"),
            Self::Open(o) => write!(f, "{o}"),
            Self::Close(c) => write!(f, "{c}"),
            Self::Commodity(c) => write!(f, "{c}"),
            Self::Pad(p) => write!(f, "{p}"),
            Self::Event(e) => write!(f, "{e}"),
            Self::Query(q) => write!(f, "{q}"),
            Self::Note(n) => write!(f, "{n}"),
            Self::Document(d) => write!(f, "{d}"),
            Self::Price(p) => write!(f, "{p}"),
            Self::Custom(c) => write!(f, "{c}"),
        }
    }
}

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

    /// One concept, one vocabulary: what `option "x" "V"` accepts is exactly
    /// what `x: V` metadata accepts.
    ///
    /// The loader's option parser calls [`parse_bool_word`] directly, so this
    /// pins the shared set rather than a copy of it. Before they were unified,
    /// metadata took `YES`/`T` while the option warned on them.
    #[test]
    fn options_and_metadata_accept_the_same_spellings() {
        for word in ["TRUE", "true", "True", "1"] {
            assert_eq!(parse_bool_word(word), Some(true), "{word}");
        }
        for word in ["FALSE", "false", "False", "0"] {
            assert_eq!(parse_bool_word(word), Some(false), "{word}");
        }
        for word in ["YES", "NO", "T", "F", "on", "", "2", "maybe"] {
            assert_eq!(
                parse_bool_word(word),
                None,
                "{word} is not accepted by `option`, so metadata must not take \
                 it either — the option parser warns (E7002) and metadata \
                 leaves the default"
            );
        }
    }

    /// An uppercase bare word's token classification depends on context, so
    /// the same declaration can arrive as three different `MetaValue`s.
    #[test]
    fn a_bare_word_is_read_however_it_lexed() {
        assert_eq!(meta_value_as_bool(&MetaValue::Bool(true)), Some(true));
        assert_eq!(
            meta_value_as_bool(&MetaValue::String("TRUE".into())),
            Some(true)
        );
        assert_eq!(
            meta_value_as_bool(&MetaValue::Currency("TRUE".into())),
            Some(true)
        );
        assert_eq!(
            meta_value_as_bool(&MetaValue::Currency("FALSE".into())),
            Some(false)
        );
        assert_eq!(meta_value_as_bool(&MetaValue::Currency("USD".into())), None);
    }
}

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

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

    /// Layout/distinctness snapshot for [`MetaValue`] — the rkyv companion to
    /// `CostNumber`'s `cost_number_archived_bytes_snapshot`.
    ///
    /// The cache archives the whole `Directive` graph, including metadata, but the
    /// only frozen-byte tripwire pinned `CostNumber`. A `MetaValue` variant
    /// reorder or discriminant collision changes the on-disk bytes while
    /// `CostNumber` stays identical, so a stale cache would deserialize old bytes
    /// into the new layout. This pins that every variant archives non-empty and
    /// PAIRWISE-DISTINCT — including same-payload pairs like `Number(42)`/`Int(42)`
    /// and `String("USD")`/`Currency("USD")` whose only difference is the
    /// discriminant. A collision or reorder trips here; the exact frozen bytes
    /// that also catch a *uniform* encoding shift live in `rustledger-loader::cache`.
    #[cfg(feature = "rkyv")]
    #[test]
    fn meta_value_archived_bytes_snapshot() {
        let archive = |mv: &MetaValue| rkyv::to_bytes::<rkyv::rancor::Error>(mv).unwrap().to_vec();

        // Same-payload pairs are deliberate so pairwise distinctness exercises the
        // discriminant, not the payload.
        let cases: &[(&str, MetaValue)] = &[
            ("String", MetaValue::String("USD".to_string())),
            (
                "Account",
                MetaValue::Account(crate::Account::from("Assets:Bank")),
            ),
            (
                "Currency",
                MetaValue::Currency(crate::Currency::from("USD")),
            ),
            ("Tag", MetaValue::Tag(crate::Tag::from("t"))),
            ("Link", MetaValue::Link(crate::Link::from("t"))),
            ("Date", MetaValue::Date(date(2024, 1, 15))),
            ("Number", MetaValue::Number(dec!(42))),
            ("Bool", MetaValue::Bool(true)),
            ("Amount", MetaValue::Amount(Amount::new(dec!(10), "USD"))),
            ("None", MetaValue::None),
            ("Int", MetaValue::Int(42)),
        ];

        let archived: Vec<(&str, Vec<u8>)> =
            cases.iter().map(|(n, mv)| (*n, archive(mv))).collect();

        for (name, bytes) in &archived {
            assert!(
                !bytes.is_empty(),
                "MetaValue::{name} archived to empty bytes"
            );
        }
        for (i, (na, a)) in archived.iter().enumerate() {
            for (nb, b) in archived.iter().skip(i + 1) {
                assert_ne!(
                    a, b,
                    "MetaValue::{na} and MetaValue::{nb} archive identically — a \
                     discriminant collision (variant reorder?) the cache can't tell apart"
                );
            }
        }
    }

    #[test]
    fn test_transaction() {
        let txn = Transaction::new(date(2024, 1, 15), "Grocery shopping")
            .with_payee("Whole Foods")
            .with_flag('*')
            .with_tag("food")
            .with_synthesized_posting(Posting::new(
                "Expenses:Food",
                Amount::new(dec!(50.00), "USD"),
            ))
            .with_synthesized_posting(Posting::auto("Assets:Checking"));

        assert_eq!(txn.flag, '*');
        assert_eq!(txn.payee.as_deref(), Some("Whole Foods"));
        assert_eq!(txn.postings.len(), 2);
        assert!(txn.is_complete());
    }

    #[test]
    fn test_balance() {
        let bal = Balance::new(
            date(2024, 1, 1),
            "Assets:Checking",
            Amount::new(dec!(1000.00), "USD"),
        );

        assert_eq!(bal.account, "Assets:Checking");
        assert_eq!(bal.amount.number, dec!(1000.00));
    }

    #[test]
    fn test_open() {
        let open = Open::new(date(2024, 1, 1), "Assets:Bank:Checking")
            .with_currencies(vec!["USD".into()])
            .with_booking("FIFO");

        assert_eq!(open.currencies, vec![InternedStr::from("USD")]);
        assert_eq!(open.booking, Some("FIFO".to_string()));
    }

    #[test]
    fn test_directive_date() {
        let txn = Transaction::new(date(2024, 1, 15), "Test");
        let dir = Directive::Transaction(txn);

        assert_eq!(dir.date(), date(2024, 1, 15));
        assert!(dir.is_transaction());
        assert_eq!(dir.type_name(), "transaction");
    }

    #[test]
    fn test_posting_display() {
        let posting = Posting::new("Assets:Checking", Amount::new(dec!(100.00), "USD"));
        let s = format!("{posting}");
        assert!(s.contains("Assets:Checking"));
        assert!(s.contains("100.00 USD"));
    }

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

        let s = format!("{txn}");
        assert!(s.contains("2024-01-15"));
        assert!(s.contains("Test Payee"));
        assert!(s.contains("Test transaction"));
    }

    #[test]
    fn test_directive_priority() {
        // Test that priorities are ordered correctly
        assert!(DirectivePriority::Open < DirectivePriority::Transaction);
        assert!(DirectivePriority::Pad < DirectivePriority::Balance);
        assert!(DirectivePriority::Balance < DirectivePriority::Transaction);
        assert!(DirectivePriority::Transaction < DirectivePriority::Close);
        assert!(DirectivePriority::Price < DirectivePriority::Close);
    }

    #[test]
    fn test_sort_directives_by_date() {
        let mut directives = vec![
            Directive::Transaction(Transaction::new(date(2024, 1, 15), "Third")),
            Directive::Transaction(Transaction::new(date(2024, 1, 1), "First")),
            Directive::Transaction(Transaction::new(date(2024, 1, 10), "Second")),
        ];

        sort_directives(&mut directives);

        assert_eq!(directives[0].date(), date(2024, 1, 1));
        assert_eq!(directives[1].date(), date(2024, 1, 10));
        assert_eq!(directives[2].date(), date(2024, 1, 15));
    }

    #[test]
    fn test_sort_directives_by_type_same_date() {
        // On the same date, open should come before transaction, transaction before close
        let mut directives = vec![
            Directive::Close(Close::new(date(2024, 1, 1), "Assets:Bank")),
            Directive::Transaction(Transaction::new(date(2024, 1, 1), "Payment")),
            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
            Directive::Balance(Balance::new(
                date(2024, 1, 1),
                "Assets:Bank",
                Amount::new(dec!(0), "USD"),
            )),
        ];

        sort_directives(&mut directives);

        assert_eq!(directives[0].type_name(), "open");
        assert_eq!(directives[1].type_name(), "balance");
        assert_eq!(directives[2].type_name(), "transaction");
        assert_eq!(directives[3].type_name(), "close");
    }

    #[test]
    fn test_sort_directives_pad_before_balance() {
        // Pad must come before balance assertion on the same day
        let mut directives = vec![
            Directive::Balance(Balance::new(
                date(2024, 1, 1),
                "Assets:Bank",
                Amount::new(dec!(1000), "USD"),
            )),
            Directive::Pad(Pad::new(
                date(2024, 1, 1),
                "Assets:Bank",
                "Equity:Opening-Balances",
            )),
        ];

        sort_directives(&mut directives);

        assert_eq!(directives[0].type_name(), "pad");
        assert_eq!(directives[1].type_name(), "balance");
    }

    #[test]
    fn same_date_directives_sort_in_file_order() {
        // #2093 / #841. These two are the transactions from #841: the
        // "Transfer Received" that looks like a reduction is written before
        // the "Transfer Sent" that creates the lot.
        //
        // The sort deliberately does NOT float the augmentation ahead. Python
        // books same-date entries by `lineno`, and reordering them changes
        // which lots an ambiguous match can see (#2093). #841's ledger is
        // handled by `Inventory::is_booking_reduction` instead: with
        // `Assets:Transit` empty, the -11.11 posting is an augmentation that
        // opens a negative lot, and "Transfer Sent" closes it.
        let looks_like_a_reduction = Directive::Transaction(
            Transaction::new(date(2024, 9, 1), "Transfer Received")
                .with_synthesized_posting(
                    Posting::new("Assets:AccountB", Amount::new(dec!(11.11), "USD")).with_cost(
                        CostSpec::empty()
                            .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
                            .with_currency("EUR"),
                    ),
                )
                .with_synthesized_posting(
                    Posting::new("Assets:Transit", Amount::new(dec!(-11.11), "USD")).with_cost(
                        CostSpec::empty()
                            .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
                            .with_currency("EUR"),
                    ),
                ),
        );

        let augmentation = Directive::Transaction(
            Transaction::new(date(2024, 9, 1), "Transfer Sent")
                .with_synthesized_posting(Posting::new(
                    "Assets:AccountA",
                    Amount::new(dec!(-10.00), "EUR"),
                ))
                .with_synthesized_posting(
                    Posting::new("Assets:Transit", Amount::new(dec!(11.11), "USD")).with_cost(
                        CostSpec::empty()
                            .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
                            .with_currency("EUR"),
                    ),
                ),
        );

        let mut directives = vec![looks_like_a_reduction, augmentation];
        sort_directives(&mut directives);

        let narrations: Vec<&str> = directives
            .iter()
            .map(|d| match d {
                Directive::Transaction(t) => t.narration.as_str(),
                _ => unreachable!(),
            })
            .collect();
        assert_eq!(
            narrations,
            vec!["Transfer Received", "Transfer Sent"],
            "same-date directives must keep file order; floating augmentations \
             ahead of reductions is what #2093 reported"
        );
    }

    #[test]
    fn test_transaction_flags() {
        let make_txn = |flag: char| Transaction::new(date(2024, 1, 15), "Test").with_flag(flag);

        // Standard flags
        assert!(make_txn('*').is_complete());
        assert!(make_txn('!').is_incomplete());
        assert!(make_txn('!').is_pending());

        // Extended flags
        assert!(make_txn('S').is_summarization());
        assert!(make_txn('T').is_transfer());
        assert!(make_txn('C').is_conversion());
        assert!(make_txn('U').is_unrealized());
        assert!(make_txn('R').is_return());
        assert!(make_txn('M').is_merge());
        assert!(make_txn('#').is_bookmarked());
        assert!(make_txn('?').needs_investigation());

        // Negative cases
        assert!(!make_txn('*').is_pending());
        assert!(!make_txn('!').is_complete());
    }

    #[test]
    fn test_is_valid_flag() {
        // Valid flags
        for flag in [
            '*', '!', 'P', 'S', 'T', 'C', 'U', 'R', 'M', '#', '?', '%', '&',
        ] {
            assert!(
                Transaction::is_valid_flag(flag),
                "Flag '{flag}' should be valid"
            );
        }

        // Invalid flags
        for flag in ['x', 'X', '0', ' ', 'a', 'Z'] {
            assert!(
                !Transaction::is_valid_flag(flag),
                "Flag '{flag}' should be invalid"
            );
        }
    }

    #[test]
    fn test_transaction_display_includes_metadata() {
        let mut meta = Metadata::default();
        meta.insert(
            "document".to_string(),
            MetaValue::String("myfile.pdf".to_string()),
        );

        let txn = Transaction {
            date: date(2026, 2, 23),
            flag: '*',
            payee: None,
            narration: "Example".into(),
            tags: vec![],
            links: vec![],
            meta,
            postings: vec![
                crate::Spanned::synthesized(Posting::new(
                    "Assets:Bank",
                    Amount::new(dec!(-2), "USD"),
                )),
                crate::Spanned::synthesized(Posting::auto("Expenses:Example")),
            ],
            trailing_comments: Vec::new(),
        };

        let output = txn.to_string();
        assert!(
            output.contains("document: \"myfile.pdf\""),
            "Transaction Display should include metadata: {output}"
        );
        assert!(
            output.contains("Assets:Bank"),
            "Transaction Display should include postings: {output}"
        );
    }

    #[test]
    fn test_posting_display_includes_metadata() {
        let mut meta = Metadata::default();
        meta.insert(
            "category".to_string(),
            MetaValue::String("groceries".to_string()),
        );

        let posting = Posting {
            account: "Expenses:Food".into(),
            units: Some(IncompleteAmount::Complete(Amount::new(dec!(50), "USD"))),
            cost: None,
            price: None,
            flag: None,
            meta,
            comments: Vec::new(),
            trailing_comments: Vec::new(),
        };

        let output = posting.to_string();
        assert!(
            output.contains("category: \"groceries\""),
            "Posting Display should include metadata: {output}"
        );
    }

    #[test]
    fn test_directive_display() {
        // Test that Directive enum delegates to inner type's Display
        let txn = Transaction::new(date(2024, 1, 15), "Test transaction");
        let dir = Directive::Transaction(txn.clone());

        // Directive::Display should produce same output as Transaction::Display
        assert_eq!(format!("{dir}"), format!("{txn}"));

        // Test other directive types
        let open = Open::new(date(2024, 1, 1), "Assets:Bank");
        let dir_open = Directive::Open(open.clone());
        assert_eq!(format!("{dir_open}"), format!("{open}"));

        let balance = Balance::new(
            date(2024, 1, 1),
            "Assets:Bank",
            Amount::new(dec!(100), "USD"),
        );
        let dir_balance = Directive::Balance(balance.clone());
        assert_eq!(format!("{dir_balance}"), format!("{balance}"));
    }

    // ----- parse_precision_meta (issue #991) ---------------------------------

    #[test]
    fn parse_precision_meta_accepts_non_negative_integers() {
        // `precision: 2` parses as `Int`; `precision: 2.0` as `Number`. Both
        // must validate identically.
        assert_eq!(parse_precision_meta(&MetaValue::Int(0)), Ok(0));
        assert_eq!(parse_precision_meta(&MetaValue::Int(2)), Ok(2));
        assert_eq!(parse_precision_meta(&MetaValue::Int(28)), Ok(28));
        assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(0))), Ok(0));
        assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(2))), Ok(2));
        assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(28))), Ok(28));
        // Integer-valued decimals (e.g. `precision: 2.0` in source) must
        // round-trip the same as `precision: 2` — the parser will produce
        // `Number(dec!(2.0))` for the dotted form.
        assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(2.0))), Ok(2));
        assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(0.000))), Ok(0));
    }

    #[test]
    fn parse_precision_meta_rejects_negatives() {
        let err = parse_precision_meta(&MetaValue::Number(dec!(-1))).unwrap_err();
        assert!(err.contains("non-negative"), "got: {err}");
        let err = parse_precision_meta(&MetaValue::Int(-1)).unwrap_err();
        assert!(err.contains("non-negative"), "got: {err}");
    }

    #[test]
    fn parse_precision_meta_rejects_fractional() {
        let err = parse_precision_meta(&MetaValue::Number(dec!(2.5))).unwrap_err();
        assert!(err.contains("integer"), "got: {err}");
    }

    #[test]
    fn parse_precision_meta_rejects_overflow() {
        // 2^33 — out of u32 range.
        let err = parse_precision_meta(&MetaValue::Number(dec!(8589934592))).unwrap_err();
        assert!(err.contains("exceeds"), "got: {err}");
        let err = parse_precision_meta(&MetaValue::Int(8_589_934_592)).unwrap_err();
        assert!(err.contains("exceeds"), "got: {err}");
    }

    #[test]
    fn meta_value_int_display_and_kind() {
        assert_eq!(MetaValue::Int(42).to_string(), "42");
        assert_eq!(MetaValue::Int(-7).to_string(), "-7");
        assert_eq!(
            crate::format::format_meta_value(
                &MetaValue::Int(42),
                &crate::format::FormatConfig::default()
            ),
            "42"
        );
        assert_eq!(meta_value_kind(&MetaValue::Int(0)), "int");
    }

    #[test]
    fn parse_precision_meta_rejects_non_number_variants() {
        // Cover every non-Number `MetaValue` variant so the kind-labeling
        // arms in `meta_value_kind` are all exercised. Each error message
        // names the kind ("string value", "bool value", etc.) so users
        // see what they actually wrote.
        use crate::Amount;
        use rust_decimal_macros::dec;
        let cases = [
            (MetaValue::String("2".into()), "string"),
            (MetaValue::Account("Assets:Cash".into()), "account"),
            (MetaValue::Currency("USD".into()), "currency"),
            (MetaValue::Tag("foo".into()), "tag"),
            (MetaValue::Link("bar".into()), "link"),
            (MetaValue::Date(date(2024, 1, 1)), "date"),
            (MetaValue::Bool(true), "bool"),
            (MetaValue::Amount(Amount::new(dec!(2), "USD")), "amount"),
            (MetaValue::None, "none"),
        ];
        for (case, kind) in cases {
            let err = match parse_precision_meta(&case) {
                Ok(_) => panic!("should have rejected {case:?}"),
                Err(e) => e,
            };
            assert!(
                err.contains(kind),
                "error for {case:?} should mention kind {kind:?}, got: {err}"
            );
        }
    }
}
