// doppio canonical wire format.
//
// This file defines the wire shape of `.dop` artifacts. Language consumers
// generate bindings via their respective `protoc` plugins and read elaborated
// journals directly.
//
// =============================================================================
// Decimal handling
// =============================================================================
//
// Protocol Buffers has no native big-decimal type. The mantissa is encoded as
// a signed 128-bit two's-complement integer split into two halves (low:
// uint64, high: sint64), plus an unsigned scale (0..28).
//
// Reassemble client-side with:
//
//   value = (((mantissa_high as i128) << 64) | (mantissa_low as i128)) * 10^(-scale)
//
// Two things are easy to get wrong: (1) sign-extension of the high half when
// reconstructing the 128-bit integer, and (2) the direction of scale (it is
// 10^-scale, so a scale of 2 means divide by 100). The Rust reference
// implementation lives at `crates/doppio/src/lib.rs::decimal_from_proto`.
//
// Recipes for common languages:
//
//   Rust (the reference):
//     fn decimal_from_proto(p: &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)
//     }
//
//   Python (using stdlib `decimal.Decimal`):
//     from decimal import Decimal
//     def decimal_from_proto(p) -> Decimal:
//         # Reconstruct as a signed Python int. Python ints are arbitrary
//         # precision, so we shift the (already-signed) high 64 bits and
//         # OR in the unsigned low 64 bits.
//         mantissa = (p.mantissa_high << 64) | (p.mantissa_low & ((1 << 64) - 1))
//         return Decimal(mantissa).scaleb(-p.scale)
//
//   TypeScript / JavaScript (using `BigInt`; `Number` is too narrow):
//     // `decimal.js` or similar is recommended for the final type; the
//     // reassembly itself is `BigInt`-only:
//     function decimalFromProto(p: { mantissaLow: bigint, mantissaHigh: bigint, scale: number }): { mantissa: bigint, scale: number } {
//       const MASK64 = (1n << 64n) - 1n;
//       // p.mantissaHigh is a signed sint64; bigint preserves the sign.
//       // p.mantissaLow is uint64; mask defensively.
//       const mantissa = (p.mantissaHigh << 64n) | (p.mantissaLow & MASK64);
//       return { mantissa, scale: p.scale };
//     }
//
//   Go (using `math/big.Int` + manual scale; convert to `big.Float` or
//   shopspring/decimal at the boundary):
//     import "math/big"
//     func DecimalFromProto(p *Decimal) (*big.Int, uint32) {
//         mantissa := new(big.Int).Lsh(big.NewInt(p.MantissaHigh), 64)
//         lo := new(big.Int).SetUint64(p.MantissaLow)
//         mantissa.Or(mantissa, lo)
//         return mantissa, p.Scale
//     }
//
// Note that the protobuf wire types (`uint64` / `sint64`) determine sign
// handling: `sint64` uses ZigZag encoding for the high half so that small
// negative numbers stay compact on the wire, but consumers see a normal
// signed 64-bit value after decoding. `uint64` for the low half is intentional
// -- it carries the lower bits of the two's-complement representation, not a
// magnitude. This is why the language recipes mask `mantissaLow` defensively
// rather than treating it as signed.
//
// =============================================================================
// Exchange rate lookup
// =============================================================================
//
// Exchange rates are stored verbatim in `Journal.prices` as `HistoricalPrice`
// entries. Consumers compute a rate from `from_commodity` to `to_commodity`
// as of a given date with the algorithm below -- direct + inverse only, no
// chaining through intermediate commodities. This is doppio's normative
// contract; consumers in any language MUST implement it identically when
// returning a "default" rate for cross-language parity. (Consumers MAY
// implement chaining or other strategies on top, opt-in.)
//
// Algorithm:
//
//   exchange_rate_at(prices, from, to, as_of) -> Optional<rate>:
//     if from == to: return 1
//     best = None
//     for hp in prices:
//       direct  = (hp.commodity == from and hp.price_commodity == to)
//       inverse = (hp.commodity == to   and hp.price_commodity == from)
//       if not (direct or inverse): continue
//       if as_of is not None and hp.date_naive() > as_of: continue
//       if hp.price is None or hp.price.to_decimal() == 0: continue
//       rate = hp.price.to_decimal() if direct else 1 / hp.price.to_decimal()
//       if best is None or hp.date_naive() > best.date:
//         best = (hp.date_naive(), rate)
//     return best.rate if best is not None else None
//
// The "most-recent quote in either direction wins" rule is uniform: there
// is no preference between an explicit forward quote and the inverse of a
// reverse quote. The data point closest to `as_of` is the answer regardless
// of how it was declared.
//
// Rationale and comparison with hledger / Beancount / ledger-cli are in
// the doppio repo's `docs/exchange-rates.md`.
//
// Recipes for common languages:
//
//   TypeScript:
//     function exchangeRateAt(
//       prices: HistoricalPrice[],
//       from: string,
//       to: string,
//       asOf: Date | null,
//     ): Decimal | null {
//       if (from === to) return new Decimal(1);
//       let best: { date: Date; rate: Decimal } | null = null;
//       for (const p of prices) {
//         const direct = p.commodity === from && p.priceCommodity === to;
//         const inverse = p.commodity === to && p.priceCommodity === from;
//         if (!direct && !inverse) continue;
//         const date = epochDaysToDate(p.date);
//         if (asOf && date > asOf) continue;
//         const raw = decimalFromProto(p.price);
//         if (raw.isZero()) continue;
//         const rate = direct ? raw : new Decimal(1).div(raw);
//         if (!best || date > best.date) best = { date, rate };
//       }
//       return best?.rate ?? null;
//     }
//
//   Python (using stdlib `decimal.Decimal` and `datetime.date`):
//     from decimal import Decimal
//     def exchange_rate_at(prices, from_, to, as_of):
//         if from_ == to: return Decimal(1)
//         best = None
//         for hp in prices:
//             direct = hp.commodity == from_ and hp.price_commodity == to
//             inverse = hp.commodity == to and hp.price_commodity == from_
//             if not (direct or inverse): continue
//             date = epoch_days_to_date(hp.date)
//             if as_of is not None and date > as_of: continue
//             raw = decimal_from_proto(hp.price)
//             if raw == 0: continue
//             rate = raw if direct else Decimal(1) / raw
//             if best is None or date > best[0]: best = (date, rate)
//         return best[1] if best else None
//
//   Go (sketch; uses the project's preferred decimal lib at the boundary):
//     func ExchangeRateAt(prices []HistoricalPrice, from, to string, asOf *time.Time) *Rate {
//       if from == to { return One() }
//       var best *struct{ date time.Time; rate Rate }
//       for _, p := range prices {
//         direct := p.Commodity == from && p.PriceCommodity == to
//         inverse := p.Commodity == to && p.PriceCommodity == from
//         if !direct && !inverse { continue }
//         date := EpochDaysToTime(p.Date)
//         if asOf != nil && date.After(*asOf) { continue }
//         raw := DecimalFromProto(p.Price)
//         if raw.IsZero() { continue }
//         rate := raw; if !direct { rate = OneDiv(raw) }
//         if best == nil || date.After(best.date) {
//           best = &struct{ date time.Time; rate Rate }{date, rate}
//         }
//       }
//       if best == nil { return nil }
//       return &best.rate
//     }

