//! `src/compare.zig` - portfolio comparison composition layer.
//!
//! The CLI + TUI shared "compose two points in time" module. Loads a
//! snapshot into a `SnapshotSide` (aggregated per-symbol holdings +
//! liquid total), and provides the aggregation primitive that turns
//! either a parsed snapshot or the live portfolio into the
//! `view.HoldingMap` shape the compare view consumes.
//!
//! Responsibility split:
//!   - `src/history.zig`          - snapshot IO + pure-domain
//!                                   aggregation (`liquidFromSnapshot`,
//!                                   `aggregateSnapshotAllocations` for
//!                                   the projection view)
//!   - `src/compare.zig`          - compare-feature-specific
//!                                   composition: loads a snapshot into
//!                                   a compare-shaped `SnapshotSide`
//!                                   (`aggregateSnapshotStocks`),
//!                                   plus a live-portfolio aggregation
//!                                   mirroring the same shape.
//!                                   Lives here (not in `history.zig`)
//!                                   because its output type is the
//!                                   compare view's `HoldingMap` -
//!                                   moving it would invert layers.
//!   - `src/views/compare.zig`    - pure view model (build CompareView
//!                                   from two holdings maps + totals)
//!   - `src/commands/compare.zig` - CLI dispatch + live-side pipeline
//!                                   + ANSI renderer
//!   - `src/tui/history_tab.zig`  - TUI selection UX + styled renderer
//!
//! This module is intentionally stateless and opinion-free about where
//! the "now" side comes from. The CLI wraps a one-shot live-portfolio
//! fetch into its own private Side type; the TUI uses the App's
//! already-loaded portfolio directly. Both feed the same view model.

const std = @import("std");
const zfin = @import("root.zig");
const history = @import("history.zig");
const snapshot_model = @import("models/snapshot.zig");
const view = @import("views/compare.zig");
const cumulativeSplitRatio = @import("models/split.zig").cumulativeSplitRatio;

pub const Date = zfin.Date;

// ── Snapshot-side loading ────────────────────────────────────

/// A snapshot-backed side of a comparison: aggregated per-symbol
/// holdings + the liquid total for that snapshot, plus the backing
/// `LoadedSnapshot` the map's string keys borrow from.
///
/// Call `deinit` to release everything in the right order (map before
/// snapshot, because the map's keys point into the snapshot bytes).
pub const SnapshotSide = struct {
    map: view.HoldingMap,
    liquid: f64,
    loaded: history.LoadedSnapshot,

    pub fn deinit(self: *SnapshotSide, allocator: std.mem.Allocator) void {
        self.map.deinit();
        self.loaded.deinit(allocator);
    }
};

/// Load the snapshot for `date` from `hist_dir` and build a
/// `SnapshotSide` from its stock lots + liquid total. Returns
/// `error.FileNotFound` if the snapshot doesn't exist; the caller
/// decides how to surface that (CLI prints a "nearest date" hint; the
/// TUI only offers dates that are known to exist so it should never
/// hit this path).
pub fn loadSnapshotSide(
    io: std.Io,
    allocator: std.mem.Allocator,
    hist_dir: []const u8,
    date: Date,
) !SnapshotSide {
    var loaded = try history.loadSnapshotAt(io, allocator, hist_dir, date);
    errdefer loaded.deinit(allocator);

    var map: view.HoldingMap = .init(allocator);
    errdefer map.deinit();
    try aggregateSnapshotStocks(&loaded.snap, &map);

    return .{
        .map = map,
        .liquid = history.liquidFromSnapshot(&loaded.snap),
        .loaded = loaded,
    };
}

// ── Stock aggregation (compare-view shape) ───────────────────

