//! doppio -- a compiler and query library for the Ledger plain-text
//! accounting format.
//!
//! # `.dop` binary format
//!
//! The `dop compile` command serialises an elaborated journal to a `.dop`
//! file. The file begins with an 8-byte header followed by the payload:
//!
//! ```text
//! Offset  Length  Content
//! 0       4       Magic: b"DOP\0"
//! 4       2       Format version: u16 LE (currently 3)
//! 6       1       Compression: u8  (0 = none, 1 = deflate)
//! 7       1       Reserved (write 0, ignore on read)
//! 8       N       Payload (protobuf, optionally deflate-compressed per byte 6)
//! ```
//!
//! Use [`write_dop`] / [`read_dop`] for the full header + (optional)
//! compression + protobuf round-trip.
//!
//! # Pipeline
//!
//! Source text is processed through four stages:
//!
//! ```text
//! source text
//!   → [parser]      ast::Journal        (PEG grammar + Pratt expressions)
//!   → [resolution]  resolution::HIR     (dates, aliases, metadata)
//!   → [elaboration] elaboration::Journal (evaluation, balancing)
//!   → serialisation                     (protobuf + optional deflate → .dop)
//! ```
//!
//! The top-level entry point is [`compile`], which runs all three in-memory
//! stages and returns the elaborated [`Journal`]. For CLI usage see the
//! `dop` binary in `src/main.rs`.
//!
//! # Modules
//!
//! - [`frontend`] -- the [`Frontend`] trait for pluggable file-format support.
//! - [`grammars`] -- grammar implementations ([`grammars::ledger`] for
//!   ledger-cli, [`grammars::hledger`] for hledger).
//! - [`resolution`] -- alias resolution, date normalisation, metadata
//!   extraction.
//! - [`elaboration`] -- prost-generated Protocol Buffers types
//!   (`Journal`, `Transaction`, `Posting`, `Amount`, `Decimal`); this is the
//!   canonical read-side public surface and the wire shape of `.dop` bodies.
//!
//! # Serialising transactions as Ledger text
//!
//! Use [`write_ledger`] to serialise a sequence of [`resolution::Transaction`]
//! values back to canonical Ledger source text:
//!
//! ```rust
//! # use doppio::resolution::{Transaction, Posting};
//! # use chrono::NaiveDate;
//! let txns = vec![
//!     Transaction::new(NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(), "Groceries")
//!         .with_posting(Posting::new("Expenses:Food").with_amount((
//!             rust_decimal::Decimal::from(50u32), "$",
//!         )))
//!         .with_posting(Posting::new("Assets:Checking")),
//! ];
//! let mut out = Vec::new();
//! doppio::write_ledger(txns, &mut out).unwrap();
//! ```

pub mod ast;
// Crate-private elaboration pipeline. Exposes `ElaborationError` (re-exported
// at crate root) so callers can catch elaboration failures without needing to
// know about the internal pipeline types. Eventually this module's job will
// be folded directly into `elaboration::*` (the proto-shaped types) but for
// now it stays as a transitional intermediate.
pub(crate) mod elaborator;
pub use elaborator::{Amount, ElaborationError, EvaluationError};
pub mod frontend;
pub mod grammars;
pub mod resolution;

/// Prost-generated Protocol Buffers types -- canonical wire shape of `.dop` bodies.
pub mod elaboration {
    include!(concat!(env!("OUT_DIR"), "/doppio.rs"));
}

mod elaboration_ext;

pub use elaboration::Journal;
pub use frontend::Frontend;
pub use grammars::beancount::BeancountFrontend;
pub use grammars::hledger::HledgerFrontend;
pub use grammars::ledger::LedgerFrontend;