// =============================================================================
// Evolution policy
// =============================================================================
//
// Normative from doppio 1.0 forward. Full rationale and how-to-land-a-change
// guide live at `docs/proto-evolution.md`. Summary:
//
// 1. **Additive only.** New fields take new tag numbers. Existing tag numbers
//    and field names never change meaning. Renaming a field is allowed
//    (wire-compatible); repurposing a field is not (use a new tag).
//
// 2. **Reserved on deprecation.** When a field is removed, its tag AND its
//    field name are added to the message's `reserved` clause so they can
//    never be reused. The CI test `crates/doppio/tests/proto_evolution.rs`
//    fails any PR that skips a tag without reserving it.
//
// 3. **Header-version bumps are rare.** The 8-byte `.dop` header carries a
//    u16 format version (currently 3). It bumps ONLY when a change is
//    breaking for old readers -- e.g. changing an existing field's wire
//    encoding, repurposing a tag, or adding a new compression byte. Adding
//    new optional fields, message types, or enum variants does NOT bump
//    the version (old readers ignore unknowns).
//
// 4. **Behaviour preservation.** For a fixed input and fixed compiler
//    version, `dop compile` produces byte-identical `.dop` output across
//    patch and minor releases (modulo documented format-version bumps).
//    Map fields use `BTreeMap` on the Rust side and consumers in other
//    languages sort by key explicitly when reproducibility matters.