/// Walk a snapshot's lot rows, filter to `security_type == "Stock"`,
/// and group by symbol into `out_map`.
///
/// Shares are the EFFECTIVE (split-adjusted) count, recovered as
/// `value / price` rather than read from the raw `.shares` field. A
/// snapshot stores `.shares` as the immutable as-transacted count but
/// `.value` as the split-adjusted market value (see the "Share model"
/// block in `models/portfolio.zig` and `snapshot.buildSnapshot`), so
/// once a split has been applied at capture, `raw_shares * price` no
/// longer equals the stored value. Deriving `value / price` keeps this
/// side self-consistent (shares x price == value) and in the snapshot's
/// own date-epoch effective basis, which `adjustThenForSplits` then
/// brings forward. For a plain pre-feature snapshot `value == shares x
/// price`, so this degrades exactly to the raw share count.
///
/// Price is taken from the first lot seen (all stock lots of a symbol
/// share the same `price` field in a given snapshot).
///
/// Lives here rather than in `history.zig` because it emits a
/// `view.HoldingMap` - a compare-view-shaped type. The projection-
/// shaped `aggregateSnapshotAllocations` (which emits the lower-level
/// `valuation.Allocation`) lives in `history.zig`.
///
/// `out_map` keys borrow from the snapshot's backing byte buffer.
/// Caller must keep the snapshot alive as long as the map is used.
pub fn aggregateSnapshotStocks(
    snap: *const snapshot_model.Snapshot,
    out_map: *view.HoldingMap,
) !void {
    for (snap.lots) |lot| {
        if (!std.mem.eql(u8, lot.security_type, "Stock")) continue;
        const price = lot.price orelse continue;
        // Effective shares = value / price (split-adjusted). Guard a
        // zero price (bad data) by falling back to the raw count so we
        // never divide by zero.
        const eff_shares = if (price != 0) lot.value / price else lot.shares;
        if (out_map.getPtr(lot.symbol)) |h| {
            h.shares += eff_shares;
            // price is already set from first-seen; leave it.
        } else {
            try out_map.put(lot.symbol, .{ .shares = eff_shares, .price = price });
        }
    }
}

// ── Live-portfolio aggregation ───────────────────────────────

/// Walk the live portfolio's stock lots, group by `priceSymbol()`,
/// and look up the current price from `prices`. Mirrors the snapshot
/// aggregation so the two sides are apples-to-apples.
///
/// `out_map` keys borrow from the portfolio's lot data (via
/// `priceSymbol()`). Caller must keep the portfolio alive as long as
/// the map is used.
pub fn aggregateLiveStocks(
    as_of: zfin.Date,
    portfolio: *const zfin.Portfolio,
    prices: *const std.StringHashMap(f64),
    out_map: *view.HoldingMap,
) !void {
    for (portfolio.lots) |lot| {
        if (lot.security_type != .stock) continue;
        if (!lot.lotIsOpenAsOf(as_of)) continue;
        const sym = lot.priceSymbol();
        const raw_price = prices.get(sym) orelse continue;
        const eff_price = lot.effectivePrice(raw_price, false);
        if (out_map.getPtr(sym)) |h| {
            h.shares += lot.effectiveShares();
        } else {
            try out_map.put(sym, .{ .shares = lot.effectiveShares(), .price = eff_price });
        }
    }
}