/// Select a frontend by file extension.
///
/// Returns the appropriate [`Frontend`] implementation for `ext`.
/// Dispatch table:
///
/// | Extension | Frontend |
/// |-----------|----------|
/// | `"ledger"` | [`LedgerFrontend`] |
/// | `"hledger"` | [`HledgerFrontend`] |
/// | `"journal"` | [`HledgerFrontend`] |
/// | `"beancount"` | [`BeancountFrontend`] (experimental) |
/// | anything else / `None` | [`LedgerFrontend`] (default) |
///
/// # Example
///
/// ```rust
/// let fe = doppio::frontend_for_extension(Some("ledger"));
/// assert!(fe.extensions().contains(&"ledger"));
///
/// let fe2 = doppio::frontend_for_extension(Some("hledger"));
/// assert!(fe2.extensions().contains(&"hledger"));
///
/// let fe3 = doppio::frontend_for_extension(Some("journal"));
/// assert!(fe3.extensions().contains(&"journal"));
///
/// let fe5 = doppio::frontend_for_extension(Some("beancount"));
/// assert!(fe5.extensions().contains(&"beancount"));
///
/// // Unknown extensions fall back to the ledger frontend.
/// let fe4 = doppio::frontend_for_extension(None);
/// assert!(fe4.extensions().contains(&"ledger"));
/// ```
pub fn frontend_for_extension(ext: Option<&str>) -> Box<dyn Frontend> {
    let Some(e) = ext else {
        return Box::new(LedgerFrontend);
    };
    if HledgerFrontend.extensions().contains(&e) {
        Box::new(HledgerFrontend)
    } else if BeancountFrontend.extensions().contains(&e) {
        Box::new(BeancountFrontend)
    } else if LedgerFrontend.extensions().contains(&e) {
        Box::new(LedgerFrontend)
    } else {
        // Preserve existing fall-through behaviour: unknown extensions
        // dispatch to the ledger frontend.
        Box::new(LedgerFrontend)
    }
}

// ---
// Proto conversion: elaboration types <-> proto wire types
// ---

/// Convert a `rust_decimal::Decimal` to the proto [`elaboration::Decimal`] encoding.
///
/// The mantissa is split into low (u64) and high (i64, sign-extended) halves of
/// the 128-bit two's-complement integer, with the scale preserved as-is.
fn decimal_to_proto(d: rust_decimal::Decimal) -> elaboration::Decimal {
    let mantissa: i128 = d.mantissa();
    let scale = d.scale();
    let mantissa_low = mantissa as u64;
    let mantissa_high = (mantissa >> 64) as i64;
    elaboration::Decimal {
        mantissa_low,
        mantissa_high,
        scale,
    }
}

/// Reconstruct a `rust_decimal::Decimal` from its [`elaboration::Decimal`] encoding.
///
/// Crate-internal: external consumers should call the inherent method
/// [`elaboration::Decimal::to_decimal`] instead.
pub(crate) fn decimal_from_proto(p: &elaboration::Decimal) -> rust_decimal::Decimal {
    let mantissa = ((p.mantissa_high as i128) << 64) | (p.mantissa_low as i128);
    rust_decimal::Decimal::from_i128_with_scale(mantissa, p.scale)
}

/// Convert an [`elaborator::TransactionState`] to its proto enum value (i32).
fn state_to_proto(s: &elaborator::TransactionState) -> i32 {
    match s {
        elaborator::TransactionState::Uncleared => elaboration::TransactionState::Uncleared as i32,
        elaborator::TransactionState::Pending => elaboration::TransactionState::Pending as i32,
        elaborator::TransactionState::Cleared => elaboration::TransactionState::Cleared as i32,
    }
}

/// Convert an [`ast::PostingKind`] to its proto enum value (i32).
pub(crate) fn posting_kind_to_proto(kind: ast::PostingKind) -> i32 {
    match kind {
        // Emit REAL (1) rather than UNSPECIFIED (0) for clarity, even though
        // consumers treat both identically per is_real().
        ast::PostingKind::Real => elaboration::PostingKind::Real as i32,
        ast::PostingKind::VirtualUnbalanced => elaboration::PostingKind::VirtualUnbalanced as i32,
        ast::PostingKind::VirtualBalanced => elaboration::PostingKind::VirtualBalanced as i32,
    }
}

// ---
// Public write/read API
// ---

/// Compression algorithm used in the `.dop` payload.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Compression {
    /// No compression -- raw protobuf bytes.
    None,
    /// Deflate compression via `miniz_oxide`.
    Deflate,
}

impl Compression {
    fn as_byte(self) -> u8 {
        match self {
            Compression::None => 0,
            Compression::Deflate => 1,
        }
    }

    fn from_byte(b: u8) -> Option<Self> {
        match b {
            0 => Some(Compression::None),
            1 => Some(Compression::Deflate),
            _ => std::option::Option::None,
        }
    }
}

/// Serialise `journal` to `writer` as a complete `.dop` file
/// (8-byte header + optional deflate + protobuf body).
///
/// # Errors
///
/// Propagates any [`std::io::Error`] from `writer`.
pub fn write_dop<W: std::io::Write>(
    journal: &elaboration::Journal,
    writer: &mut W,
    compression: Compression,
) -> std::io::Result<()> {
    use prost::Message as _;

    let encoded = journal.encode_to_vec();

    dop_write_header(writer, compression)?;

    let payload = match compression {
        Compression::None => encoded,
        Compression::Deflate => miniz_oxide::deflate::compress_to_vec(&encoded, 6),
    };

    writer.write_all(&payload)
}

