//! History IO — read `history/<date>-portfolio.srf` files produced by
//! `zfin snapshot` back into typed `Snapshot` structs. Also the
//! pure-domain aggregation helpers that turn a parsed snapshot into
//! the shapes downstream views consume.
//!
//! Three layers, all pure of rendering concerns:
//!
//! - `parseSnapshotBytes(bytes)` — parse an SRF blob into a `Snapshot`.
//!   The snapshot's string fields slice directly into `bytes`, so the
//!   caller MUST keep that buffer alive as long as the snapshot.
//! - `loadHistoryDir(dir)` — enumerate `*-portfolio.srf` in a directory
//!   and parse each. The returned `LoadedHistory` owns both the
//!   snapshots and their backing byte buffers as matched pairs.
//! - `liquidFromSnapshot(snap)`, `aggregateSnapshotAllocations(...)` —
//!   pure-domain transforms on a parsed snapshot, used by the
//!   projection view. The compare-view-specific aggregator
//!   (`aggregateSnapshotStocks`, producing a view-layer `HoldingMap`)
//!   lives in `src/compare.zig` to avoid inverting the layer direction.
//!
//! The snapshot reader is discriminator-driven: every record must carry
//! a `kind::<meta|total|tax_type|account|lot>` field. Records whose
//! `kind` is set to something this version doesn't recognize are
//! skipped (forward-compatibility). Malformed records — missing `kind`,
//! missing required fields within a known kind, coercion failures — are
//! treated as parse errors, not silently dropped.
//!
//! Lives at `src/history.zig` rather than `src/commands/history.zig`
//! because the IO is used by more than the CLI history command (the
//! rollup builder, future TUI history tab, and any external consumer
//! all go through here). The command module stays a thin CLI wrapper.

const std = @import("std");
const builtin = @import("builtin");
const srf = @import("srf");
const snapshot = @import("models/snapshot.zig");
const Date = @import("Date.zig");
const Candle = @import("models/candle.zig").Candle;
const timeline = @import("analytics/timeline.zig");
const valuation = @import("analytics/valuation.zig");
const imported_values = @import("data/imported_values.zig");

pub const Error = error{
    /// The file didn't open a `#!srfv1` directive or couldn't be
    /// iterated as SRF.
    InvalidSrf,
    /// The file parsed as SRF but had no `kind::meta` record, so we
    /// can't identify it as a snapshot.
    NoMetaRecord,
    /// Allocator returned OOM somewhere during parsing.
    OutOfMemory,
};

/// Suffix that identifies snapshot files in `history/` directory.
pub const snapshot_suffix = "-portfolio.srf";

// ── Single-file parsing ──────────────────────────────────────

/// Parse an SRF blob into a `Snapshot`. The snapshot's string fields
/// borrow directly from `bytes` (zero-copy), so the caller MUST keep
/// `bytes` alive for at least as long as the returned snapshot.
///
/// Typical call pattern:
/// ```
/// const bytes = try readFileAlloc(...);
/// defer allocator.free(bytes);
/// var snap = try parseSnapshotBytes(allocator, bytes);
/// defer snap.deinit(allocator);
/// ```
pub fn parseSnapshotBytes(
    allocator: std.mem.Allocator,
    bytes: []const u8,
) Error!snapshot.Snapshot {
    var reader = std.Io.Reader.fixed(bytes);
    // Default `parse_allocator` (`.none_with_fallback`): short
    // strings borrow from `bytes`; multi-line or comma-bearing
    // values (e.g. an account name with a comma, which `srf.fmt`
    // encodes with a length prefix) land in the iterator's
    // fallback arena and are freed by `it.deinit()`. Snapshot
    // consumers dupe what they need into the caller's allocator
    // before the iterator dies.
    var it = srf.iterator(&reader, allocator, .{ .parse_allocator = .none }) catch return error.InvalidSrf;
    defer it.deinit();

    var meta_opt: ?snapshot.MetaRow = null;
    var totals: std.ArrayList(snapshot.TotalRow) = .empty;
    errdefer totals.deinit(allocator);
    var taxes: std.ArrayList(snapshot.TaxTypeRow) = .empty;
    errdefer taxes.deinit(allocator);
    var accounts: std.ArrayList(snapshot.AccountRow) = .empty;
    errdefer accounts.deinit(allocator);
    var lots: std.ArrayList(snapshot.LotRow) = .empty;
    errdefer lots.deinit(allocator);

    while (it.next() catch return error.InvalidSrf) |field_it| {
        // `to(SnapshotRecord)` reads the `kind` discriminator first, then
        // coerces the remaining fields into the matching variant struct.
        //
        // We skip ONLY `ActiveTagDoesNotExist` — that's the genuine
        // forward-compatibility case (a future snapshot version wrote a
        // record kind we don't know about). Every other srf error
        // indicates malformed data in a record we SHOULD understand, so
        // we propagate it up rather than silently losing rows.
        const rec = field_it.to(SnapshotRecord, .{}) catch |err| switch (err) {
            error.ActiveTagDoesNotExist => continue,
            else => return error.InvalidSrf,
        };
        switch (rec) {
            .meta => |m| {
                if (meta_opt == null) meta_opt = m;
            },
            .total => |r| try totals.append(allocator, r),
            .tax_type => |r| try taxes.append(allocator, r),
            .account => |r| try accounts.append(allocator, r),
            .lot => |r| try lots.append(allocator, r),
        }
    }

    const meta = meta_opt orelse return error.NoMetaRecord;

    return .{
        .meta = meta,
        .totals = try totals.toOwnedSlice(allocator),
        .tax_types = try taxes.toOwnedSlice(allocator),
        .accounts = try accounts.toOwnedSlice(allocator),
        .lots = try lots.toOwnedSlice(allocator),
    };
}

/// Discriminated snapshot record. SRF's `FieldIterator.to(T)` dispatches
/// on the `kind` field (per `srf_tag_field`), consumes it, and then
/// coerces remaining fields into the matching variant struct. Variant
/// names here MUST match the wire-format `kind` values literally.
const SnapshotRecord = union(enum) {
    meta: snapshot.MetaRow,
    total: snapshot.TotalRow,
    tax_type: snapshot.TaxTypeRow,
    account: snapshot.AccountRow,
    lot: snapshot.LotRow,

    pub const srf_tag_field = "kind";
};

// ── Directory loading ────────────────────────────────────────

/// Result of `loadHistoryDir` — caller owns.
///
/// Holds snapshots and their backing byte buffers as parallel slices
/// (same length, matched by index). The buffers are kept alive here
/// because each snapshot borrows strings from its corresponding buffer.
/// `deinit` frees both in the right order.
pub const LoadedHistory = struct {
    snapshots: []snapshot.Snapshot,
    /// Per-snapshot backing buffers, parallel to `snapshots`. Empty
    /// slice when `snapshots` is empty.
    buffers: [][]u8,
    allocator: std.mem.Allocator,

    pub fn deinit(self: *LoadedHistory) void {
        for (self.snapshots) |*s| s.deinit(self.allocator);
        self.allocator.free(self.snapshots);
        for (self.buffers) |b| self.allocator.free(b);
        self.allocator.free(self.buffers);
    }
};