/// Opt-in: bring a "then" snapshot map into the "now" split basis so a
/// split between the two compared dates doesn't read as a phantom
/// position change (or a phantom price crash) against the "now" side.
///
/// Both snapshot sides already carry EFFECTIVE shares in their own
/// date-epoch (see `aggregateSnapshotStocks`), so all this does is
/// bridge the epoch gap between `then_date` and `now_date`: it multiplies
/// shares by, and divides price by, the cumulative split ratio in the
/// window `(max(cutover, then_date), now_date]`. Starting at `then_date`
/// (not the cutover) is what makes a snapshot captured AFTER a split a
/// no-op instead of double-applying that split; the cutover only clamps
/// the window so we never reach behind the user's reconcile baseline.
/// A held-throughout position then nets to zero share change.
///
/// Per-symbol: only symbols that carry a `splits_current_through`
/// cutover are adjusted; the rest are left untouched. No-op when nothing
/// has opted in or no split applies. Shared by the CLI `compare` command
/// and the TUI history tab. Only meaningful for a SNAPSHOT "then" side;
/// when "then" is the live portfolio it is already in the current basis,
/// so callers skip it.
///
/// Known limitation: a "then" snapshot from BEFORE the cutover, or a
/// stale pre-feature snapshot that under-recorded value, can't be
/// perfectly reconciled from recorded data - those fall back to
/// cutover-basis / recorded value.
pub fn adjustThenForSplits(
    svc: *zfin.DataService,
    allocator: std.mem.Allocator,
    then_map: *view.HoldingMap,
    portfolio_path: []const u8,
    then_date: zfin.Date,
    now_date: zfin.Date,
) void {
    var cutovers = svc.loadSplitsCutovers(allocator, portfolio_path);
    defer {
        var kit = cutovers.keyIterator();
        while (kit.next()) |k| allocator.free(k.*);
        cutovers.deinit();
    }
    if (cutovers.count() == 0) return;

    var factors = std.StringHashMap(f64).init(allocator);
    defer factors.deinit();
    var it = then_map.iterator();
    while (it.next()) |e| {
        const sym = e.key_ptr.*;
        const cutover = cutovers.get(sym) orelse continue; // per-symbol opt-in
        const fr = svc.getSplits(sym, .{}) catch continue;
        defer fr.deinit();
        // Bring the "then" holdings forward from the snapshot's own date
        // to `now_date`. Start the window at max(cutover, then_date): the
        // snapshot already reflects every split up to then_date, and raw
        // shares are current only through the cutover, so a split before
        // either boundary must not be re-applied. Mirrors enrichSplits'
        // max(cutover, open_date) clamp so the two never diverge.
        const after = if (cutover.lessThan(then_date)) then_date else cutover;
        const f = cumulativeSplitRatio(fr.data, after, now_date);
        if (f != 1.0) factors.put(sym, f) catch continue;
    }
    view.forwardAdjustThen(then_map, &factors);
}

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

const testing = std.testing;

test "aggregateSnapshotStocks: sums shares, filters non-stock, takes first price" {
    var map: view.HoldingMap = .init(testing.allocator);
    defer map.deinit();

    const lots = [_]snapshot_model.LotRow{
        .{
            .symbol = "AAPL",
            .lot_symbol = "AAPL",
            .account = "Roth",
            .security_type = "Stock",
            .shares = 100,
            .open_price = 120,
            .cost_basis = 12000,
            .value = 15000,
            .price = 150.0,
            .quote_date = Date.fromYmd(2024, 3, 15),
        },
        .{
            .symbol = "AAPL",
            .lot_symbol = "AAPL",
            .account = "IRA",
            .security_type = "Stock",
            .shares = 50,
            .open_price = 130,
            .cost_basis = 6500,
            .value = 7500,
            .price = 150.0,
            .quote_date = Date.fromYmd(2024, 3, 15),
        },
        // Cash lot - must be filtered
        .{
            .symbol = "CASH",
            .lot_symbol = "CASH",
            .account = "Roth",
            .security_type = "Cash",
            .shares = 1000,
            .open_price = 0,
            .cost_basis = 1000,
            .value = 1000,
            .price = null,
            .quote_date = null,
        },
        // Another stock
        .{
            .symbol = "MSFT",
            .lot_symbol = "MSFT",
            .account = "Roth",
            .security_type = "Stock",
            .shares = 25,
            .open_price = 380,
            .cost_basis = 9500,
            .value = 10000,
            .price = 400.0,
            .quote_date = Date.fromYmd(2024, 3, 15),
        },
    };
    const snap = snapshot_model.Snapshot{
        .meta = .{
            .snapshot_version = 1,
            .as_of_date = Date.fromYmd(2024, 3, 15),
            .captured_at = 0,
            .zfin_version = "test",
            .stale_count = 0,
        },
        .totals = &.{},
        .tax_types = &.{},
        .accounts = &.{},
        .lots = @constCast(&lots),
    };

    try aggregateSnapshotStocks(&snap, &map);

    try testing.expectEqual(@as(u32, 2), map.count()); // AAPL, MSFT - not CASH
    try testing.expectEqual(@as(f64, 150), (map.get("AAPL") orelse unreachable).shares);
    try testing.expectEqual(@as(f64, 150.0), (map.get("AAPL") orelse unreachable).price);
    try testing.expectEqual(@as(f64, 25), (map.get("MSFT") orelse unreachable).shares);
}