/// Deserialise a `.dop` file from `reader` into a [`Journal`].
///
/// `path` is used only in error messages.
///
/// # Errors
///
/// Returns a boxed error if the header is invalid, the compression byte is
/// unrecognised, decompression fails, or protobuf decoding fails.
pub fn read_dop<R: std::io::Read>(
    reader: &mut R,
    path: &std::path::Path,
) -> Result<elaboration::Journal, Box<dyn std::error::Error>> {
    use prost::Message as _;

    let compression = dop_read_header(reader, path)?;

    let mut payload = Vec::new();
    reader.read_to_end(&mut payload)?;

    let proto_bytes = match compression {
        Compression::None => payload,
        Compression::Deflate => miniz_oxide::inflate::decompress_to_vec(&payload)
            .map_err(|e| format!("{}: deflate decompression failed: {e:?}", path.display()))?,
    };

    elaboration::Journal::decode(proto_bytes.as_slice())
        .map_err(|e| format!("{}: protobuf decode failed: {e}", path.display()).into())
}

/// Write a sequence of [`resolution::Transaction`] values to `writer` in
/// canonical Ledger source text format.
///
/// Each transaction is formatted using its [`std::fmt::Display`] impl and
/// separated from the next by a blank line. The output is suitable for
/// appending to or creating a `.ledger` source file and round-trips correctly
/// through the parser: `write_ledger(txns)` -> parse -> resolve should yield
/// semantically equivalent transactions.
///
/// # Errors
///
/// Returns an [`std::io::Error`] if any write to `writer` fails.
///
/// # Example
///
/// ```rust
/// # use doppio::resolution::{Transaction, Posting};
/// # use chrono::NaiveDate;
/// let txns = vec![
///     Transaction::new(NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(), "Groceries")
///         .with_posting(Posting::new("Expenses:Food").with_amount((
///             rust_decimal::Decimal::from(50u32), "$",
///         )))
///         .with_posting(Posting::new("Assets:Checking")),
/// ];
/// let mut out = Vec::new();
/// doppio::write_ledger(txns, &mut out).unwrap();
/// let text = String::from_utf8(out).unwrap();
/// assert!(text.starts_with("2024-01-15 Groceries"));
/// ```
pub fn write_ledger<W>(
    entries: impl IntoIterator<Item = resolution::Transaction>,
    writer: &mut W,
) -> std::io::Result<()>
where
    W: std::io::Write,
{
    let mut first = true;
    for txn in entries {
        if !first {
            writeln!(writer)?;
        }
        first = false;
        write!(writer, "{txn}")?;
    }
    Ok(())
}

/// Load and concatenate all files matching a glob pattern.
///
/// This is the default file-opener used by the CLI when processing `include`
/// directives. It is passed to [`grammars::ledger::Parser`] as the `opener` field.
///
/// ## Glob patterns
///
/// Any path containing `*`, `?`, or `[` is treated as a glob pattern. Matched
/// files are read in **lexicographic order** (sorted by path after expansion)
/// and concatenated into a single string.
///
/// A glob pattern that matches **zero** files is an error -- it almost always
/// indicates a misconfigured `include` directive or a missing file tree.
///
/// ## Literal paths
///
/// A path with no glob metacharacters is treated as a single-file include. If
/// the file does not exist, an I/O error is returned.
///
/// ## Errors
///
/// Returns a boxed error if:
/// - `pattern` is not a valid glob expression,
/// - the pattern contains glob metacharacters but matches no files,
/// - a matched path cannot be read (I/O error), or
/// - a literal path does not exist (I/O error).
#[cfg(not(target_family = "wasm"))]
pub fn file_opener(pattern: &str) -> Result<String, Box<dyn std::error::Error>> {
    use std::io::Read as _;

    // Collect and sort all matching paths. Sorting ensures lexicographic,
    // deterministic ordering regardless of filesystem traversal order.
    let mut paths: Vec<_> = glob::glob(pattern)?
        .collect::<Result<_, _>>()
        .map_err(|e| format!("glob match error for {pattern:?}: {e}"))?;
    paths.sort();

    // A glob with metacharacters that resolves to nothing is always an error.
    // A plain literal path that doesn't exist is caught below by the file open.
    let is_glob = pattern.contains(['*', '?', '[']);
    if is_glob && paths.is_empty() {
        return Err(format!("include glob {pattern:?} matched no files").into());
    }

    // Literal path: glob returns empty when the file doesn't exist (glob
    // silently skips non-existent literal paths). Detect this early.
    if !is_glob && paths.is_empty() {
        return Err(format!("include: file not found: {pattern}").into());
    }

    let mut buf = String::new();
    for path in &paths {
        // Ensure each appended file starts on a fresh line. If the previous
        // file didn't end with a newline, gluing the next file's first line
        // onto the previous one can change parse meaning (e.g. attach a
        // posting to the wrong transaction).
        if !buf.is_empty() && !buf.ends_with('\n') {
            buf.push('\n');
        }
        std::fs::File::open(path)
            .map_err(|e| format!("include: cannot open {}: {e}", path.display()))?
            .read_to_string(&mut buf)
            .map_err(|e| format!("include: cannot read {}: {e}", path.display()))?;
    }

    Ok(buf)
}