/// Enumerate `*-portfolio.srf` in `history_dir` and parse each into a
/// `Snapshot`. Files that fail to parse are skipped with a stderr
/// warning; callers get back only the ones that loaded cleanly.
///
/// Returned snapshots are in filesystem enumeration order — NOT sorted.
/// Consumers that want chronological order should feed through
/// `analytics.timeline.buildSeries` (which sorts) rather than relying
/// on the loader's order.
pub fn loadHistoryDir(
    io: std.Io,
    allocator: std.mem.Allocator,
    history_dir: []const u8,
) !LoadedHistory {
    var dir = std.Io.Dir.cwd().openDir(io, history_dir, .{ .iterate = true }) catch |err| switch (err) {
        error.FileNotFound => {
            // Missing history dir isn't fatal — it just means no
            // snapshots captured yet.
            return .{ .snapshots = &.{}, .buffers = &.{}, .allocator = allocator };
        },
        else => return err,
    };
    defer dir.close(io);

    var snapshots: std.ArrayList(snapshot.Snapshot) = .empty;
    var buffers: std.ArrayList([]u8) = .empty;
    errdefer {
        for (snapshots.items) |*s| s.deinit(allocator);
        snapshots.deinit(allocator);
        for (buffers.items) |b| allocator.free(b);
        buffers.deinit(allocator);
    }

    var it = dir.iterate();
    while (try it.next(io)) |entry| {
        if (entry.kind != .file) continue;
        if (!std.mem.endsWith(u8, entry.name, snapshot_suffix)) continue;

        const full_path = try std.fs.path.join(allocator, &.{ history_dir, entry.name });
        defer allocator.free(full_path);

        const bytes = std.Io.Dir.cwd().readFileAlloc(io, full_path, allocator, .limited(16 * 1024 * 1024)) catch |err| {
            std.log.warn("history: failed to read {s}: {s}", .{ full_path, @errorName(err) });
            continue;
        };
        // `bytes` is freed either by LoadedHistory.deinit on success or
        // by the branch below on parse failure — no defer-free here.
        const snap = parseSnapshotBytes(allocator, bytes) catch |err| {
            // Tests intentionally feed malformed snapshots to exercise
            // the error path — suppress the warn under `zig build test`
            // so real parse failures stay visible in production runs.
            if (!builtin.is_test) {
                std.log.warn("history: failed to parse {s}: {s}", .{ full_path, @errorName(err) });
            }
            allocator.free(bytes);
            continue;
        };
        try snapshots.append(allocator, snap);
        try buffers.append(allocator, bytes);
    }

    return .{
        .snapshots = try snapshots.toOwnedSlice(allocator),
        .buffers = try buffers.toOwnedSlice(allocator),
        .allocator = allocator,
    };
}

/// Derive `<dirname(portfolio_path)>/history` and return the joined
/// path (caller-owned). Thin helper, but exposed so CLI and TUI agree
/// on the convention (history/ is always a sibling of portfolio.srf).
pub fn deriveHistoryDir(
    allocator: std.mem.Allocator,
    portfolio_path: []const u8,
) ![]u8 {
    const portfolio_dir = std.fs.path.dirname(portfolio_path) orelse ".";
    return std.fs.path.join(allocator, &.{ portfolio_dir, "history" });
}

/// Result of `loadTimeline` — bundles the raw snapshot collection and
/// the derived timeline series so callers can reach either without
/// re-parsing.
///
/// `series.points` is sorted ascending by date; `loaded.snapshots` is
/// in filesystem enumeration order. Both are kept alive together —
/// `series.points` references dates that live inside `loaded`'s
/// snapshot rows, and the callers may want `loaded.snapshots` directly
/// for non-timeline uses (e.g. rollup building).
pub const LoadedTimeline = struct {
    loaded: LoadedHistory,
    series: timeline.TimelineSeries,
    /// Directory we loaded from, caller-owned. Carried through for
    /// callers that want to print diagnostics or locate sibling files
    /// (rollup.srf, etc.).
    history_dir: []u8,
    allocator: std.mem.Allocator,

    pub fn deinit(self: *LoadedTimeline) void {
        self.series.deinit();
        self.loaded.deinit();
        self.allocator.free(self.history_dir);
    }
};

/// End-to-end snapshot timeline loader: derives history/, reads every
/// `*-portfolio.srf` file, and builds the sorted timeline series. The
/// single entry point used by both the CLI `zfin history` command and
/// the TUI history tab — their earlier copies had subtle divergences
/// (different dir-split logic, slightly different empty-state ordering)
/// that a shared helper rules out.
///
/// Returns `loaded.snapshots.len == 0` on an empty history dir rather
/// than erroring; callers check and produce their own "no snapshots"
/// message. Parse failures on individual files are logged to stderr by
/// `loadHistoryDir` and the offending file is skipped.
pub fn loadTimeline(
    io: std.Io,
    allocator: std.mem.Allocator,
    portfolio_path: []const u8,
) !LoadedTimeline {
    const history_dir = try deriveHistoryDir(allocator, portfolio_path);
    errdefer allocator.free(history_dir);

    var loaded = try loadHistoryDir(io, allocator, history_dir);
    errdefer loaded.deinit();

    // Merge in imported_values.srf, if present. Missing file is
    // not an error — produces an empty merge.
    const iv_path = try std.fs.path.join(allocator, &.{ history_dir, "imported_values.srf" });
    defer allocator.free(iv_path);

    var iv = try imported_values.loadImportedValues(io, allocator, iv_path);
    defer iv.deinit();

    // Translate to the analytics-layer's minimal type so timeline.zig
    // doesn't need to import data/imported_values.zig.
    const adapted = try allocator.alloc(timeline.ImportedHistoryPoint, iv.points.len);
    defer allocator.free(adapted);
    for (iv.points, 0..) |p, i| {
        adapted[i] = .{ .date = p.date, .liquid = p.liquid };
    }

    const series = try timeline.buildMergedSeries(allocator, loaded.snapshots, adapted);

    return .{
        .loaded = loaded,
        .series = series,
        .history_dir = history_dir,
        .allocator = allocator,
    };
}

// ── Single-snapshot loading ──────────────────────────────────

/// Owned snapshot + its backing byte buffer. Strings inside `snap`
/// borrow from `bytes`, so the two must be freed in order: snap first
/// (releases its arrays), then bytes (releases the string storage).
/// `deinit` handles the order.
pub const LoadedSnapshot = struct {
    snap: snapshot.Snapshot,
    bytes: []u8,

    pub fn deinit(self: *LoadedSnapshot, allocator: std.mem.Allocator) void {
        self.snap.deinit(allocator);
        allocator.free(self.bytes);
    }
};

/// Read + parse `history_dir/<date>-portfolio.srf`. Returns
/// `error.FileNotFound` if the exact file doesn't exist; the caller is
/// responsible for deciding how to surface that (CLI prints a
/// suggestion via `findNearestSnapshot`, TUI just won't offer missing
/// dates as selectable rows).
pub fn loadSnapshotAt(
    io: std.Io,
    allocator: std.mem.Allocator,
    hist_dir: []const u8,
    target: Date,
) !LoadedSnapshot {
    var date_buf: [10]u8 = undefined;
    const date_str = try std.fmt.bufPrint(&date_buf, "{f}", .{target});
    const filename = try std.fmt.allocPrint(allocator, "{s}{s}", .{ date_str, snapshot_suffix });
    defer allocator.free(filename);
    const full_path = try std.fs.path.join(allocator, &.{ hist_dir, filename });
    defer allocator.free(full_path);

    const bytes = try std.Io.Dir.cwd().readFileAlloc(io, full_path, allocator, .limited(16 * 1024 * 1024));
    errdefer allocator.free(bytes);

    const snap = try parseSnapshotBytes(allocator, bytes);
    return .{ .snap = snap, .bytes = bytes };
}

/// Nearest-snapshot search result. `earlier` and `later` are
/// independently null if no snapshot exists on that side of `target`.
pub const Nearest = struct {
    earlier: ?Date,
    later: ?Date,
};