test "aggregateSnapshotStocks: derives effective shares from value/price (split-captured snapshot)" {
    // A snapshot captured with split adjustment on stores RAW shares
    // (100) but the split-adjusted market VALUE (1000 effective shares
    // x $120 = $120,000). The compare map must reflect the effective
    // 1000 shares (value / price), not the raw 100 - otherwise a split
    // reads as a phantom 10x position change against the effective-share
    // "now" side. For a plain (pre-feature) snapshot value == shares x
    // price, so this degrades to raw shares and is backward-compatible.
    var map: view.HoldingMap = .init(testing.allocator);
    defer map.deinit();

    const lots = [_]snapshot_model.LotRow{
        .{
            .symbol = "NVDA",
            .lot_symbol = "NVDA",
            .account = "Sample Brokerage",
            .security_type = "Stock",
            .shares = 100, // RAW, as transacted (immutable historical fact)
            .open_price = 40,
            .cost_basis = 4000,
            .value = 120000, // effective: 1000 shares x $120
            .price = 120.0,
        },
    };
    const snap = snapshot_model.Snapshot{
        .meta = .{
            .snapshot_version = 1,
            .as_of_date = Date.fromYmd(2024, 9, 1),
            .captured_at = 0,
            .zfin_version = "test",
            .stale_count = 0,
        },
        .totals = &.{},
        .tax_types = &.{},
        .accounts = &.{},
        .lots = @constCast(&lots),
    };

    try aggregateSnapshotStocks(&snap, &map);

    const h = map.get("NVDA") orelse return error.TestUnexpectedResult;
    // Effective shares = value / price = 120000 / 120 = 1000, not raw 100.
    try testing.expectApproxEqAbs(@as(f64, 1000), h.shares, 0.001);
    try testing.expectApproxEqAbs(@as(f64, 120.0), h.price, 0.001);
}

test "loadSnapshotSide: happy path builds a SnapshotSide with aggregated holdings" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    const snap_bytes =
        \\#!srfv1
        \\#!created=1777589000
        \\kind::meta,snapshot_version:num:1,as_of_date::2024-03-15,captured_at:num:1777589000,zfin_version::test,stale_count:num:0
        \\kind::total,scope::net_worth,value:num:25000
        \\kind::total,scope::liquid,value:num:25000
        \\kind::total,scope::illiquid,value:num:0
        \\kind::lot,symbol::FOO,lot_symbol::FOO,account::Main,security_type::Stock,shares:num:100,open_price:num:120,cost_basis:num:12000,value:num:15000,price:num:150,quote_date::2024-03-15
        \\kind::lot,symbol::BAR,lot_symbol::BAR,account::Main,security_type::Stock,shares:num:50,open_price:num:180,cost_basis:num:9000,value:num:10000,price:num:200,quote_date::2024-03-15
        \\
    ;
    try tmp.dir.writeFile(io, .{ .sub_path = "2024-03-15-portfolio.srf", .data = snap_bytes });

    const hist_dir = try tmp.dir.realPathFileAlloc(io, ".", testing.allocator);
    defer testing.allocator.free(hist_dir);

    var side = try loadSnapshotSide(std.testing.io, testing.allocator, hist_dir, Date.fromYmd(2024, 3, 15));
    defer side.deinit(testing.allocator);

    try testing.expectEqual(@as(f64, 25000), side.liquid);
    try testing.expectEqual(@as(u32, 2), side.map.count());
    try testing.expectEqual(@as(f64, 150), (side.map.get("FOO") orelse unreachable).price);
    try testing.expectEqual(@as(f64, 50), (side.map.get("BAR") orelse unreachable).shares);
}