syntax = "proto3";

package doppio;

// A 96-bit-mantissa fixed-point decimal value with scale 0..28.
message Decimal {
  // Lower 64 bits of the two's-complement mantissa.
  uint64 mantissa_low = 1;
  // Upper 64 bits of the two's-complement mantissa.
  sint64 mantissa_high = 2;
  // Number of decimal places (0..28).
  uint32 scale = 3;
}

// Cleared / pending / uncleared state of a transaction or posting.
enum TransactionState {
  TRANSACTION_STATE_UNSPECIFIED = 0;
  TRANSACTION_STATE_UNCLEARED = 1;
  TRANSACTION_STATE_PENDING = 2;
  TRANSACTION_STATE_CLEARED = 3;
}

// Virtual-posting semantics for a posting.
//
// Ledger-cli supports two virtual-posting markers that change balance-rule
// semantics:
//
//   Real (default)            -- normal posting; participates in balance check.
//   VirtualUnbalanced  `(A)` -- excluded from the transaction balance check.
//                               Reports include it by default; omit with --real.
//   VirtualBalanced    `[A]` -- included in the balance check but flagged so
//                               reports can include/exclude via --real.
//
// Proto3 evolution note: the default zero value (UNSPECIFIED) is intentionally
// treated as REAL by all consumers, so older `.dop` files that pre-date this
// field continue to behave as if every posting is REAL on read -- no format-
// version bump required.
enum PostingKind {
  POSTING_KIND_UNSPECIFIED = 0;        // Treated as REAL by all consumers.
  POSTING_KIND_REAL = 1;
  POSTING_KIND_VIRTUAL_UNBALANCED = 2; // `(Account)` -- excluded from balance.
  POSTING_KIND_VIRTUAL_BALANCED = 3;   // `[Account]` -- included in balance, flagged.
}

// A multi-commodity balance: commodity symbol -> Decimal value.
//
// Iteration-order note: `by_commodity` is a protobuf `map<string, Decimal>`.
// The protobuf specification explicitly does not guarantee map field
// ordering -- bindings in language X may iterate this map in any order. The
// same applies to `Posting.metadata`, `Transaction.metadata`,
// `Journal.accounts`, and `Journal.commodities`.
//
// doppio's Rust binding is an exception: it configures prost to generate
// `BTreeMap<String, _>` for every map field (see `crates/doppio/build.rs`'s
// `btree_map([\".\"])` setting), so iteration is deterministic and sorted by
// key. Rust consumers can rely on that. Bindings in other languages should
// sort by key explicitly when stable output is required:
//
//   // Python example (sorted-by-key iteration):
//   for commodity, decimal in sorted(amount.by_commodity.items()):
//       ...
//
//   // TypeScript example:
//   for (const commodity of Object.keys(amount.by_commodity).sort()) {
//       ...
//   }
message Amount {
  map<string, Decimal> by_commodity = 1;
}

// Per-lot cost basis and acquisition metadata, pinned at purchase time for
// capital-gains tracking and FIFO/LIFO lot selection on a later sale.
//
// Corresponds to the `{cost}`, `[date]`, and `((note))` annotation forms
// in ledger-cli syntax (e.g. `10 AAPL {$150} [2024-03-01] ((BUY-2024-01))`).
//
// All three fields are optional; an absent `lot` field on a `Posting` means
// no lot annotation was present on the posting.
message Lot {
  // Per-lot cost basis as a multi-commodity amount. Typically single-commodity
  // (the lot's quote currency) but the schema mirrors `Amount` for symmetry.
  // Corresponds to the `{cost}` annotation form (per-unit cost).
  Amount cost = 1;
  // Optional lot acquisition date in epoch days (matches Transaction.date).
  // Corresponds to the `[date]` annotation form.
  optional sint32 date = 2;
  // Optional free-form lot note. Corresponds to the `((note))` annotation form.
  optional string note = 3;
}