/// Scan `history_dir` for `YYYY-MM-DD-portfolio.srf` filenames and
/// return the closest date strictly earlier than `target` and the
/// closest date strictly later than `target`. Files whose name doesn't
/// parse as an ISO date + the snapshot suffix are ignored.
///
/// Pure function — no stderr side effects. CLI callers that want to
/// print a "no snapshot for X; nearest is Y" hint compose this with
/// their own output pass.
pub fn findNearestSnapshot(
    io: std.Io,
    history_dir: []const u8,
    target: Date,
) !Nearest {
    var dir = std.Io.Dir.cwd().openDir(io, history_dir, .{ .iterate = true }) catch |err| switch (err) {
        error.FileNotFound => return .{ .earlier = null, .later = null },
        else => return err,
    };
    defer dir.close(io);

    var earlier: ?Date = null;
    var later: ?Date = null;

    var it = dir.iterate();
    while (try it.next(io)) |entry| {
        if (entry.kind != .file) continue;
        if (!std.mem.endsWith(u8, entry.name, snapshot_suffix)) continue;
        const expected_len = 10 + snapshot_suffix.len;
        if (entry.name.len != expected_len) continue;
        const d = Date.parse(entry.name[0..10]) catch continue;

        if (d.days < target.days) {
            if (earlier == null or d.days > earlier.?.days) earlier = d;
        } else if (d.days > target.days) {
            if (later == null or d.days < later.?.days) later = d;
        }
        // Exact hit (d == target) is ignored — this function only reports
        // neighbors. Callers with an exact match use loadSnapshotAt.
    }

    return .{ .earlier = earlier, .later = later };
}

/// Result of resolving a requested snapshot date against the history
/// directory. `exact` is true when the requested date had its own
/// snapshot file; false when we auto-snapped to the nearest earlier.
pub const ResolvedSnapshot = struct {
    requested: Date,
    actual: Date,
    exact: bool,
};

pub const ResolveSnapshotError = error{
    /// No snapshot file exists at or before the requested date.
    NoSnapshotAtOrBefore,
} || std.mem.Allocator.Error || std.Io.Dir.AccessError || std.Io.File.OpenError;

/// Source of an as-of resolution: a native snapshot file, or an
/// `imported_values.srf` row. The snapshot path is preferred (higher
/// fidelity) and consulted first; only when no snapshot exists at or
/// before the requested date do we look at imported_values.
pub const AsOfSourceKind = enum { snapshot, imported };

/// Result of `resolveAsOfDate` — a unified resolver that consults
/// both the native snapshot directory and `imported_values.srf`.
pub const ResolvedAsOf = struct {
    requested: Date,
    actual: Date,
    exact: bool,
    source: AsOfSourceKind,
    /// Liquid total at `actual`. For `.snapshot` this is filled by
    /// the caller after loading the snapshot file (left at 0 here);
    /// for `.imported` this is the value from the imported_values
    /// row directly.
    liquid: f64 = 0,
};

pub const ResolveAsOfError = error{
    /// Neither a snapshot nor an imported_values row exists at or
    /// before the requested date.
    NoDataAtOrBefore,
};

/// Resolve a requested as-of date against `hist_dir`, accepting
/// either a native snapshot OR an imported_values row.
///
///   1. Look up nearest-earlier snapshot via the existing
///      `resolveSnapshotDate`. If found → return `.snapshot`.
///   2. Otherwise read `<hist_dir>/imported_values.srf` and find the
///      latest row whose date is `<= requested`. If found → return
///      `.imported` with that liquid.
///   3. Otherwise → `error.NoDataAtOrBefore`.
///
/// When BOTH sources have a hit at the same date, snapshot wins
/// (higher fidelity). When the snapshot is older than the imported
/// row, the snapshot still wins (we prefer literal lot-level state
/// over a scaled approximation when both are available within the
/// requested window).
///
/// Returns `anyerror` rather than a tight error set because
/// `loadImportedValues` pulls in the full file-read error universe
/// (network filesystems, etc.) and the caller usually surfaces all
/// failures via `@errorName` anyway.
pub fn resolveAsOfDate(
    io: std.Io,
    arena: std.mem.Allocator,
    hist_dir: []const u8,
    requested: Date,
) anyerror!ResolvedAsOf {
    // Try the snapshot path first. Catch only the "no snapshot"
    // error; surface IO / OOM unchanged.
    const snap_attempt = resolveSnapshotDate(io, arena, hist_dir, requested);
    if (snap_attempt) |snap| {
        return .{
            .requested = snap.requested,
            .actual = snap.actual,
            .exact = snap.exact,
            .source = .snapshot,
        };
    } else |err| switch (err) {
        error.NoSnapshotAtOrBefore => {}, // fall through to imported
        else => |e| return e,
    }

    // No snapshot at-or-before. Try imported_values.
    const iv_path = try std.fs.path.join(arena, &.{ hist_dir, "imported_values.srf" });
    var iv = imported_values.loadImportedValues(io, arena, iv_path) catch |err| switch (err) {
        // Treat parse errors the same as "no data" — the file is
        // there but unusable. The full timeline-load path will log
        // a more detailed error; we just gracefully degrade here.
        error.InvalidSrf, error.DuplicateDate, error.NotSorted => return error.NoDataAtOrBefore,
        else => |e| return e,
    };
    defer iv.deinit();

    // Walk ascending list to find the latest row <= requested.
    // (`points` is guaranteed ascending by `parseImportedValues`.)
    var match: ?imported_values.HistoryPoint = null;
    for (iv.points) |p| {
        if (p.date.days <= requested.days) {
            match = p;
        } else {
            break;
        }
    }

    if (match) |m| {
        return .{
            .requested = requested,
            .actual = m.date,
            .exact = m.date.eql(requested),
            .source = .imported,
            .liquid = m.liquid,
        };
    }

    return error.NoDataAtOrBefore;
}

/// Resolve a requested snapshot date against `hist_dir`:
///   - If `hist_dir/<requested>-portfolio.srf` exists, return it as
///     an exact match.
///   - Otherwise, look up the nearest earlier snapshot via
///     `findNearestSnapshot`. Return it as an inexact match.
///   - If nothing exists at or before `requested`, return
///     `error.NoSnapshotAtOrBefore` — the caller decides how to
///     surface that to the user (CLI: stderr; TUI: status bar).
///
/// Shared between the CLI (`zfin projections --as-of <DATE>`) and TUI
/// (projections tab date popup) to avoid duplicating the
/// exact-then-fallback resolution logic.
///
/// Uses `arena` for the two small intermediate strings (filename,
/// full path). Pass a short-lived arena; the returned struct has no
/// borrowed references.
pub fn resolveSnapshotDate(
    io: std.Io,
    arena: std.mem.Allocator,
    hist_dir: []const u8,
    requested: Date,
) ResolveSnapshotError!ResolvedSnapshot {
    var date_buf: [10]u8 = undefined;
    // SAFETY: 10-byte buffer is exactly the size of "YYYY-MM-DD".
    const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{requested}) catch date_buf[0..];
    const filename = try std.fmt.allocPrint(arena, "{s}{s}", .{ date_str, snapshot_suffix });
    const full_path = try std.fs.path.join(arena, &.{ hist_dir, filename });

    std.Io.Dir.cwd().access(io, full_path, .{}) catch |err| switch (err) {
        error.FileNotFound => {
            const nearest = try findNearestSnapshot(io, hist_dir, requested);
            if (nearest.earlier) |earlier| {
                return .{ .requested = requested, .actual = earlier, .exact = false };
            }
            return error.NoSnapshotAtOrBefore;
        },
        else => |e| return e,
    };

    return .{ .requested = requested, .actual = requested, .exact = true };
}

// ── Pure-domain aggregation ───────────────────────────────────