/// Compile Ledger source text into a fully elaborated [`Journal`].
///
/// Runs the three in-memory pipeline stages in sequence -- parse,
/// resolve, elaborate. The `parser` argument supplies the file-opener
/// for `include` directives and the base path for relative path
/// resolution.
///
/// # Errors
///
/// Returns a boxed error from the first failing stage (parse error, resolution
/// error, or elaboration error).
pub fn compile<F>(
    input: &str,
    mut parser: grammars::ledger::Parser<F>,
) -> Result<elaboration::Journal, Box<dyn std::error::Error>>
where
    F: Fn(&str) -> Result<String, Box<dyn std::error::Error>>,
{
    let output = parser.parse(input)?;
    let hir: resolution::HIR = output.try_into()?;
    // The parser is type-locked to ledger-cli, so the matching defaults
    // are `ledger_defaults()`. Callers who want a different elaboration
    // ruleset (e.g. relaxed tolerance, subtree assertions) can call
    // `frontend.parse()` and `elaborate()` separately and pass an
    // explicit `ElaborationConfig`.
    Ok(elaborate(hir, &grammars::ledger::ledger_defaults())?)
}

/// Run the elaboration stage on a resolved [`resolution::HIR`] under
/// the given [`resolution::ElaborationConfig`], producing a
/// fully-balanced [`elaboration::Journal`].
///
/// The config is the elaborator's only source of semantic choices
/// (tolerance rule, balance mode, assertion scope, ...). Callers
/// typically pass `&frontend.elaboration_defaults()` for the matching
/// tool's behaviour:
///
/// ```rust
/// use doppio::frontend::Frontend as _;
/// use doppio::LedgerFrontend;
///
/// let hir = LedgerFrontend
///     .parse(
///         "2024-01-01 Test\n  Expenses:Food  $10\n  Assets:Cash\n",
///         std::path::Path::new(""),
///         &|_| Ok(String::new()),
///     )
///     .unwrap();
/// let journal = doppio::elaborate(hir, &LedgerFrontend.elaboration_defaults()).unwrap();
/// assert_eq!(journal.transactions.len(), 1);
/// ```
///
/// Or any other config to mix-and-match syntax and semantics
/// (parse a beancount file under ledger-cli rules, etc.).
pub fn elaborate(
    hir: resolution::HIR,
    config: &resolution::ElaborationConfig,
) -> Result<elaboration::Journal, elaborator::ElaborationError> {
    elaborator::elaborate(hir, config)
}