// A posting with a fully evaluated, concrete amount.
message Posting {
  // The canonical account name (after alias resolution). For virtual postings
  // the surrounding markers (`(` `)` or `[` `]`) are stripped -- only the bare
  // account name is stored here; the marker semantics live in `kind`.
  string account = 1;
  // Payee for this posting -- taken from posting-level `payee:` metadata if
  // present, otherwise inherited from the transaction description.
  string payee = 2;
  // The posting amount, keyed by commodity.
  Amount amount = 3;
  // Per-posting state.
  TransactionState state = 4;
  // Bare tags from `; :tag1:tag2:` syntax in posting notes.
  repeated string tags = 5;
  // Key-value metadata from `; key: value` in posting notes.
  map<string, string> metadata = 6;
  // Virtual-posting kind. Defaults to REAL (via UNSPECIFIED = 0) so that
  // older `.dop` files without this field continue to behave as all-real.
  PostingKind kind = 7;
  // Lot annotation (cost basis, acquisition date, free-form note).
  // Absent (proto3 default) means no lot annotation was present.
  optional Lot lot = 8;
}

// A fully evaluated and balanced transaction.
message Transaction {
  // Days since the Unix epoch (1970-01-01 = 0). Negative for pre-epoch dates.
  sint32 date = 1;
  // Optional secondary (effective) date in the same epoch-days format.
  optional sint32 secondary_date = 2;
  // Cleared / pending / uncleared.
  TransactionState state = 3;
  // Optional reference code from the transaction header `( ... )`.
  optional string code = 4;
  // Payee / description.
  string description = 5;
  // Bare tags from `; :tag1:tag2:` syntax in transaction-header notes.
  repeated string tags = 6;
  // Key-value metadata from `; key: value` in transaction-header notes.
  map<string, string> metadata = 7;
  // The resolved postings (all amounts concrete, null posting filled in).
  repeated Posting postings = 8;
}

// Properties of an account declared with an `account` directive.
//
// Note: `assert` / `check` expressions declared on accounts are evaluated
// during elaboration and are not persisted -- they are a compile-time check,
// not runtime data.
//
// Metadata is extracted from `; key: value` notes on the account
// directive (header line or indented note lines) and from `key: value`
// items in the directive body. The map is **denormalised by inheritance**:
// an account's metadata at elaboration time includes every key declared
// on it OR on any of its colon-separated ancestors, with closer
// ancestors winning over distant ones. So a directive like
//     account Income
//         type: R
// makes `Journal.accounts["Income:Salary"].metadata["type"] = "R"` even
// when `Income:Salary` itself has no directive.
//
// The schema deliberately stays generic -- meaning is up to the
// consumer. `type:` is the only key carried by convention (see
// hledger's account-type tag) but no other key is reserved.
message AccountProperties {
  // Free-form note from the `note` sub-directive.
  optional string note = 1;
  // Inherited key-value metadata. See message-level comment for
  // extraction and inheritance semantics.
  map<string, string> metadata = 2;
}

// Display and market-data properties of a commodity declared with a
// `commodity` directive.
message CommodityProperties {
  // Display format string from the `format` sub-directive (e.g. "$1,000.00").
  optional string format = 1;
  // True iff `nomarket` was declared.
  bool no_market = 2;
  // Free-form note from the `note` sub-directive.
  optional string note = 3;
}

// A historical-price entry from a `P` directive.
message HistoricalPrice {
  // Days since the Unix epoch on which this price was recorded.
  sint32 date = 1;
  // Optional wall-clock time of the price quote.
  optional string time = 2;
  // The commodity whose price is being recorded.
  string commodity = 3;
  // The evaluated price of one unit of `commodity`.
  Decimal price = 4;
  // The commodity the price is expressed in.
  string price_commodity = 5;
}

// Top-level elaborated journal. This is the canonical wire shape of the
// `.dop` artifact body. The body follows the 8-byte file header
// (magic + version + reserved).
message Journal {
  // All transactions in source order, with amounts fully evaluated.
  repeated Transaction transactions = 1;
  // Accounts referenced by any posting, keyed by canonical account name.
  map<string, AccountProperties> accounts = 2;
  // Commodity properties from `commodity` directives, keyed by commodity name.
  map<string, CommodityProperties> commodities = 3;
  // Market price quotes from `P` directives, in source order.
  repeated HistoricalPrice prices = 4;
}