/// Return the prefix of `candles` whose dates are `<= as_of`.
///
/// When `as_of` is null, returns the full slice unchanged (live mode
/// pass-through). When set, binary-searches for the first index
/// strictly after `as_of` and slices up to it. Zero-length slice
/// when `as_of` precedes all cached candles.
///
/// Candles are assumed sorted by date ascending. Used to truncate
/// benchmark and per-symbol price history for historical projections —
/// `performance.trailingReturns` uses the last candle's date as the
/// endpoint, so trimming the tail is equivalent to "compute as of
/// that date".
pub fn sliceCandlesAsOf(candles: []const Candle, as_of: ?Date) []const Candle {
    const d = as_of orelse return candles;
    if (candles.len == 0) return candles;
    var lo: usize = 0;
    var hi: usize = candles.len;
    while (lo < hi) {
        const mid = lo + (hi - lo) / 2;
        const cd = candles[mid].date;
        if (cd.lessThan(d) or cd.eql(d)) {
            lo = mid + 1;
        } else {
            hi = mid;
        }
    }
    return candles[0..lo];
}

/// Find the `scope=="liquid"` total in a snapshot. Returns 0.0 if not
/// present (old snapshots from before the liquid/illiquid split —
/// shouldn't happen in practice).
pub fn liquidFromSnapshot(snap: *const snapshot.Snapshot) f64 {
    for (snap.totals) |t| {
        if (std.mem.eql(u8, t.scope, "liquid")) return t.value;
    }
    return 0.0;
}

/// Per-symbol allocations derived from a snapshot's lot rows, plus
/// the totals needed to feed `benchmark.deriveAllocationSplit`.
///
/// String fields inside `allocations` (`symbol`, `display_symbol`)
/// borrow from the snapshot's backing buffer. Keep the snapshot (and
/// its bytes) alive for the lifetime of these allocations.
pub const SnapshotAllocations = struct {
    allocations: []valuation.Allocation,
    total_value: f64,
    cash_value: f64,
    cd_value: f64,

    /// Free the `allocations` slice. `alloc` MUST be the same allocator
    /// passed to `aggregateSnapshotAllocations` — the slice is owned
    /// by that allocator, not tracked internally.
    pub fn deinit(self: *SnapshotAllocations, alloc: std.mem.Allocator) void {
        alloc.free(self.allocations);
    }
};

/// Aggregate a snapshot's `LotRow`s into per-symbol `Allocation`s.
///
/// Matches the lot aggregation that `valuation.portfolioSummary` does
/// for live portfolios: sum `value` per `symbol`, compute weight
/// against the snapshot's `liquid` total. Non-stock lots contribute
/// to `cash_value` / `cd_value` instead of the allocation list.
///
/// Security-type strings come from `LotType.label()` in the snapshot
/// writer — "Stock", "Cash", "CD", "Option", "Illiquid". Match is
/// case-sensitive, consistent with `aggregateSnapshotStocks` in
/// `src/compare.zig`.
///
/// The returned `Allocation`s only populate `symbol`, `display_symbol`,
/// `market_value`, and `weight` — every other field is zero. This is
/// enough for `deriveAllocationSplit` and the per-position trailing
/// returns loop; nothing downstream reads cost basis or shares here.
pub fn aggregateSnapshotAllocations(
    alloc: std.mem.Allocator,
    snap: *const snapshot.Snapshot,
) !SnapshotAllocations {
    const total_value = liquidFromSnapshot(snap);

    var map = std.StringHashMap(f64).init(alloc);
    defer map.deinit();
    var cash_value: f64 = 0;
    var cd_value: f64 = 0;

    for (snap.lots) |lot| {
        if (std.mem.eql(u8, lot.security_type, "Cash")) {
            cash_value += lot.value;
            continue;
        }
        if (std.mem.eql(u8, lot.security_type, "CD")) {
            cd_value += lot.value;
            continue;
        }
        if (std.mem.eql(u8, lot.security_type, "Illiquid")) {
            // Illiquid lots aren't in the liquid total and don't feed
            // benchmark projections. Skip.
            continue;
        }
        // Stock + Option: aggregate by `symbol`. For stocks the
        // snapshot writer stores `symbol = lot.priceSymbol()` (the
        // pricing ticker used for cache lookups, e.g. "BRK-B"),
        // distinct from `lot_symbol` which preserves the user's
        // original form (e.g. "BRK.B"). For options, `symbol` is the
        // contract identifier — options won't have candles in the
        // cache, so they're silently dropped from the per-position
        // trailing returns loop downstream; they still count toward
        // total market value and allocation weight.
        const gop = try map.getOrPut(lot.symbol);
        if (gop.found_existing) {
            gop.value_ptr.* += lot.value;
        } else {
            gop.value_ptr.* = lot.value;
        }
    }

    var allocations = try alloc.alloc(valuation.Allocation, map.count());
    errdefer alloc.free(allocations);

    var i: usize = 0;
    var it = map.iterator();
    while (it.next()) |e| : (i += 1) {
        const mv = e.value_ptr.*;
        allocations[i] = .{
            .symbol = e.key_ptr.*,
            .display_symbol = e.key_ptr.*,
            .shares = 0,
            .avg_cost = 0,
            .current_price = 0,
            .market_value = mv,
            .cost_basis = 0,
            .weight = if (total_value > 0) mv / total_value else 0,
            .unrealized_gain_loss = 0,
            .unrealized_return = 0.0,
        };
    }

    return .{
        .allocations = allocations,
        .total_value = total_value,
        .cash_value = cash_value,
        .cd_value = cd_value,
    };
}

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

const testing = std.testing;

/// Test helper: dupe a string literal into testing.allocator and parse
/// it. Returns the snapshot AND the owned bytes so the test can free
/// both in the correct order (snapshot first, then bytes).
const ParsedLiteral = struct {
    snap: snapshot.Snapshot,
    bytes: []u8,

    fn deinit(self: *ParsedLiteral) void {
        self.snap.deinit(testing.allocator);
        testing.allocator.free(self.bytes);
    }
};

fn parseLiteral(input: []const u8) !ParsedLiteral {
    const bytes = try testing.allocator.dupe(u8, input);
    errdefer testing.allocator.free(bytes);
    const snap = try parseSnapshotBytes(testing.allocator, bytes);
    return .{ .snap = snap, .bytes = bytes };
}

test "parseSnapshotBytes: minimal meta + totals round-trip" {
    const input =
        \\#!srfv1
        \\#!created=1700000000
        \\kind::meta,snapshot_version:num:1,as_of_date::2026-04-17,captured_at:num:1700000000,zfin_version::v0.1.0,stale_count:num:0
        \\kind::total,scope::net_worth,value:num:1000
        \\kind::total,scope::liquid,value:num:800
        \\kind::total,scope::illiquid,value:num:200
        \\
    ;
    var parsed = try parseLiteral(input);
    defer parsed.deinit();
    const snap = parsed.snap;
    // Note: `snap.meta.kind` is `""` post-parse — the `kind` discriminator
    // is consumed by union dispatch (see `SnapshotRecord`). The union tag
    // is the source of truth for record type, not `.kind`.
    try testing.expectEqual(@as(u32, 1), snap.meta.snapshot_version);
    try testing.expect(snap.meta.as_of_date.eql(Date.fromYmd(2026, 4, 17)));
    try testing.expectEqualStrings("v0.1.0", snap.meta.zfin_version);
    try testing.expectEqual(@as(i64, 1_700_000_000), snap.meta.captured_at);
    try testing.expectEqual(@as(usize, 0), snap.meta.stale_count);
    try testing.expect(snap.meta.quote_date_min == null);
    try testing.expect(snap.meta.quote_date_max == null);

    try testing.expectEqual(@as(usize, 3), snap.totals.len);
    try testing.expectEqualStrings("net_worth", snap.totals[0].scope);
    try testing.expectEqual(@as(f64, 1000), snap.totals[0].value);
    try testing.expectEqualStrings("liquid", snap.totals[1].scope);
    try testing.expectEqual(@as(f64, 800), snap.totals[1].value);
    try testing.expectEqualStrings("illiquid", snap.totals[2].scope);
    try testing.expectEqual(@as(f64, 200), snap.totals[2].value);

    try testing.expectEqual(@as(usize, 0), snap.tax_types.len);
    try testing.expectEqual(@as(usize, 0), snap.accounts.len);
    try testing.expectEqual(@as(usize, 0), snap.lots.len);
}