test "loadSnapshotSide: missing file propagates FileNotFound" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    const hist_dir = try tmp.dir.realPathFileAlloc(io, ".", testing.allocator);
    defer testing.allocator.free(hist_dir);

    const result = loadSnapshotSide(std.testing.io, testing.allocator, hist_dir, Date.fromYmd(2024, 3, 15));
    try testing.expectError(error.FileNotFound, result);
}

test "aggregateLiveStocks: sums shares for same symbol across accounts" {
    var map: view.HoldingMap = .init(testing.allocator);
    defer map.deinit();

    const today = Date.fromYmd(2026, 5, 8);
    const lots = [_]zfin.Lot{
        .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .account = "Roth" },
        .{ .symbol = "AAPL", .shares = 50, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 160, .account = "IRA" },
        .{ .symbol = "MSFT", .shares = 25, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 350, .account = "Roth" },
    };
    const portfolio: zfin.Portfolio = .{ .lots = @constCast(&lots), .allocator = testing.allocator };

    var prices: std.StringHashMap(f64) = .init(testing.allocator);
    defer prices.deinit();
    try prices.put("AAPL", 200.0);
    try prices.put("MSFT", 400.0);

    try aggregateLiveStocks(today, &portfolio, &prices, &map);

    try testing.expectEqual(@as(u32, 2), map.count());
    const aapl = map.get("AAPL") orelse return error.TestUnexpectedResult;
    try testing.expectApproxEqAbs(@as(f64, 150), aapl.shares, 0.01);
    try testing.expectApproxEqAbs(@as(f64, 200.0), aapl.price, 0.01);
    const msft = map.get("MSFT") orelse return error.TestUnexpectedResult;
    try testing.expectApproxEqAbs(@as(f64, 25), msft.shares, 0.01);
}

test "aggregateLiveStocks: filters out non-stock lots" {
    var map: view.HoldingMap = .init(testing.allocator);
    defer map.deinit();

    const today = Date.fromYmd(2026, 5, 8);
    const lots = [_]zfin.Lot{
        .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .security_type = .stock },
        .{ .symbol = "CASH", .shares = 5000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash },
        .{ .symbol = "VTI", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200, .security_type = .cd, .maturity_date = Date.fromYmd(2027, 1, 1) },
    };
    const portfolio: zfin.Portfolio = .{ .lots = @constCast(&lots), .allocator = testing.allocator };

    var prices: std.StringHashMap(f64) = .init(testing.allocator);
    defer prices.deinit();
    try prices.put("AAPL", 200.0);
    try prices.put("CASH", 1.0);
    try prices.put("VTI", 250.0);

    try aggregateLiveStocks(today, &portfolio, &prices, &map);

    // Only stock lots make it in.
    try testing.expectEqual(@as(u32, 1), map.count());
    try testing.expect(map.get("AAPL") != null);
    try testing.expect(map.get("CASH") == null);
    try testing.expect(map.get("VTI") == null);
}

test "aggregateLiveStocks: excludes lots not yet open as of today" {
    var map: view.HoldingMap = .init(testing.allocator);
    defer map.deinit();

    const today = Date.fromYmd(2024, 6, 1);
    const lots = [_]zfin.Lot{
        // Already open
        .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150 },
        // Not yet bought (open_date is in the future)
        .{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 300 },
        // Sold before today
        .{ .symbol = "GOOG", .shares = 25, .open_date = Date.fromYmd(2023, 1, 1), .open_price = 100, .close_date = Date.fromYmd(2024, 3, 1), .close_price = 150 },
    };
    const portfolio: zfin.Portfolio = .{ .lots = @constCast(&lots), .allocator = testing.allocator };

    var prices: std.StringHashMap(f64) = .init(testing.allocator);
    defer prices.deinit();
    try prices.put("AAPL", 200.0);
    try prices.put("MSFT", 400.0);
    try prices.put("GOOG", 175.0);

    try aggregateLiveStocks(today, &portfolio, &prices, &map);

    try testing.expectEqual(@as(u32, 1), map.count());
    try testing.expect(map.get("AAPL") != null);
}