/// Evaluate a single [`resolution::Transaction`] through the elaboration stage.
///
/// This is the bridge between programmatic transaction construction (via the
/// [`resolution::Transaction`] builder API) and full elaboration. It resolves
/// aliases, evaluates amount expressions, balances postings, and applies cost
/// basis -- returning a fully resolved transaction or an error.
///
/// The `context` parameter supplies alias definitions, commodity aliases, and
/// the default commodity. Use [`resolution::Context::default()`] when no
/// aliases or default commodity are needed.
///
/// Internally this constructs a minimal [`resolution::HIR`] containing the
/// single transaction, runs the elaboration pipeline, and extracts the result.
///
/// # Errors
///
/// Returns an [`elaborator::ElaborationError`] if the transaction cannot be
/// elaborated (e.g. unbalanced postings, expression evaluation failure, or
/// too many null postings).
///
/// # Example
///
/// ```rust
/// use doppio::resolution::{Context, Transaction, Posting};
/// use chrono::NaiveDate;
/// use rust_decimal::Decimal;
///
/// let txn = Transaction::new(
///     NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
///     "Groceries",
/// )
/// .with_posting(
///     Posting::new("Expenses:Food").with_amount((Decimal::from(50u32), "$")),
/// )
/// .with_posting(Posting::new("Assets:Checking"));
///
/// let resolved = doppio::eval_transaction(txn, &Context::default()).unwrap();
/// assert_eq!(resolved.description, "Groceries");
/// assert_eq!(resolved.postings.len(), 2);
/// ```
pub fn eval_transaction(
    txn: resolution::Transaction,
    context: &resolution::Context,
) -> Result<elaboration::Transaction, elaborator::ElaborationError> {
    let hir = resolution::HIR {
        entries: vec![resolution::ResolutionEntry {
            context_id: 0,
            data: resolution::Entry::Transaction(txn),
        }],
        contexts: vec![context.clone()],
        ..Default::default()
    };
    // No frontend is involved in programmatic transaction construction,
    // so there's no natural "matching" default. Use the ledger-cli rules
    // (strict balance, cost-basis lots, direct-account assertions) --
    // the strictest of the three -- so a programmatically-built
    // transaction has to balance exactly. Callers that need different
    // semantics can use [`elaborate`] directly with their own
    // [`resolution::ElaborationConfig`].
    let journal = elaborator::elaborate(hir, &grammars::ledger::ledger_defaults())?;
    // The HIR contained exactly one transaction, so the journal has exactly one.
    Ok(journal
        .transactions
        .into_iter()
        .next()
        .expect("journal should contain exactly one transaction"))
}

// ---
// .dop header helpers
// ---

/// Four-byte magic that identifies every `.dop` file.
pub(crate) const DOP_MAGIC: [u8; 4] = *b"DOP\0";

/// Format version embedded in every `.dop` header.
///
/// Bump this constant (and update [`dop_read_header`]) whenever the
/// serialisation format changes in a breaking way.
pub(crate) const DOP_FORMAT_VERSION: u16 = 3;

/// Write the 8-byte `.dop` header to `writer`.
///
/// Layout: magic (4 bytes) + version LE u16 (2 bytes) +
///         compression byte (1 byte) + reserved byte (1 byte).
///
/// Crate-internal helper for [`write_dop`].
pub(crate) fn dop_write_header<W: std::io::Write>(
    writer: &mut W,
    compression: Compression,
) -> std::io::Result<()> {
    writer.write_all(&DOP_MAGIC)?;
    writer.write_all(&DOP_FORMAT_VERSION.to_le_bytes())?;
    writer.write_all(&[compression.as_byte(), 0u8])?;
    Ok(())
}

/// Read and validate the 8-byte `.dop` header from `reader`.
///
/// Returns the [`Compression`] method declared in the header. Crate-internal
/// helper for [`read_dop`].
pub(crate) fn dop_read_header<R: std::io::Read>(
    reader: &mut R,
    path: &std::path::Path,
) -> Result<Compression, Box<dyn std::error::Error>> {
    let mut magic = [0u8; 4];
    // A short read here means the file is too small to be valid.
    reader.read_exact(&mut magic).map_err(|_| {
        format!(
            "{}: not a valid .dop file (missing magic header); \
             recompile from source with `dop compile`",
            path.display()
        )
    })?;
    if magic != DOP_MAGIC {
        return Err(format!(
            "{}: not a valid .dop file (missing magic header); \
             recompile from source with `dop compile`",
            path.display()
        )
        .into());
    }

    let mut version_bytes = [0u8; 2];
    reader.read_exact(&mut version_bytes)?;
    let version = u16::from_le_bytes(version_bytes);
    if version != DOP_FORMAT_VERSION {
        return Err(format!(
            "{}: incompatible .dop format version {} \
             (this binary supports version {}); \
             recompile from source with `dop compile`",
            path.display(),
            version,
            DOP_FORMAT_VERSION,
        )
        .into());
    }

    let mut compression_reserved = [0u8; 2];
    reader.read_exact(&mut compression_reserved)?;
    let compression = Compression::from_byte(compression_reserved[0]).ok_or_else(|| {
        format!(
            "{}: unknown compression byte {} in .dop header",
            path.display(),
            compression_reserved[0],
        )
    })?;
    // byte 7 is reserved -- ignored on read.

    Ok(compression)
}

#[cfg(test)]
mod write_ledger_tests {
    use chrono::NaiveDate;
    use rust_decimal::Decimal;

    use super::*;

    fn date(y: i32, m: u32, d: u32) -> NaiveDate {
        NaiveDate::from_ymd_opt(y, m, d).unwrap()
    }