test "parseSnapshotBytes: with tax_type, account, and lot records" {
    const input =
        \\#!srfv1
        \\kind::meta,snapshot_version:num:1,as_of_date::2026-04-17,captured_at:num:0,zfin_version::x,stale_count:num:0
        \\kind::total,scope::net_worth,value:num:1500
        \\kind::tax_type,label::Taxable,value:num:1000
        \\kind::tax_type,label::Roth (Post-Tax),value:num:500
        \\kind::account,name::Sample Roth,value:num:800
        \\kind::lot,symbol::VTI,lot_symbol::VTI,account::Sample Roth,security_type::Stock,shares:num:10,open_price:num:200,cost_basis:num:2000,value:num:2500,price:num:250,quote_date::2026-04-17
        \\
    ;
    var parsed = try parseLiteral(input);
    defer parsed.deinit();
    const snap = parsed.snap;

    try testing.expectEqual(@as(usize, 2), snap.tax_types.len);
    try testing.expectEqualStrings("Taxable", snap.tax_types[0].label);
    try testing.expectEqualStrings("Roth (Post-Tax)", snap.tax_types[1].label);

    try testing.expectEqual(@as(usize, 1), snap.accounts.len);
    try testing.expectEqualStrings("Sample Roth", snap.accounts[0].name);
    try testing.expectEqual(@as(f64, 800), snap.accounts[0].value);

    try testing.expectEqual(@as(usize, 1), snap.lots.len);
    try testing.expectEqualStrings("VTI", snap.lots[0].symbol);
    try testing.expect(snap.lots[0].price != null);
    try testing.expectEqual(@as(f64, 250), snap.lots[0].price.?);
    try testing.expect(snap.lots[0].quote_date != null);
    try testing.expect(snap.lots[0].quote_date.?.eql(Date.fromYmd(2026, 4, 17)));
    try testing.expect(!snap.lots[0].quote_stale);
}

test "parseSnapshotBytes: lot with stale flag and optional price absent" {
    const input =
        \\#!srfv1
        \\kind::meta,snapshot_version:num:1,as_of_date::2026-04-17,captured_at:num:0,zfin_version::x,stale_count:num:1
        \\kind::lot,symbol::CASH,lot_symbol::CASH,account::Checking,security_type::Cash,shares:num:500,open_price:num:0,cost_basis:num:0,value:num:500
        \\kind::lot,symbol::OLDQ,lot_symbol::OLDQ,account::IRA,security_type::Stock,shares:num:1,open_price:num:100,cost_basis:num:100,value:num:95,price:num:95,quote_date::2026-04-15,quote_stale:bool:true
        \\
    ;
    var parsed = try parseLiteral(input);
    defer parsed.deinit();
    const snap = parsed.snap;

    try testing.expectEqual(@as(usize, 2), snap.lots.len);
    // Cash: no price, no quote_date, no stale flag
    try testing.expect(snap.lots[0].price == null);
    try testing.expect(snap.lots[0].quote_date == null);
    try testing.expect(!snap.lots[0].quote_stale);
    // Stale stock lot
    try testing.expect(snap.lots[1].quote_stale);
    try testing.expect(snap.lots[1].quote_date != null);
    try testing.expect(snap.lots[1].quote_date.?.eql(Date.fromYmd(2026, 4, 15)));
}

test "parseSnapshotBytes: quote_date_min/max in meta round-trip" {
    const input =
        \\#!srfv1
        \\kind::meta,snapshot_version:num:1,as_of_date::2026-04-17,captured_at:num:0,zfin_version::x,quote_date_min::2026-04-14,quote_date_max::2026-04-17,stale_count:num:3
        \\
    ;
    var parsed = try parseLiteral(input);
    defer parsed.deinit();
    const snap = parsed.snap;

    try testing.expect(snap.meta.quote_date_min != null);
    try testing.expect(snap.meta.quote_date_min.?.eql(Date.fromYmd(2026, 4, 14)));
    try testing.expect(snap.meta.quote_date_max != null);
    try testing.expect(snap.meta.quote_date_max.?.eql(Date.fromYmd(2026, 4, 17)));
    try testing.expectEqual(@as(usize, 3), snap.meta.stale_count);
}

test "parseSnapshotBytes: unknown kind is silently skipped" {
    const input =
        \\#!srfv1
        \\kind::meta,snapshot_version:num:1,as_of_date::2026-04-17,captured_at:num:0,zfin_version::x,stale_count:num:0
        \\kind::future_extension,some_field::some_value
        \\kind::total,scope::net_worth,value:num:100
        \\
    ;
    var parsed = try parseLiteral(input);
    defer parsed.deinit();
    const snap = parsed.snap;
    try testing.expectEqual(@as(usize, 1), snap.totals.len);
}

test "parseSnapshotBytes: record without kind field is a parse error" {
    // A record missing the `kind` discriminator is malformed data, not
    // forward-compat. We must not silently drop it.
    const input =
        \\#!srfv1
        \\kind::meta,snapshot_version:num:1,as_of_date::2026-04-17,captured_at:num:0,zfin_version::x,stale_count:num:0
        \\random_field::random_value
        \\kind::total,scope::net_worth,value:num:100
        \\
    ;
    try testing.expectError(error.InvalidSrf, parseLiteral(input));
}

test "parseSnapshotBytes: missing meta record returns error" {
    const input =
        \\#!srfv1
        \\kind::total,scope::net_worth,value:num:100
        \\
    ;
    try testing.expectError(error.NoMetaRecord, parseLiteral(input));
}

test "parseSnapshotBytes: totally malformed input returns error" {
    // Not valid srf at all.
    const input = "this is not srf data\x00\xff\x00";
    const result = parseLiteral(input);
    // Either InvalidSrf (iterator failed) or NoMetaRecord (iterator
    // returned nothing). Both are acceptable failure modes; the test
    // just asserts we don't panic or succeed.
    try testing.expect(std.meta.isError(result));
}

test "loadHistoryDir: missing directory returns empty result" {
    // No dir created; should silently yield an empty list rather than
    // raising FileNotFound to the caller.
    var result = try loadHistoryDir(std.testing.io, testing.allocator, "/nonexistent/path/for/testing");
    defer result.deinit();
    try testing.expectEqual(@as(usize, 0), result.snapshots.len);
}