test "aggregateLiveStocks: skips lots with no price in map" {
    var map: view.HoldingMap = .init(testing.allocator);
    defer map.deinit();

    const today = Date.fromYmd(2026, 5, 8);
    const lots = [_]zfin.Lot{
        .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150 },
        .{ .symbol = "OBSCURE", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 50 },
    };
    const portfolio: zfin.Portfolio = .{ .lots = @constCast(&lots), .allocator = testing.allocator };

    var prices: std.StringHashMap(f64) = .init(testing.allocator);
    defer prices.deinit();
    // Only AAPL has a price; OBSCURE does not.
    try prices.put("AAPL", 200.0);

    try aggregateLiveStocks(today, &portfolio, &prices, &map);

    try testing.expectEqual(@as(u32, 1), map.count());
    try testing.expect(map.get("AAPL") != null);
    try testing.expect(map.get("OBSCURE") == null);
}

test "aggregateLiveStocks: applies price_ratio via effectivePrice" {
    var map: view.HoldingMap = .init(testing.allocator);
    defer map.deinit();

    const today = Date.fromYmd(2026, 5, 8);
    // CUSIP-style lot with price_ratio: raw price * ratio = effective.
    const lots = [_]zfin.Lot{
        .{
            .symbol = "02315N600",
            .shares = 100,
            .open_date = Date.fromYmd(2024, 1, 1),
            .open_price = 140,
            .ticker = "VTTHX",
            .price_ratio = 5.0,
        },
    };
    const portfolio: zfin.Portfolio = .{ .lots = @constCast(&lots), .allocator = testing.allocator };

    var prices: std.StringHashMap(f64) = .init(testing.allocator);
    defer prices.deinit();
    try prices.put("VTTHX", 30.0); // raw price

    try aggregateLiveStocks(today, &portfolio, &prices, &map);

    // priceSymbol() returns "VTTHX" (the ticker), not the CUSIP.
    const h = map.get("VTTHX") orelse return error.TestUnexpectedResult;
    try testing.expectApproxEqAbs(@as(f64, 100), h.shares, 0.01);
    // effective price = raw * price_ratio = 30 * 5 = 150
    try testing.expectApproxEqAbs(@as(f64, 150.0), h.price, 0.01);
}

test "aggregateLiveStocks: empty portfolio yields empty map" {
    var map: view.HoldingMap = .init(testing.allocator);
    defer map.deinit();

    const today = Date.fromYmd(2026, 5, 8);
    const lots = [_]zfin.Lot{};
    const portfolio: zfin.Portfolio = .{ .lots = @constCast(&lots), .allocator = testing.allocator };

    var prices: std.StringHashMap(f64) = .init(testing.allocator);
    defer prices.deinit();

    try aggregateLiveStocks(today, &portfolio, &prices, &map);
    try testing.expectEqual(@as(u32, 0), map.count());
}