    /// Parse a Ledger-format source string and return the resolved transactions.
    fn parse_transactions(source: &str) -> Vec<resolution::Transaction> {
        let mut p = grammars::ledger::Parser {
            opener: |_: &str| Ok(String::new()),
            base_path: std::path::PathBuf::new(),
        };
        let ast_journal = p.parse(&source.to_string()).expect("parse failed");
        let hir: resolution::HIR = ast_journal.try_into().expect("resolution failed");
        hir.transactions().collect()
    }

    #[test]
    fn write_empty_iterator_produces_no_output() {
        let mut out: Vec<u8> = Vec::new();
        write_ledger(std::iter::empty::<resolution::Transaction>(), &mut out).unwrap();
        assert!(out.is_empty());
    }

    #[test]
    fn write_single_transaction_basic() {
        let txn = resolution::Transaction::new(date(2024, 1, 15), "Groceries")
            .with_posting(
                resolution::Posting::new("Expenses:Food").with_amount((Decimal::from(50u32), "$")),
            )
            .with_posting(resolution::Posting::new("Assets:Checking"));

        let mut out: Vec<u8> = Vec::new();
        write_ledger([txn], &mut out).unwrap();
        let text = String::from_utf8(out).unwrap();

        assert_eq!(
            text,
            "2024-01-15 Groceries\n    Expenses:Food  50 $\n    Assets:Checking\n"
        );
    }

    #[test]
    fn multiple_transactions_separated_by_blank_line() {
        let txns = vec![
            resolution::Transaction::new(date(2024, 1, 1), "First"),
            resolution::Transaction::new(date(2024, 1, 2), "Second"),
        ];

        let mut out: Vec<u8> = Vec::new();
        write_ledger(txns, &mut out).unwrap();
        let text = String::from_utf8(out).unwrap();

        assert_eq!(text, "2024-01-01 First\n\n2024-01-02 Second\n");
    }

    #[test]
    fn round_trip_preserves_date_and_description() {
        let original = resolution::Transaction::new(date(2024, 3, 15), "Salary payment")
            .with_state(ast::TransactionState::Cleared)
            .with_posting(
                resolution::Posting::new("Income:Salary")
                    .with_amount((Decimal::from(5000u32), "USD")),
            )
            .with_posting(resolution::Posting::new("Assets:Checking"));

        let mut out: Vec<u8> = Vec::new();
        write_ledger([original], &mut out).unwrap();
        let text = String::from_utf8(out).unwrap();

        let parsed = parse_transactions(&text);
        assert_eq!(parsed.len(), 1);
        let roundtripped = &parsed[0];

        assert_eq!(roundtripped.date, date(2024, 3, 15));
        assert_eq!(roundtripped.description, "Salary payment");
        assert!(matches!(roundtripped.state, ast::TransactionState::Cleared));
        assert_eq!(roundtripped.postings.len(), 2);
        assert_eq!(roundtripped.postings[0].account, "Income:Salary");
        assert_eq!(roundtripped.postings[1].account, "Assets:Checking");
    }

    #[test]
    fn round_trip_preserves_metadata_and_tags() {
        let original = resolution::Transaction::new(date(2024, 6, 1), "Grant revenue")
            .with_tag("income")
            .with_comment("Q2 payment")
            .with_comment("approved")
            .with_metadata("program", "Grant:UW:HARVEST")
            .with_metadata("ref", "INV-001")
            .with_posting(
                resolution::Posting::new("Income:Grants")
                    .with_amount((Decimal::from(10_000u32), "$")),
            )
            .with_posting(resolution::Posting::new("Assets:Checking"));

        let mut out: Vec<u8> = Vec::new();
        write_ledger([original], &mut out).unwrap();
        let text = String::from_utf8(out).unwrap();

        let parsed = parse_transactions(&text);
        assert_eq!(parsed.len(), 1);
        let rt = &parsed[0];

        assert!(
            rt.tags.contains(&"income".to_string()),
            "tag 'income' missing from {rt:?}"
        );
        assert!(
            rt.comments.contains(&"Q2 payment".to_string()),
            "comment 'Q2 payment' missing from {rt:?}",
        );
        assert!(
            rt.comments.contains(&"approved".to_string()),
            "comment 'approved' missing from {rt:?}",
        );
        assert_eq!(
            rt.metadata.get("program").map(String::as_str),
            Some("Grant:UW:HARVEST")
        );
        assert_eq!(rt.metadata.get("ref").map(String::as_str), Some("INV-001"));
    }