test "loadHistoryDir: loads snapshots and skips non-matching files" {
    const io = std.testing.io;
    var tmp_dir = testing.tmpDir(.{});
    defer tmp_dir.cleanup();

    // Seed three files:
    //   2026-04-17-portfolio.srf — valid
    //   2026-04-18-portfolio.srf — valid
    //   readme.txt              — non-matching extension, should be skipped
    const snap_bytes =
        \\#!srfv1
        \\kind::meta,snapshot_version:num:1,as_of_date::2026-04-17,captured_at:num:0,zfin_version::x,stale_count:num:0
        \\kind::total,scope::net_worth,value:num:1000
        \\
    ;
    const snap2_bytes =
        \\#!srfv1
        \\kind::meta,snapshot_version:num:1,as_of_date::2026-04-18,captured_at:num:0,zfin_version::x,stale_count:num:0
        \\kind::total,scope::net_worth,value:num:1100
        \\
    ;
    try tmp_dir.dir.writeFile(io, .{ .sub_path = "2026-04-17-portfolio.srf", .data = snap_bytes });
    try tmp_dir.dir.writeFile(io, .{ .sub_path = "2026-04-18-portfolio.srf", .data = snap2_bytes });
    try tmp_dir.dir.writeFile(io, .{ .sub_path = "readme.txt", .data = "not a snapshot" });

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

    var result = try loadHistoryDir(std.testing.io, testing.allocator, dir_path);
    defer result.deinit();

    try testing.expectEqual(@as(usize, 2), result.snapshots.len);

    // Each loaded snapshot has a meta and one total. Values differ so we
    // can tell them apart regardless of filesystem enumeration order.
    var saw_1000 = false;
    var saw_1100 = false;
    for (result.snapshots) |s| {
        try testing.expectEqual(@as(usize, 1), s.totals.len);
        if (s.totals[0].value == 1000) saw_1000 = true;
        if (s.totals[0].value == 1100) saw_1100 = true;
    }
    try testing.expect(saw_1000);
    try testing.expect(saw_1100);
}

test "loadHistoryDir: corrupt files are skipped, others still load" {
    const io = std.testing.io;
    var tmp_dir = testing.tmpDir(.{});
    defer tmp_dir.cleanup();

    const good_bytes =
        \\#!srfv1
        \\kind::meta,snapshot_version:num:1,as_of_date::2026-04-17,captured_at:num:0,zfin_version::x,stale_count:num:0
        \\
    ;
    try tmp_dir.dir.writeFile(io, .{ .sub_path = "2026-04-17-portfolio.srf", .data = good_bytes });
    try tmp_dir.dir.writeFile(io, .{ .sub_path = "2026-04-18-portfolio.srf", .data = "totally-not-srf\n" });

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

    var result = try loadHistoryDir(std.testing.io, testing.allocator, dir_path);
    defer result.deinit();

    // Only the good one lands.
    try testing.expectEqual(@as(usize, 1), result.snapshots.len);
}

// ── findNearestSnapshot / loadSnapshotAt tests ─────────────────

test "findNearestSnapshot: empty dir returns both null" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

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

    const result = try findNearestSnapshot(std.testing.io, path, Date.fromYmd(2024, 3, 15));
    try testing.expectEqual(@as(?Date, null), result.earlier);
    try testing.expectEqual(@as(?Date, null), result.later);
}

test "findNearestSnapshot: non-existent dir returns both null" {
    const result = try findNearestSnapshot(
        std.testing.io,
        "/tmp/zfin-history-nearest-never-exists-91823",
        Date.fromYmd(2024, 3, 15),
    );
    try testing.expectEqual(@as(?Date, null), result.earlier);
    try testing.expectEqual(@as(?Date, null), result.later);
}

test "findNearestSnapshot: earlier and later around gap" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    try tmp.dir.writeFile(io, .{ .sub_path = "2024-03-10-portfolio.srf", .data = "" });
    try tmp.dir.writeFile(io, .{ .sub_path = "2024-03-12-portfolio.srf", .data = "" });
    try tmp.dir.writeFile(io, .{ .sub_path = "2024-03-15-portfolio.srf", .data = "" });
    try tmp.dir.writeFile(io, .{ .sub_path = "2024-03-20-portfolio.srf", .data = "" });
    // Noise files that should be ignored.
    try tmp.dir.writeFile(io, .{ .sub_path = "random.txt", .data = "" });
    try tmp.dir.writeFile(io, .{ .sub_path = "rollup.srf", .data = "" });
    try tmp.dir.writeFile(io, .{ .sub_path = "bogus-date-portfolio.srf", .data = "" });

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

    const result = try findNearestSnapshot(std.testing.io, path, Date.fromYmd(2024, 3, 14));
    try testing.expect(result.earlier != null);
    try testing.expect(result.later != null);
    try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 12).days), result.earlier.?.days);
    try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 15).days), result.later.?.days);
}

test "findNearestSnapshot: before earliest — only later set" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    try tmp.dir.writeFile(io, .{ .sub_path = "2024-03-10-portfolio.srf", .data = "" });
    try tmp.dir.writeFile(io, .{ .sub_path = "2024-03-12-portfolio.srf", .data = "" });

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

    const result = try findNearestSnapshot(std.testing.io, path, Date.fromYmd(2024, 1, 1));
    try testing.expectEqual(@as(?Date, null), result.earlier);
    try testing.expect(result.later != null);
    try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 10).days), result.later.?.days);
}

test "findNearestSnapshot: after latest — only earlier set" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    try tmp.dir.writeFile(io, .{ .sub_path = "2024-03-10-portfolio.srf", .data = "" });
    try tmp.dir.writeFile(io, .{ .sub_path = "2024-03-12-portfolio.srf", .data = "" });

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

    const result = try findNearestSnapshot(std.testing.io, path, Date.fromYmd(2025, 1, 1));
    try testing.expect(result.earlier != null);
    try testing.expectEqual(@as(?Date, null), result.later);
    try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 12).days), result.earlier.?.days);
}

test "findNearestSnapshot: target hits a file exactly — returns neighbors, not self" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    try tmp.dir.writeFile(io, .{ .sub_path = "2024-03-10-portfolio.srf", .data = "" });
    try tmp.dir.writeFile(io, .{ .sub_path = "2024-03-12-portfolio.srf", .data = "" });
    try tmp.dir.writeFile(io, .{ .sub_path = "2024-03-15-portfolio.srf", .data = "" });

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

    const result = try findNearestSnapshot(std.testing.io, path, Date.fromYmd(2024, 3, 12));
    try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 10).days), result.earlier.?.days);
    try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 15).days), result.later.?.days);
}

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

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

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

test "loadSnapshotAt: happy path loads and parses" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // Minimal valid snapshot
    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::liquid,value:num:1000
        \\kind::total,scope::net_worth,value:num:1000
        \\kind::total,scope::illiquid,value:num:0
        \\
    ;
    try tmp.dir.writeFile(io, .{ .sub_path = "2024-03-15-portfolio.srf", .data = snap_bytes });

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

    var loaded = try loadSnapshotAt(io, testing.allocator, path, Date.fromYmd(2024, 3, 15));
    defer loaded.deinit(testing.allocator);

    try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 15).days), loaded.snap.meta.as_of_date.days);
    try testing.expectEqual(@as(usize, 3), loaded.snap.totals.len);
}

// ── Aggregation tests ─────────────────────────────────────────

test "liquidFromSnapshot: finds liquid scope" {
    const totals = [_]snapshot.TotalRow{
        .{ .scope = "net_worth", .value = 1100 },
        .{ .scope = "liquid", .value = 1000 },
        .{ .scope = "illiquid", .value = 100 },
    };
    const snap = snapshot.Snapshot{
        .meta = .{
            .snapshot_version = 1,
            .as_of_date = Date.fromYmd(2024, 3, 15),
            .captured_at = 0,
            .zfin_version = "test",
            .stale_count = 0,
        },
        .totals = @constCast(&totals),
        .tax_types = &.{},
        .accounts = &.{},
        .lots = &.{},
    };
    try testing.expectEqual(@as(f64, 1000), liquidFromSnapshot(&snap));
}

test "liquidFromSnapshot: returns 0 when no liquid scope" {
    const totals = [_]snapshot.TotalRow{
        .{ .scope = "net_worth", .value = 1100 },
    };
    const snap = snapshot.Snapshot{
        .meta = .{
            .snapshot_version = 1,
            .as_of_date = Date.fromYmd(2024, 3, 15),
            .captured_at = 0,
            .zfin_version = "test",
            .stale_count = 0,
        },
        .totals = @constCast(&totals),
        .tax_types = &.{},
        .accounts = &.{},
        .lots = &.{},
    };
    try testing.expectEqual(@as(f64, 0.0), liquidFromSnapshot(&snap));
}

