//! Shared types and helpers for brokerage exports.
//!
//! This file holds the cross-broker shape — the normalized
//! `BrokeragePosition` record and the dollar-string parser every
//! broker needs. Per-broker parsers (`fidelity.zig`, `schwab.zig`)
//! build on top of these.
//!
//! ## Why not reuse `Lot` / `Account` from the portfolio model?
//!
//! Brokerage exports describe positions at a different granularity
//! and identity than zfin's portfolio file:
//!
//! - **Aggregate, not atomic.** A brokerage row says "100 AAPL @
//!   $150 avg cost" — that single row can correspond to N lots
//!   opened on different dates. `Lot` is per-buy; conflating them
//!   would force a synthetic open_date every parser would have to
//!   invent.
//! - **No buy date.** Positions CSVs don't include open_date /
//!   open_price; you'd need a separate transactions export for
//!   that. `Lot` requires both.
//! - **Current value lives on the row.** Brokerage rows carry
//!   `current_value` as fact-from-the-export; lots compute value
//!   from `shares × current_price` at query time.
//! - **Account identity differs.** Brokerage rows reference an
//!   account *number* (the trailing N digits the export shows);
//!   portfolio lots reference the human-readable account *name*.
//!   The whole point of `accounts.srf` is to map between the two.
//!
//! `BrokeragePosition` is intentionally the unmapped, point-in-time
//! shape — exactly what the audit reconciler needs to compare
//! against the portfolio's mapped view.
//!
//! ## Memory & lifetime contract
//!
//! All string fields in records produced by the per-broker parsers
//! are slices INTO the caller's input bytes. The caller must keep
//! the input alive as long as it uses the returned records. The
//! returned slices themselves are heap-allocated against the caller's
//! allocator.

const std = @import("std");

// ── Brokerage position (normalized from any source) ─────────

/// A single position row from a brokerage export, normalized to a
/// common shape so reconcilers and the import command don't need
/// per-broker branching.
pub const BrokeragePosition = struct {
    account_number: []const u8,
    account_name: []const u8,
    symbol: []const u8,
    description: []const u8,
    quantity: ?f64,
    current_value: ?f64,
    cost_basis: ?f64,
    is_cash: bool,
};

// ── Dollar-string parsing ────────────────────────────────────

/// Parse a dollar amount string like "$1,234.56", "+$3,732.40", "-$6,300.00".
/// Strips $, commas, and +/- prefix. Returns null for empty or unparseable values.
///
/// Both Fidelity and Schwab use the same dollar-string format in their
/// exports, so this helper lives at the cross-broker level. The upcoming
/// `import` command also reuses it for synthesizing lots from any
/// brokerage export.
pub fn parseDollarAmount(raw: []const u8) ?f64 {
    const trimmed = std.mem.trim(u8, raw, &.{ ' ', '"' });
    if (trimmed.len == 0) return null;

    // Strip leading +/- and $, remove commas
    var buf: [64]u8 = undefined;
    var pos: usize = 0;
    var negative = false;
    for (trimmed) |c| {
        if (c == '-') {
            negative = true;
        } else if (c == '$' or c == '+' or c == ',') {
            continue;
        } else {
            if (pos >= buf.len) return null;
            buf[pos] = c;
            pos += 1;
        }
    }
    if (pos == 0) return null;

    const val = std.fmt.parseFloat(f64, buf[0..pos]) catch return null;
    return if (negative) -val else val;
}

/// Returns true when both the last price and average cost basis parse
/// to exactly $1.00, indicating a money-market or cash-equivalent
/// position (e.g. FDRXX). Used by the Fidelity parser to catch cash
/// holdings that aren't tagged with the `**` suffix Fidelity normally
/// uses.
pub fn isUnitPriceCash(price_raw: []const u8, cost_raw: []const u8) bool {
    const price = parseDollarAmount(price_raw) orelse return false;
    const cost = parseDollarAmount(cost_raw) orelse return false;
    return price == 1.0 and cost == 1.0;
}

// ── Tests ────────────────────────────────────────────────────

test "parseDollarAmount" {
    try std.testing.expectApproxEqAbs(@as(f64, 1234.56), parseDollarAmount("$1,234.56").?, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 7140.33), parseDollarAmount("$7140.33").?, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 3732.40), parseDollarAmount("+$3,732.40").?, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, -6300.00), parseDollarAmount("-$6,300.00").?, 0.01);
    try std.testing.expect(parseDollarAmount("") == null);
    try std.testing.expect(parseDollarAmount("  ") == null);
    try std.testing.expectApproxEqAbs(@as(f64, 301), parseDollarAmount("301").?, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 2387.616), parseDollarAmount("2387.616").?, 0.001);
}

test "isUnitPriceCash: $1.00 + $1.00 returns true" {
    try std.testing.expect(isUnitPriceCash("$1.00", "$1.00"));
    try std.testing.expect(isUnitPriceCash("1.00", "1.00"));
    try std.testing.expect(isUnitPriceCash("$1", "$1"));
}

test "isUnitPriceCash: non-$1 price returns false" {
    try std.testing.expect(!isUnitPriceCash("$1.01", "$1.00"));
    try std.testing.expect(!isUnitPriceCash("$1.00", "$1.01"));
    try std.testing.expect(!isUnitPriceCash("$150.00", "$120.00"));
    try std.testing.expect(!isUnitPriceCash("$0.99", "$1.00"));
}

test "isUnitPriceCash: unparseable inputs return false" {
    try std.testing.expect(!isUnitPriceCash("", "$1.00"));
    try std.testing.expect(!isUnitPriceCash("$1.00", ""));
    try std.testing.expect(!isUnitPriceCash("N/A", "$1.00"));
}