    #[test]
    fn round_trip_multiple_transactions() {
        let txns = vec![
            resolution::Transaction::new(date(2024, 1, 10), "Food")
                .with_posting(
                    resolution::Posting::new("Expenses:Food")
                        .with_amount((Decimal::from(30u32), "$")),
                )
                .with_posting(resolution::Posting::new("Assets:Checking")),
            resolution::Transaction::new(date(2024, 1, 20), "Rent")
                .with_state(ast::TransactionState::Cleared)
                .with_posting(
                    resolution::Posting::new("Expenses:Rent")
                        .with_amount((Decimal::from(1200u32), "$")),
                )
                .with_posting(resolution::Posting::new("Assets:Checking")),
        ];

        let mut out: Vec<u8> = Vec::new();
        write_ledger(txns, &mut out).unwrap();
        let text = String::from_utf8(out).unwrap();

        let parsed = parse_transactions(&text);
        assert_eq!(parsed.len(), 2);

        assert_eq!(parsed[0].description, "Food");
        assert_eq!(parsed[0].date, date(2024, 1, 10));

        assert_eq!(parsed[1].description, "Rent");
        assert_eq!(parsed[1].date, date(2024, 1, 20));
        assert!(matches!(parsed[1].state, ast::TransactionState::Cleared));
    }

    #[test]
    fn round_trip_posting_with_metadata() {
        let original = resolution::Transaction::new(date(2024, 4, 1), "Payroll")
            .with_posting(
                resolution::Posting::new("Expenses:Salary")
                    .with_amount((Decimal::from(3000u32), "$"))
                    .with_metadata("employee", "alice")
                    .with_tag("payroll"),
            )
            .with_posting(resolution::Posting::new("Assets:Bank"));

        let mut out: Vec<u8> = Vec::new();
        write_ledger([original], &mut out).unwrap();
        let text = String::from_utf8(out).unwrap();

        let parsed = parse_transactions(&text);
        assert_eq!(parsed.len(), 1);
        let posting = &parsed[0].postings[0];

        assert_eq!(posting.account, "Expenses:Salary");
        assert_eq!(
            posting.metadata.get("employee").map(String::as_str),
            Some("alice")
        );
        assert!(posting.tags.contains(&"payroll".to_string()));
    }
}

#[cfg(test)]
mod eval_transaction_tests {
    use chrono::NaiveDate;
    use rust_decimal::{Decimal, dec};

    use super::*;

    fn date(y: i32, m: u32, d: u32) -> NaiveDate {
        NaiveDate::from_ymd_opt(y, m, d).unwrap()
    }

    #[test]
    fn simple_two_posting_transaction() {
        let txn = resolution::Transaction::new(date(2024, 1, 15), "Groceries")
            .with_posting(
                resolution::Posting::new("Expenses:Food").with_amount((Decimal::from(50u32), "$")),
            )
            .with_posting(resolution::Posting::new("Assets:Checking"));

        let resolved = eval_transaction(txn, &resolution::Context::default()).unwrap();

        assert_eq!(resolved.description, "Groceries");
        assert_eq!(resolved.postings.len(), 2);

        let food = resolved
            .postings
            .iter()
            .find(|p| p.account == "Expenses:Food")
            .unwrap();
        assert_eq!(food.amount_in("$"), Some(dec!(50)));

        let checking = resolved
            .postings
            .iter()
            .find(|p| p.account == "Assets:Checking")
            .unwrap();
        assert_eq!(checking.amount_in("$"), Some(dec!(-50)));
    }

    #[test]
    fn null_posting_inferred() {
        let txn = resolution::Transaction::new(date(2024, 2, 1), "Rent")
            .with_posting(
                resolution::Posting::new("Expenses:Rent")
                    .with_amount((Decimal::from(1200u32), "$")),
            )
            .with_posting(resolution::Posting::new("Assets:Checking"));

        let resolved = eval_transaction(txn, &resolution::Context::default()).unwrap();

        let checking = resolved
            .postings
            .iter()
            .find(|p| p.account == "Assets:Checking")
            .unwrap();
        assert_eq!(
            checking.amount_in("$"),
            Some(dec!(-1200)),
            "null posting should be inferred as -$1200"
        );
    }

    #[test]
    fn explicit_balanced_amounts() {
        let txn = resolution::Transaction::new(date(2024, 3, 1), "Transfer")
            .with_posting(
                resolution::Posting::new("Assets:Savings")
                    .with_amount((Decimal::from(500u32), "$")),
            )
            .with_posting(
                resolution::Posting::new("Assets:Checking")
                    .with_amount((Decimal::from(-500i32), "$")),
            );

        let resolved = eval_transaction(txn, &resolution::Context::default()).unwrap();
        assert_eq!(resolved.postings.len(), 2);
    }

