//! `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");

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 summed; 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;
        if (out_map.getPtr(lot.symbol)) |h| {
            h.shares += lot.shares;
            // price is already set from first-seen; leave it.
        } else {
            try out_map.put(lot.symbol, .{ .shares = lot.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.shares;
        } else {
            try out_map.put(sym, .{ .shares = lot.shares, .price = eff_price });
        }
    }
}

// ── 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 "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());
}