test "adjustThenForSplits: forward-adjusts then map from seeded cache + cutover" {
    const allocator = testing.allocator;
    const io = testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // metadata.srf with a per-symbol cutover before the split -> applied.
    try tmp.dir.writeFile(io, .{
        .sub_path = "metadata.srf",
        .data = "#!srfv1\nsymbol::NVDA,splits_current_through::2024-01-01\n",
    });

    var path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
    const dir = path_buf[0..dir_len];

    // Seed NVDA 10:1 (2024-06-10).
    var store = zfin.cache.Store.init(io, allocator, dir);
    var splits = [_]zfin.Split{.{ .date = zfin.Date.fromYmd(2024, 6, 10), .numerator = 10, .denominator = 1 }};
    store.write(zfin.Split, "NVDA", splits[0..], .{ .seconds = zfin.cache.Ttl.splits });

    var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir });
    defer svc.deinit();

    const anchor = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
    defer allocator.free(anchor);

    var then_map: view.HoldingMap = .init(allocator);
    defer then_map.deinit();
    try then_map.put("NVDA", .{ .shares = 100, .price = 600 });

    // then snapshot taken 2024-03-01 (before the 2024-06-10 split);
    // now_date after the split: factor 10 -> 100 -> 1000 shares, $600 -> $60.
    adjustThenForSplits(&svc, allocator, &then_map, anchor, zfin.Date.fromYmd(2024, 3, 1), zfin.Date.fromYmd(2026, 1, 1));
    try testing.expectApproxEqAbs(@as(f64, 1000), then_map.get("NVDA").?.shares, 0.01);
    try testing.expectApproxEqAbs(@as(f64, 60.0), then_map.get("NVDA").?.price, 0.01);
}

test "adjustThenForSplits: no cutover leaves then map untouched" {
    const allocator = testing.allocator;
    const io = testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    // No metadata.srf at all -> opt-in off.

    var path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
    const dir = path_buf[0..dir_len];

    var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir });
    defer svc.deinit();

    const anchor = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
    defer allocator.free(anchor);

    var then_map: view.HoldingMap = .init(allocator);
    defer then_map.deinit();
    try then_map.put("NVDA", .{ .shares = 100, .price = 600 });

    adjustThenForSplits(&svc, allocator, &then_map, anchor, zfin.Date.fromYmd(2024, 3, 1), zfin.Date.fromYmd(2026, 1, 1));
    try testing.expectApproxEqAbs(@as(f64, 100), then_map.get("NVDA").?.shares, 0.01);
    try testing.expectApproxEqAbs(@as(f64, 600.0), then_map.get("NVDA").?.price, 0.01);
}

test "adjustThenForSplits: then snapshot captured after the split is not double-applied" {
    const allocator = testing.allocator;
    const io = testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // Same opt-in + seeded 10:1 split (2024-06-10) as the forward case.
    try tmp.dir.writeFile(io, .{
        .sub_path = "metadata.srf",
        .data = "#!srfv1\nsymbol::NVDA,splits_current_through::2024-01-01\n",
    });

    var path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
    const dir = path_buf[0..dir_len];

    var store = zfin.cache.Store.init(io, allocator, dir);
    var splits = [_]zfin.Split{.{ .date = zfin.Date.fromYmd(2024, 6, 10), .numerator = 10, .denominator = 1 }};
    store.write(zfin.Split, "NVDA", splits[0..], .{ .seconds = zfin.cache.Ttl.splits });

    var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir });
    defer svc.deinit();

    const anchor = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
    defer allocator.free(anchor);

    // A "then" snapshot captured 2024-08-01 - AFTER the split - already
    // holds effective shares (1000) at the post-split price ($120). The
    // split predates this snapshot, so bringing it forward to now must
    // be a no-op: the window starts at the snapshot's own date, not the
    // cutover. Applying the split again would 10x it into a phantom.
    var then_map: view.HoldingMap = .init(allocator);
    defer then_map.deinit();
    try then_map.put("NVDA", .{ .shares = 1000, .price = 120 });

    adjustThenForSplits(&svc, allocator, &then_map, anchor, zfin.Date.fromYmd(2024, 8, 1), zfin.Date.fromYmd(2026, 1, 1));
    try testing.expectApproxEqAbs(@as(f64, 1000), then_map.get("NVDA").?.shares, 0.01);
    try testing.expectApproxEqAbs(@as(f64, 120.0), then_map.get("NVDA").?.price, 0.01);
}