    #[test]
    fn unbalanced_transaction_returns_error() {
        let txn = resolution::Transaction::new(date(2024, 4, 1), "Bad")
            .with_posting(
                resolution::Posting::new("Expenses:Food").with_amount((Decimal::from(100u32), "$")),
            )
            .with_posting(
                resolution::Posting::new("Assets:Checking")
                    .with_amount((Decimal::from(-50i32), "$")),
            );

        let result = eval_transaction(txn, &resolution::Context::default());
        assert!(
            result.is_err(),
            "unbalanced transaction should return an error"
        );
        assert!(matches!(
            result.unwrap_err(),
            elaborator::ElaborationError::TransactionDoesNotBalance(_)
        ));
    }

    #[test]
    fn account_alias_resolved_via_context() {
        let mut context = resolution::Context::default();
        context
            .account_aliases
            .insert("Checking".into(), "Assets:Checking:Mercury:7920".into());

        let txn = resolution::Transaction::new(date(2024, 5, 1), "Deposit")
            .with_posting(
                resolution::Posting::new("Income:Salary")
                    .with_amount((Decimal::from(5000u32), "$")),
            )
            .with_posting(resolution::Posting::new("Checking"));

        let resolved = eval_transaction(txn, &context).unwrap();

        let checking = resolved
            .postings
            .iter()
            .find(|p| p.account == "Assets:Checking:Mercury:7920")
            .expect("alias should resolve to canonical account name");
        assert_eq!(checking.amount_in("$"), Some(dec!(-5000)));
    }

    #[test]
    fn default_commodity_from_context() {
        let mut context = resolution::Context::default();
        context.default_commodity = Some("USD".into());

        let bare = ast::ValueExpr::Amount {
            value: Decimal::from(25u32),
            commodity: None,
        };
        let txn = resolution::Transaction::new(date(2024, 6, 1), "Bare amount")
            .with_posting(resolution::Posting::new("Expenses:Food").with_amount(bare))
            .with_posting(resolution::Posting::new("Assets:Cash"));

        let resolved = eval_transaction(txn, &context).unwrap();

        let food = resolved
            .postings
            .iter()
            .find(|p| p.account == "Expenses:Food")
            .unwrap();
        assert_eq!(
            food.amount_in("USD"),
            Some(dec!(25)),
            "bare amount should use default commodity from context"
        );
    }

    #[test]
    fn resolved_transaction_preserves_fields() {
        let txn = resolution::Transaction::new(date(2024, 7, 4), "Independence Day")
            .with_state(ast::TransactionState::Cleared)
            .with_code("IND-04")
            .with_secondary_date(date(2024, 7, 5))
            .with_tag("holiday")
            .with_metadata("ref", "USA")
            .with_posting(
                resolution::Posting::new("Expenses:Celebration")
                    .with_amount((Decimal::from(200u32), "$")),
            )
            .with_posting(resolution::Posting::new("Assets:Checking"));

        let resolved = eval_transaction(txn, &resolution::Context::default()).unwrap();

        assert_eq!(resolved.description, "Independence Day");
        assert_eq!(
            resolved.state,
            elaboration::TransactionState::Cleared as i32
        );
        assert_eq!(resolved.code.as_deref(), Some("IND-04"));
        assert!(resolved.secondary_date.is_some());
        assert!(resolved.tags.contains(&"holiday".to_string()));
        assert_eq!(
            resolved.metadata.get("ref").map(String::as_str),
            Some("USA")
        );
    }

    #[test]
    fn too_many_null_postings_returns_error() {
        let txn = resolution::Transaction::new(date(2024, 8, 1), "Ambiguous")
            .with_posting(resolution::Posting::new("Expenses:A"))
            .with_posting(resolution::Posting::new("Expenses:B"));

        let result = eval_transaction(txn, &resolution::Context::default());
        assert!(result.is_err());
        assert!(matches!(
            result.unwrap_err(),
            elaborator::ElaborationError::TooManyNullPostings
        ));
    }
}

// (proto_from_journal_tests module removed: it tested the
// `From<&pipeline::Journal> for elaboration::Journal` impls that are being
// deleted in this PR. Equivalent end-to-end coverage is provided by the
// CLI's dop_format integration tests, which exercise the same compile ->
// write -> read round-trip on the new direct path.)