test "aggregateSnapshotAllocations: stocks grouped, cash and CD separated" {
    var lots = [_]snapshot.LotRow{
        .{
            .kind = "lot",
            .symbol = "AAPL",
            .lot_symbol = "AAPL",
            .account = "Brokerage",
            .security_type = "Stock",
            .shares = 100,
            .open_price = 120,
            .cost_basis = 12_000,
            .value = 15_000,
        },
        .{
            .kind = "lot",
            .symbol = "AAPL",
            .lot_symbol = "AAPL",
            .account = "Roth",
            .security_type = "Stock",
            .shares = 50,
            .open_price = 150,
            .cost_basis = 7_500,
            .value = 7_500,
        },
        .{
            .kind = "lot",
            .symbol = "CASH",
            .lot_symbol = "CASH",
            .account = "Brokerage",
            .security_type = "Cash",
            .shares = 10_000,
            .open_price = 1,
            .cost_basis = 10_000,
            .value = 10_000,
        },
        .{
            .kind = "lot",
            .symbol = "CD-1Y",
            .lot_symbol = "CD-1Y",
            .account = "Roth",
            .security_type = "CD",
            .shares = 50_000,
            .open_price = 1,
            .cost_basis = 50_000,
            .value = 50_000,
        },
        // Illiquid lots get skipped entirely — they aren't in the
        // liquid total and don't affect benchmark projections.
        .{
            .kind = "lot",
            .symbol = "House",
            .lot_symbol = "House",
            .account = "Joint",
            .security_type = "Illiquid",
            .shares = 1,
            .open_price = 500_000,
            .cost_basis = 500_000,
            .value = 500_000,
        },
    };
    var totals = [_]snapshot.TotalRow{
        .{ .kind = "total", .scope = "liquid", .value = 82_500 },
        .{ .kind = "total", .scope = "net_worth", .value = 582_500 },
    };
    const snap: snapshot.Snapshot = .{
        .meta = .{
            .kind = "meta",
            .snapshot_version = 1,
            .as_of_date = Date.fromYmd(2026, 4, 2),
            .captured_at = 0,
            .zfin_version = "test",
            .stale_count = 0,
        },
        .totals = &totals,
        .tax_types = &.{},
        .accounts = &.{},
        .lots = &lots,
    };

    var sa = try aggregateSnapshotAllocations(testing.allocator, &snap);
    defer sa.deinit(testing.allocator);

    // One aggregated AAPL row (two lots merged), no illiquid row.
    try testing.expectEqual(@as(usize, 1), sa.allocations.len);
    try testing.expectEqualStrings("AAPL", sa.allocations[0].symbol);
    try testing.expectApproxEqAbs(@as(f64, 22_500), sa.allocations[0].market_value, 0.01);
    // weight = 22,500 / 82,500 ≈ 0.2727
    try testing.expectApproxEqAbs(@as(f64, 22_500.0 / 82_500.0), sa.allocations[0].weight, 0.0001);

    try testing.expectApproxEqAbs(@as(f64, 82_500), sa.total_value, 0.01);
    try testing.expectApproxEqAbs(@as(f64, 10_000), sa.cash_value, 0.01);
    try testing.expectApproxEqAbs(@as(f64, 50_000), sa.cd_value, 0.01);
}

test "aggregateSnapshotAllocations: no liquid total defaults to zero weights" {
    // If the snapshot somehow lacks a `liquid` row, the function
    // should still succeed — weights just come out as 0.
    var lots = [_]snapshot.LotRow{
        .{
            .kind = "lot",
            .symbol = "AAPL",
            .lot_symbol = "AAPL",
            .account = "A",
            .security_type = "Stock",
            .shares = 1,
            .open_price = 150,
            .cost_basis = 150,
            .value = 150,
        },
    };
    const snap: snapshot.Snapshot = .{
        .meta = .{
            .kind = "meta",
            .snapshot_version = 1,
            .as_of_date = Date.fromYmd(2026, 4, 2),
            .captured_at = 0,
            .zfin_version = "test",
            .stale_count = 0,
        },
        .totals = &.{},
        .tax_types = &.{},
        .accounts = &.{},
        .lots = &lots,
    };

    var sa = try aggregateSnapshotAllocations(testing.allocator, &snap);
    defer sa.deinit(testing.allocator);

    try testing.expectEqual(@as(usize, 1), sa.allocations.len);
    try testing.expectEqual(@as(f64, 0), sa.allocations[0].weight);
    try testing.expectEqual(@as(f64, 0), sa.total_value);
}

test "aggregateSnapshotAllocations: aggregates by `symbol` (pricing), not `lot_symbol`" {
    // Snapshot writer stores `symbol = priceSymbol()` (e.g. "BRK-B")
    // and `lot_symbol = lot.symbol` (user's form, e.g. "BRK.B"). The
    // candle cache is keyed by the pricing symbol, so aggregation
    // must use `symbol` to match downstream `getCachedCandles` lookups.
    //
    // This test constructs two lots with the same `symbol` (pricing)
    // but different `lot_symbol` values — they should collapse into a
    // single allocation.
    var lots = [_]snapshot.LotRow{
        .{
            .kind = "lot",
            .symbol = "BRK-B",
            .lot_symbol = "BRK.B",
            .account = "Brokerage",
            .security_type = "Stock",
            .shares = 10,
            .open_price = 400,
            .cost_basis = 4_000,
            .value = 4_500,
        },
        .{
            .kind = "lot",
            .symbol = "BRK-B",
            .lot_symbol = "BRK-B",
            .account = "Roth",
            .security_type = "Stock",
            .shares = 5,
            .open_price = 430,
            .cost_basis = 2_150,
            .value = 2_250,
        },
    };
    var totals = [_]snapshot.TotalRow{
        .{ .kind = "total", .scope = "liquid", .value = 6_750 },
    };
    const snap: snapshot.Snapshot = .{
        .meta = .{
            .kind = "meta",
            .snapshot_version = 1,
            .as_of_date = Date.fromYmd(2026, 4, 2),
            .captured_at = 0,
            .zfin_version = "test",
            .stale_count = 0,
        },
        .totals = &totals,
        .tax_types = &.{},
        .accounts = &.{},
        .lots = &lots,
    };

    var sa = try aggregateSnapshotAllocations(testing.allocator, &snap);
    defer sa.deinit(testing.allocator);

    // Single entry — two lots merged by pricing symbol "BRK-B".
    try testing.expectEqual(@as(usize, 1), sa.allocations.len);
    try testing.expectEqualStrings("BRK-B", sa.allocations[0].symbol);
    try testing.expectApproxEqAbs(@as(f64, 6_750), sa.allocations[0].market_value, 0.01);
}

// ── sliceCandlesAsOf tests ────────────────────────────────────

fn makeTestCandle(y: i16, m: u8, d: u8, close: f64) Candle {
    return .{
        .date = Date.fromYmd(y, m, d),
        .open = close,
        .high = close,
        .low = close,
        .close = close,
        .adj_close = close,
        .volume = 0,
    };
}

test "sliceCandlesAsOf: null as_of returns everything" {
    const candles = [_]Candle{
        makeTestCandle(2024, 1, 1, 100),
        makeTestCandle(2024, 1, 2, 101),
    };
    const sliced = sliceCandlesAsOf(&candles, null);
    try testing.expectEqual(@as(usize, 2), sliced.len);
}

test "sliceCandlesAsOf: empty input" {
    const candles = [_]Candle{};
    const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2024, 1, 1));
    try testing.expectEqual(@as(usize, 0), sliced.len);
}

test "sliceCandlesAsOf: empty input with null as_of" {
    const candles = [_]Candle{};
    const sliced = sliceCandlesAsOf(&candles, null);
    try testing.expectEqual(@as(usize, 0), sliced.len);
}

test "sliceCandlesAsOf: exact date match included" {
    const candles = [_]Candle{
        makeTestCandle(2024, 1, 1, 100),
        makeTestCandle(2024, 1, 2, 101),
        makeTestCandle(2024, 1, 3, 102),
        makeTestCandle(2024, 1, 4, 103),
    };
    const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2024, 1, 2));
    try testing.expectEqual(@as(usize, 2), sliced.len);
    try testing.expectApproxEqAbs(@as(f64, 101), sliced[sliced.len - 1].close, 0.001);
}

test "sliceCandlesAsOf: no exact match snaps to earlier" {
    const candles = [_]Candle{
        makeTestCandle(2024, 1, 1, 100),
        makeTestCandle(2024, 1, 3, 102), // gap — no candle on the 2nd
        makeTestCandle(2024, 1, 4, 103),
    };
    // Asking for Jan 2 returns everything through Jan 1 (nothing at/after Jan 2).
    const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2024, 1, 2));
    try testing.expectEqual(@as(usize, 1), sliced.len);
    try testing.expectApproxEqAbs(@as(f64, 100), sliced[0].close, 0.001);
}

test "sliceCandlesAsOf: as_of before all candles returns empty" {
    const candles = [_]Candle{
        makeTestCandle(2024, 1, 1, 100),
        makeTestCandle(2024, 1, 2, 101),
    };
    const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2023, 12, 31));
    try testing.expectEqual(@as(usize, 0), sliced.len);
}

test "sliceCandlesAsOf: as_of after all candles returns everything" {
    const candles = [_]Candle{
        makeTestCandle(2024, 1, 1, 100),
        makeTestCandle(2024, 1, 2, 101),
    };
    const sliced = sliceCandlesAsOf(&candles, Date.fromYmd(2026, 1, 1));
    try testing.expectEqual(@as(usize, 2), sliced.len);
}

// ── resolveSnapshotDate tests ─────────────────────────────────

test "resolveSnapshotDate: exact match returns exact=true" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(io, .{ .sub_path = "2024-03-15-portfolio.srf", .data = "" });

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

    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();

    const resolved = try resolveSnapshotDate(io, arena.allocator(), hist_dir, Date.fromYmd(2024, 3, 15));
    try testing.expect(resolved.exact);
    try testing.expectEqual(Date.fromYmd(2024, 3, 15).days, resolved.actual.days);
    try testing.expectEqual(Date.fromYmd(2024, 3, 15).days, resolved.requested.days);
}

test "resolveSnapshotDate: no exact match snaps to nearest earlier" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(io, .{ .sub_path = "2024-03-10-portfolio.srf", .data = "" });
    try tmp.dir.writeFile(io, .{ .sub_path = "2024-03-20-portfolio.srf", .data = "" });

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

    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();

    const resolved = try resolveSnapshotDate(io, arena.allocator(), hist_dir, Date.fromYmd(2024, 3, 15));
    try testing.expect(!resolved.exact);
    try testing.expectEqual(Date.fromYmd(2024, 3, 10).days, resolved.actual.days);
    try testing.expectEqual(Date.fromYmd(2024, 3, 15).days, resolved.requested.days);
}

test "resolveSnapshotDate: no earlier snapshot returns NoSnapshotAtOrBefore" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    // Only a later snapshot — can't satisfy a request for an earlier date.
    try tmp.dir.writeFile(io, .{ .sub_path = "2024-04-01-portfolio.srf", .data = "" });

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

    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();

    const result = resolveSnapshotDate(io, arena.allocator(), hist_dir, Date.fromYmd(2024, 3, 15));
    try testing.expectError(error.NoSnapshotAtOrBefore, result);
}

test "resolveSnapshotDate: empty history dir returns NoSnapshotAtOrBefore" {
    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);

    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();

    const result = resolveSnapshotDate(io, arena.allocator(), hist_dir, Date.fromYmd(2024, 3, 15));
    try testing.expectError(error.NoSnapshotAtOrBefore, result);
}

// ── resolveAsOfDate (snapshot OR imported) ────────────────────

test "resolveAsOfDate: snapshot-only history resolves to snapshot" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(io, .{ .sub_path = "2024-04-01-portfolio.srf", .data = "" });

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

    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();

    const r = try resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2024, 4, 1));
    try testing.expectEqual(AsOfSourceKind.snapshot, r.source);
    try testing.expect(r.exact);
    try testing.expectEqual(@as(f64, 0), r.liquid);
}

test "resolveAsOfDate: imported-only falls back to imported_values" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    const iv_data =
        \\#!srfv1
        \\date::2016-01-03,liquid:num:1500000.00
        \\date::2016-01-10,liquid:num:1510000.00
        \\date::2016-01-17,liquid:num:1520000.00
        \\
    ;
    try tmp.dir.writeFile(io, .{ .sub_path = "imported_values.srf", .data = iv_data });

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

    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();

    // Request a date between rows — should snap to the latest <= date.
    const r = try resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2016, 1, 14));
    try testing.expectEqual(AsOfSourceKind.imported, r.source);
    try testing.expect(!r.exact);
    try testing.expectEqual(@as(i32, Date.fromYmd(2016, 1, 10).days), r.actual.days);
    try testing.expectEqual(@as(f64, 1_510_000), r.liquid);
}

test "resolveAsOfDate: snapshot wins over imported when both present" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(io, .{ .sub_path = "2024-04-01-portfolio.srf", .data = "" });
    const iv_data =
        \\#!srfv1
        \\date::2024-04-01,liquid:num:9999999.00
        \\
    ;
    try tmp.dir.writeFile(io, .{ .sub_path = "imported_values.srf", .data = iv_data });

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

    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();

    const r = try resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2024, 4, 1));
    try testing.expectEqual(AsOfSourceKind.snapshot, r.source);
}

test "resolveAsOfDate: requested predates all data returns NoDataAtOrBefore" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    const iv_data =
        \\#!srfv1
        \\date::2016-01-03,liquid:num:1500000.00
        \\
    ;
    try tmp.dir.writeFile(io, .{ .sub_path = "imported_values.srf", .data = iv_data });

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

    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();

    const result = resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2015, 1, 1));
    try testing.expectError(error.NoDataAtOrBefore, result);
}

test "resolveAsOfDate: empty history dir returns NoDataAtOrBefore" {
    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);

    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();

    const result = resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2024, 3, 15));
    try testing.expectError(error.NoDataAtOrBefore, result);
}

test "resolveAsOfDate: snapshot at later date but imported earlier — snapshot wins (different dates)" {
    // Edge: the imported date is older than the snapshot date AND
    // the requested date matches the snapshot exactly. Snapshot
    // wins (exact match path).
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(io, .{ .sub_path = "2024-04-01-portfolio.srf", .data = "" });
    const iv_data =
        \\#!srfv1
        \\date::2016-01-03,liquid:num:1500000.00
        \\date::2024-03-01,liquid:num:2000000.00
        \\
    ;
    try tmp.dir.writeFile(io, .{ .sub_path = "imported_values.srf", .data = iv_data });

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

    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();

    // Request the snapshot date — exact snapshot hit.
    const r = try resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2024, 4, 1));
    try testing.expectEqual(AsOfSourceKind.snapshot, r.source);
    try testing.expect(r.exact);

    // Request a date between the two imported rows but BEFORE the
    // snapshot — should fall through to imported (no snapshot
    // at/before that date).
    const r2 = try resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2020, 6, 15));
    try testing.expectEqual(AsOfSourceKind.imported, r2.source);
    try testing.expectEqual(@as(i32, Date.fromYmd(2016, 1, 3).days), r2.actual.days);
}
