//! Portfolio file loading + per-portfolio data pipeline.
//!
//! Single home for "read N portfolio_*.srf files and merge them into
//! one Portfolio for both surfaces (CLI commands, TUI App)." Both
//! surfaces import this module directly; neither depends on the
//! other for portfolio loading. Pre-extraction, the same logic
//! lived in `commands/common.zig` and the TUI either called into
//! that file (which had a "TUI calls into commands/" code smell)
//! or - worse - rolled its own parallel single-file path that
//! drifted from the CLI's multi-file logic.
//!
//! The split is meaningful in only one direction: this module knows
//! about pattern resolution (via `commands/framework.resolvePatterns`)
//! and the `cache` deserializer. It does NOT know about RunCtx,
//! Globals, or any CLI-shape concerns. The CLI-specific
//! `loadPortfolio(ctx, as_of)` convenience wrapper that bridges
//! a `RunCtx` to `loadPortfolioFromConfig` lives in
//! `commands/common.zig` where it belongs.
//!
//! ## Surface
//!
//! - `LoadedPortfolio` - merged Portfolio + computed positions/syms
//!   + the resolved path slice the lots came from. Carries an
//!   `anchor()` accessor for sibling-file derivation
//!   (`accounts.srf`, `metadata.srf`, history dir).
//!
//! - `loadPortfolioFromConfig(io, alloc, config, patterns, as_of)`
//!   - the workhorse. Resolves `-p` patterns through
//!   `framework.resolvePatterns`, reads + deserializes + merges,
//!   returns a fully-populated `LoadedPortfolio`. Used by the
//!   CLI (via `commands.common.loadPortfolio` wrapping it with a
//!   `RunCtx`) and directly by the TUI.
//!
//! - `loadPortfolioFromPaths(io, alloc, paths, as_of)` - caller
//!   has already resolved patterns; load the given files. Used by
//!   the TUI's reload-button path (re-uses the original resolved
//!   path slice without re-globbing).
//!
//! - `loadPortfolioFromPathsAtRev(io, alloc, paths, rev, as_of)` -
//!   git-historical variant: read each file at `rev` via
//!   `git.show` instead of from the working tree. Files that don't
//!   exist at the rev are silently skipped (the union just doesn't
//!   include those lots). Used by `zfin snapshot --as-of` to
//!   capture point-in-time portfolio state across a multi-file
//!   glob from git history.
//!
//! All three loaders share a single private `loadFromBytes` core
//! that does the deserialize + union-merge + position-compute +
//! symbol-extract pass. Tests target `loadFromBytes` directly with
//! synthetic byte literals to avoid filesystem I/O.
//!
//! - `PortfolioData` + `buildPortfolioData(...)` - second-stage
//!   pipeline: turn a `LoadedPortfolio` (or its parts) plus a
//!   `prices` map into a `PortfolioSummary` with allocations,
//!   candle map, and historical snapshots.

const std = @import("std");
const zfin = @import("root.zig");
const framework = @import("commands/framework.zig");
const stderr = @import("stderr.zig");
const git = @import("git.zig");
const test_git = @import("testutil/git.zig");
const enrichSplits = @import("models/portfolio.zig").enrichSplits;

// ── Portfolio loading ────────────────────────────────────────

/// Result of loading and parsing one or more portfolio files. The
/// returned `portfolio` holds the union of all lots across every
/// resolved file; `positions` and `syms` are computed against that
/// merged view. Caller must call deinit().
pub const LoadedPortfolio = struct {
    /// Resolved paths the lots came from, sorted lexicographically
    /// (by `Config.resolveUserFiles`). `paths[0]` is the *anchor*
    /// path used for sibling-file derivation (`accounts.srf`,
    /// `metadata.srf`, `transaction_log.srf`, history dir).
    /// Display labels typically render `paths[0]` plus
    /// "(+N more)" when `paths.len > 1`. Owned.
    paths: []const []const u8,
    /// Optional `ResolvedPaths` handle for the same set of paths.
    /// When the loader resolved patterns through `RunCtx`, the
    /// `Config.ResolvedPaths` is captured here so `deinit()` can
    /// release the owned path strings. When the loader was given
    /// pre-resolved paths directly (test path, snapshot fallback),
    /// this is null and the `paths` slice is shallow-copied bytes
    /// the caller still owns.
    resolved_paths: ?zfin.Config.ResolvedPaths,
    /// Raw bytes of every file we read. One entry per portfolio
    /// file. Owned.
    file_datas: []const []const u8,
    portfolio: zfin.Portfolio,
    positions: []const zfin.Position,
    syms: []const []const u8,

    pub fn deinit(self: *LoadedPortfolio, allocator: std.mem.Allocator) void {
        allocator.free(self.syms);
        allocator.free(self.positions);
        self.portfolio.deinit();
        for (self.file_datas) |d| allocator.free(d);
        allocator.free(self.file_datas);
        // Path-string ownership: `resolved_paths` (if present) owns
        // the underlying path strings. The `paths` slice is the
        // borrowed view; free only its outer storage.
        allocator.free(self.paths);
        if (self.resolved_paths) |rp| rp.deinit();
    }

    /// Convenience: returns `paths[0]`, the first / anchor path.
    /// Sibling-file derivation (accounts.srf, metadata.srf, etc.)
    /// hangs off this directory.
    pub fn anchor(self: LoadedPortfolio) []const u8 {
        return self.paths[0];
    }
};

/// Resolve `patterns` against `config` (cwd -> ZFIN_HOME), then load
/// the union of all matched portfolio files. The TUI uses this
/// directly (no `RunCtx`); CLI commands go through
/// `commands.common.loadPortfolio(ctx, ...)` which is a thin
/// wrapper.
///
/// `patterns` is the user-supplied `-p` slice; pass an empty slice
/// (`&.{}`) for the default `portfolio*.srf` behavior.
///
/// Returns `null` on any error path (a stderr message has already
/// been printed). Caller must `deinit(allocator)` the returned
/// struct.
pub fn loadPortfolioFromConfig(
    io: std.Io,
    allocator: std.mem.Allocator,
    config: zfin.Config,
    patterns: []const []const u8,
    as_of: zfin.Date,
) ?LoadedPortfolio {
    var resolved = framework.resolvePatterns(io, allocator, config, patterns) catch |err| switch (err) {
        error.MixedPortfolioDirs => {
            stderr.print(io, "Error: portfolio files resolved to multiple directories.\n");
            stderr.print(io, "       Sibling files (accounts.srf, metadata.srf, transaction_log.srf) live\n");
            stderr.print(io, "       next to the portfolio, so all portfolio files must share a directory.\n");
            return null;
        },
        else => {
            stderr.print(io, "Error: failed to resolve portfolio path(s)\n");
            return null;
        },
    };
    if (resolved.paths.len == 0) {
        resolved.deinit();
        // The error message names the searched location explicitly
        // so the user can verify it against their expectations.
        // ZFIN_HOME is exclusive when set: we never look at cwd
        // in that case, so the message would be misleading if it
        // mentioned cwd as a possibility.
        if (config.zfin_home) |home| {
            var msg_buf: [512]u8 = undefined;
            const msg = std.fmt.bufPrint(&msg_buf, "Error: no portfolio file found in ZFIN_HOME ({s}). Looked for portfolio*.srf.\n", .{home}) catch "Error: no portfolio file found in ZFIN_HOME\n";
            stderr.print(io, msg);
        } else {
            stderr.print(io, "Error: no portfolio file found in cwd. Looked for portfolio*.srf. (ZFIN_HOME is unset.)\n");
        }
        return null;
    }
    // Snapshot the path-string view as our own owned slice. Backing
    // strings stay live as long as `resolved.inner` does - we
    // hand `inner` off to LoadedPortfolio (it'll be freed by
    // `LoadedPortfolio.deinit`). The framework-level `resolved.paths`
    // view slice is allocator-owned but redundant after the dupe;
    // free it before discarding the wrapper.
    const paths_owned = allocator.dupe([]const u8, resolved.paths) catch {
        resolved.deinit();
        return null;
    };
    allocator.free(resolved.paths);
    return loadFromPaths(io, allocator, paths_owned, resolved.inner, as_of);
}

/// Lower-level loader: caller has already resolved the path list and
/// owns the path strings. Used by the TUI's manual reload (re-loads
/// the same files without re-globbing) and by tests.
///
/// Strings inside `paths` are NOT freed by `LoadedPortfolio.deinit`
/// - caller retains ownership of them. The slice `paths` itself IS
/// freed by deinit (the LoadedPortfolio takes ownership of just the
/// slice).
pub fn loadPortfolioFromPaths(io: std.Io, allocator: std.mem.Allocator, paths: []const []const u8, as_of: zfin.Date) ?LoadedPortfolio {
    if (paths.len == 0) {
        stderr.print(io, "Error: No portfolio file found\n");
        return null;
    }
    // Dupe the slice so deinit can free it without touching the
    // caller's storage. Path strings remain caller-owned and are
    // borrowed by the returned struct (resolved_paths = null
    // signals "no Config.ResolvedPaths to deinit").
    const paths_owned = allocator.dupe([]const u8, paths) catch return null;
    return loadFromPaths(io, allocator, paths_owned, null, as_of);
}

/// Like `loadPortfolioFromPaths`, but reads each file from a git
/// revision instead of the working tree. The result is the union
/// of all files at that rev, deserialized + union-merged the same
/// way the live loader does.
///
/// Files that don't exist at the requested rev (e.g.
/// `portfolio_other.srf` was added later than `rev`) are silently
/// skipped - the union just doesn't include those lots. This is the
/// right behavior for `zfin snapshot --as-of <past-date>` against a
/// portfolio that's been split into multiple files over time.
///
/// All `paths` must live within the same git repository as the
/// first path. Repository discovery uses `git.findRepo` on the
/// first path; the rest derive their rel-paths by trimming the
/// repo-root prefix.
///
/// Returns null on:
/// - empty paths slice (no portfolio file to load);
/// - first path not in any git repo;
/// - any deserialize / merge / position-compute failure (same as
///   the working-tree loader).
///
/// `git.show` failures other than `PathMissingInRev` (e.g.
/// `UnknownRevision`) propagate as null; the function prints a
/// stderr message describing which file/rev failed.
pub fn loadPortfolioFromPathsAtRev(
    io: std.Io,
    allocator: std.mem.Allocator,
    env: *const std.process.Environ.Map,
    paths: []const []const u8,
    rev: []const u8,
    as_of: zfin.Date,
) ?LoadedPortfolio {
    if (paths.len == 0) {
        stderr.print(io, "Error: No portfolio file found\n");
        return null;
    }

    // Discover the repo from the first path. All other paths are
    // assumed to live in the same repo (typical for sibling
    // portfolio files in the same directory); we derive their
    // rel-paths below.
    const info = git.findRepo(io, allocator, env, paths[0]) catch {
        var msg_buf: [512]u8 = undefined;
        const msg = std.fmt.bufPrint(&msg_buf, "Error: portfolio path not in a git repo: {s}\n", .{paths[0]}) catch "Error: portfolio path not in a git repo\n";
        stderr.print(io, msg);
        return null;
    };
    defer allocator.free(info.root);
    defer allocator.free(info.rel_path);

    // Read each file at `rev`. On `PathMissingInRev` we substitute
    // empty bytes; `loadFromBytes` knows to treat that as "file
    // absent at this rev, skip without parsing." Any other git
    // error is fatal.
    var datas: std.ArrayList([]const u8) = .empty;
    var datas_consumed = false;
    defer if (!datas_consumed) {
        for (datas.items) |d| allocator.free(d);
        datas.deinit(allocator);
    };

    for (paths) |abs_path| {
        // Derive rel_path from this file's absolute path. The first
        // file's rel_path was computed by findRepo; for the rest we
        // strip the repo root prefix manually. realpath ensures
        // canonicalization (the user could pass a non-canonical
        // path).
        const real = std.Io.Dir.cwd().realPathFileAlloc(io, abs_path, allocator) catch {
            var msg_buf: [512]u8 = undefined;
            const msg = std.fmt.bufPrint(&msg_buf, "Error: cannot resolve real path for: {s}\n", .{abs_path}) catch "Error: cannot resolve real path\n";
            stderr.print(io, msg);
            return null;
        };
        defer allocator.free(real);

        const rel = if (std.mem.startsWith(u8, real, info.root) and real.len > info.root.len)
            std.mem.trimStart(u8, real[info.root.len..], "/")
        else
            std.fs.path.basename(real);

        const data = git.show(io, allocator, env, info.root, rev, rel) catch |err| switch (err) {
            error.PathMissingInRev => empty_blk: {
                // File didn't exist at this rev. Substitute empty
                // bytes; loadFromBytes skips empty entries.
                break :empty_blk allocator.dupe(u8, "") catch return null;
            },
            else => {
                var msg_buf: [512]u8 = undefined;
                const msg = std.fmt.bufPrint(&msg_buf, "Error: git show {s}:{s}: {t}\n", .{ rev, rel, err }) catch "Error: git show failed\n";
                stderr.print(io, msg);
                return null;
            },
        };
        datas.append(allocator, data) catch {
            allocator.free(data);
            return null;
        };
    }

    const datas_owned = datas.toOwnedSlice(allocator) catch return null;
    datas_consumed = true;

    // Hand off ownership of the slice we just dupe'd from the
    // caller. `loadFromBytes` frees both `paths_owned` and
    // `datas_owned` on any failure.
    const paths_owned = allocator.dupe([]const u8, paths) catch {
        for (datas_owned) |d| allocator.free(d);
        allocator.free(datas_owned);
        return null;
    };
    return loadFromBytes(io, allocator, paths_owned, null, datas_owned, as_of);
}

/// Internal: load+merge given a pre-resolved paths slice. The slice
/// `paths_owned` is taken (will be freed by `LoadedPortfolio.deinit`).
/// `resolved_paths_opt` is the optional `Config.ResolvedPaths` to
/// hand off ownership of the path strings to the returned struct;
/// when null, path strings are caller-owned.
fn loadFromPaths(
    io: std.Io,
    allocator: std.mem.Allocator,
    paths_owned: []const []const u8,
    resolved_paths_opt: ?zfin.Config.ResolvedPaths,
    as_of: zfin.Date,
) ?LoadedPortfolio {
    // Read every file up front; bail on first error.
    var file_datas: std.ArrayList([]const u8) = .empty;
    for (paths_owned) |p| {
        const data = std.Io.Dir.cwd().readFileAlloc(io, p, allocator, .limited(10 * 1024 * 1024)) catch {
            var msg_buf: [512]u8 = undefined;
            const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read portfolio file: {s}\n", .{p}) catch "Error: Cannot read portfolio file\n";
            stderr.print(io, msg);
            for (file_datas.items) |d| allocator.free(d);
            file_datas.deinit(allocator);
            allocator.free(paths_owned);
            if (resolved_paths_opt) |rp| rp.deinit();
            return null;
        };
        file_datas.append(allocator, data) catch {
            allocator.free(data);
            for (file_datas.items) |d| allocator.free(d);
            file_datas.deinit(allocator);
            allocator.free(paths_owned);
            if (resolved_paths_opt) |rp| rp.deinit();
            return null;
        };
    }

    const file_datas_owned = file_datas.toOwnedSlice(allocator) catch {
        for (file_datas.items) |d| allocator.free(d);
        file_datas.deinit(allocator);
        allocator.free(paths_owned);
        if (resolved_paths_opt) |rp| rp.deinit();
        return null;
    };

    // Hand off to the bytes-only path. `loadFromBytes` takes
    // ownership of all four slices and frees them on any error.
    return loadFromBytes(io, allocator, paths_owned, resolved_paths_opt, file_datas_owned, as_of);
}

/// Bytes-only union-merge core. Takes ownership of `paths_owned`,
/// `resolved_paths_opt` (if non-null), and `file_datas_owned` (each
/// element must be `allocator`-owned). Frees them on any error.
///
/// Tests use this directly with synthetic byte literals; production
/// callers come through `loadFromPaths` (working-tree reads) or
/// `loadPortfolioFromPathsAtRev` (git-historical reads). All three
/// share this single deserialize-merge-positions-syms pass.
///
/// `paths_owned` and `file_datas_owned` must have the same length;
/// each `file_datas_owned[i]` is the bytes loaded from
/// `paths_owned[i]`. Path strings are used only for diagnostics
/// (which file failed to parse) and in the returned struct.
fn loadFromBytes(
    io: std.Io,
    allocator: std.mem.Allocator,
    paths_owned: []const []const u8,
    resolved_paths_opt: ?zfin.Config.ResolvedPaths,
    file_datas_owned: []const []const u8,
    as_of: zfin.Date,
) ?LoadedPortfolio {
    std.debug.assert(paths_owned.len == file_datas_owned.len);

    // Cleanup is staged: lots live on `merged` until
    // `toOwnedSlice`, then move into `combined` (which has its
    // own deinit). The two cleanup paths are mutually exclusive,
    // tracked by `lots_owner`. On success, neither fires.
    var merged: std.ArrayList(zfin.Lot) = .empty;
    var combined: zfin.Portfolio = .{ .lots = &.{}, .allocator = allocator };
    const LotsOwner = enum { merged_list, combined_struct, none };
    var lots_owner: LotsOwner = .merged_list;
    var success = false;

    defer if (!success) {
        switch (lots_owner) {
            .merged_list => {
                for (merged.items) |lot| {
                    allocator.free(lot.symbol);
                    if (lot.note) |n| allocator.free(n);
                    if (lot.label) |l| allocator.free(l);
                    if (lot.account) |a| allocator.free(a);
                    if (lot.ticker) |t| allocator.free(t);
                    if (lot.underlying) |u| allocator.free(u);
                }
                merged.deinit(allocator);
            },
            .combined_struct => combined.deinit(),
            .none => {},
        }
        for (file_datas_owned) |d| allocator.free(d);
        allocator.free(file_datas_owned);
        allocator.free(paths_owned);
        if (resolved_paths_opt) |rp| rp.deinit();
    };

    // Deserialize each into an owned Portfolio, then merge their
    // lot slices into a single combined slice. We can't simply
    // concat the underlying slices because each Portfolio expects
    // to free its own lots in `deinit()`; instead, we steal each
    // Portfolio's lots[] (string fields are already dupe'd into
    // `allocator`) and free only the empty Portfolio struct.
    for (file_datas_owned, 0..) |data, idx| {
        // Empty bytes are a legitimate "file absent" signal -
        // `loadPortfolioFromPathsAtRev` uses this to indicate a
        // file that didn't exist at the requested git rev. Skip
        // without trying to parse.
        if (data.len == 0) continue;

        var portfolio = zfin.cache.deserializePortfolio(allocator, data) catch {
            var msg_buf: [512]u8 = undefined;
            const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse portfolio file: {s}\n", .{paths_owned[idx]}) catch "Error: Cannot parse portfolio file\n";
            stderr.print(io, msg);
            return null;
        };
        for (portfolio.lots) |lot| {
            merged.append(allocator, lot) catch {
                portfolio.deinit();
                return null;
            };
        }
        // Free the now-empty Portfolio's lots slice without freeing
        // the per-lot strings - they were transferred to `merged`.
        allocator.free(portfolio.lots);
    }

    const merged_slice = merged.toOwnedSlice(allocator) catch return null;
    combined = .{
        .lots = merged_slice,
        .allocator = allocator,
    };
    lots_owner = .combined_struct;

    const positions = combined.positions(as_of, allocator) catch {
        stderr.print(io, "Error: Cannot compute positions\n");
        return null;
    };

    const syms = combined.stockSymbols(allocator) catch {
        allocator.free(positions);
        stderr.print(io, "Error: Cannot get stock symbols\n");
        return null;
    };

    success = true;
    return .{
        .paths = paths_owned,
        .resolved_paths = resolved_paths_opt,
        .file_datas = file_datas_owned,
        .portfolio = combined,
        .positions = positions,
        .syms = syms,
    };
}

// ── Portfolio data pipeline ──────────────────────────────────

/// Result of the shared portfolio data pipeline. Caller must call deinit().
pub const PortfolioData = struct {
    summary: zfin.valuation.PortfolioSummary,
    candle_map: std.StringHashMap([]const zfin.Candle),
    snapshots: ?[6]zfin.valuation.HistoricalSnapshot,

    pub fn deinit(self: *PortfolioData, allocator: std.mem.Allocator) void {
        self.summary.deinit(allocator);
        var it = self.candle_map.valueIterator();
        while (it.next()) |v| allocator.free(v.*);
        self.candle_map.deinit();
    }
};

/// Build portfolio summary, candle map, and historical snapshots from
/// pre-populated prices. Shared between CLI `portfolio` command, TUI
/// `loadPortfolioData`, and TUI `reloadPortfolioFile`.
///
/// Callers are responsible for populating `prices` (via network fetch,
/// cache read, or pre-fetched map) before calling this.
///
/// Returns error.NoAllocations if the summary produces no positions
/// (e.g. no cached prices available).
pub fn buildPortfolioData(
    allocator: std.mem.Allocator,
    portfolio: zfin.Portfolio,
    positions: []const zfin.Position,
    syms: []const []const u8,
    prices: *std.StringHashMap(f64),
    svc: *zfin.DataService,
    as_of: zfin.Date,
) !PortfolioData {
    var manual_price_set = try zfin.valuation.buildFallbackPrices(allocator, portfolio.lots, positions, prices);
    defer manual_price_set.deinit();

    var summary = zfin.valuation.portfolioSummary(as_of, allocator, portfolio, positions, prices.*, manual_price_set) catch
        return error.SummaryFailed;
    errdefer summary.deinit(allocator);

    if (summary.allocations.len == 0) {
        summary.deinit(allocator);
        return error.NoAllocations;
    }

    var candle_map = std.StringHashMap([]const zfin.Candle).init(allocator);
    errdefer {
        var it = candle_map.valueIterator();
        while (it.next()) |v| allocator.free(v.*);
        candle_map.deinit();
    }
    for (syms) |sym| {
        if (svc.getCachedCandles(allocator, sym)) |cs| {
            // cs.data is owned by the caller's `allocator`. Store
            // the raw slice; PortfolioData.deinit (or the arena
            // reset, in TUI) below frees via the same allocator.
            try candle_map.put(sym, cs.data);
        }
    }

    const snapshots = zfin.valuation.computeHistoricalSnapshots(
        as_of,
        positions,
        prices.*,
        candle_map,
    );

    return .{
        .summary = summary,
        .candle_map = candle_map,
        .snapshots = snapshots,
    };
}

/// Opt-in stock-split adjustment: set each open stock lot's
/// `split_factor` from the per-symbol metadata.srf `splits_current_through`
/// cutovers and the fetched split corpus. A no-op (and skips the corpus
/// fetch entirely) when no symbol has opted in.
///
/// `opts` is the caller's cache policy and must be threaded from the
/// invocation's `--refresh-data` setting. It used to be hardcoded to
/// defaults here, which silently fetched splits over the network even under
/// `--refresh-data=never`.
///
/// This mutates only `lots[].split_factor`; callers that hold an
/// already-computed positions slice must recompute it afterward (the
/// CLI wrapper `applySplitAdjustment` does this). Used directly by the
/// TUI, snapshot, and audit paths, which recompute positions on their own.
///
/// Failures degrade gracefully to raw shares. Contributions never
/// calls this (it diffs raw declared shares from git), so a split can
/// never masquerade as a contribution.
pub fn enrichLotsSplits(
    svc: *zfin.DataService,
    allocator: std.mem.Allocator,
    lots: []zfin.Lot,
    syms: []const []const u8,
    anchor_path: []const u8,
    as_of: zfin.Date,
    opts: zfin.FetchOptions,
) void {
    var cutovers = svc.loadSplitsCutovers(allocator, anchor_path);
    defer freeCutovers(allocator, &cutovers);
    if (cutovers.count() == 0) return; // nothing opted in -> skip the corpus fetch

    // Fetch splits ONLY for the opted-in symbols. `enrichSplits` looks the
    // corpus up strictly by a symbol that is already in `cutovers`, so a split
    // record for any other symbol is fetched and then discarded.
    //
    // That waste is not free and it is not quiet in the way you might hope: it
    // is a provider round trip per cache miss, it runs synchronously inside
    // `PortfolioData.load` BEFORE the progress UI exists, and split TTLs are
    // 14 days with jitter - so on whatever day a few lapse, the TUI sits there
    // with no output before first paint. Narrowing the corpus to the opt-in set
    // cuts that from every held symbol to the handful that asked for it.
    //
    // Borrowing `syms`' entries is safe: `corpus` is torn down by the defer
    // below, which runs before `freeCutovers` above it.
    var wanted: std.ArrayList([]const u8) = .empty;
    defer wanted.deinit(allocator);
    for (syms) |s| {
        // Only symbols actually held - `syms` is the held set, and a metadata
        // row can name a symbol the portfolio no longer holds.
        //
        // Driven from `syms` with a hash lookup, not from the map's keys with a
        // linear scan of `syms` per key: same result, O(n) instead of O(n*m),
        // and the output order follows the held set instead of hash iteration
        // order.
        // Bailing on OOM rather than propagating: split adjustment is opt-in
        // enrichment, and the un-adjusted portfolio is still correct.
        if (cutovers.contains(s)) wanted.append(allocator, s) catch return;
    }
    if (wanted.items.len == 0) return;

    var corpus = svc.loadAllSplits(allocator, wanted.items, opts);
    defer {
        var it = corpus.valueIterator();
        while (it.next()) |v| allocator.free(v.*);
        corpus.deinit();
    }

    enrichSplits(lots, &corpus, &cutovers, as_of);
}

/// Free a `symbol -> cutover` map produced by `loadSplitsCutovers`
/// (owned keys, then the map itself).
fn freeCutovers(allocator: std.mem.Allocator, cutovers: *std.StringHashMap(zfin.Date)) void {
    var it = cutovers.keyIterator();
    while (it.next()) |k| allocator.free(k.*);
    cutovers.deinit();
}

/// A held stock symbol that split AFTER a lot was purchased while that
/// symbol has NOT opted into split adjustment - i.e. an unhandled split
/// that would change the position once the user enables the feature for
/// it. Surfaced as an `audit` hygiene finding. `symbol` borrows from the
/// caller's lots; use before the portfolio is freed.
pub const SplitNudge = struct {
    symbol: []const u8,
    date: zfin.Date,
};

/// Collect every held stock symbol that has NOT opted into split
/// adjustment yet has a split after a lot's purchase date. One entry per
/// symbol (first qualifying split). Powers the `audit` hygiene "unhandled
/// stock splits" section.
///
/// Caller owns the returned slice (`allocator.free` it); each `symbol`
/// borrows from `lots`, so use the result before the portfolio is freed.
/// Returns an empty slice on any allocation failure (hygiene is
/// best-effort).
///
/// `opts` is the caller's cache policy. This used to hardcode
/// `skip_network`, which made the check cheap but meant flagless `zfin
/// audit` - which never warms the split cache, since it returns into
/// hygiene before `applySplitAdjustment` runs - reported findings from
/// whatever stale data happened to be on disk, and logged a
/// "stale-cached returned (skip_network)" line per symbol while doing it.
/// A hygiene check that tells you your data is stale instead of
/// refreshing it is the wrong trade; pass the invocation's real policy so
/// `--refresh-data=never` still gets cache-only behavior on request.
pub fn findUnhandledSplits(
    svc: *zfin.DataService,
    allocator: std.mem.Allocator,
    lots: []const zfin.Lot,
    syms: []const []const u8,
    anchor_path: []const u8,
    as_of: zfin.Date,
    opts: zfin.FetchOptions,
) []SplitNudge {
    var cutovers = svc.loadSplitsCutovers(allocator, anchor_path);
    defer freeCutovers(allocator, &cutovers);

    var corpus = svc.loadAllSplits(allocator, syms, opts);
    defer {
        var it = corpus.valueIterator();
        while (it.next()) |v| allocator.free(v.*);
        corpus.deinit();
    }

    var found: std.ArrayList(SplitNudge) = .empty;
    defer found.deinit(allocator);

    for (lots) |lot| {
        if (lot.security_type != .stock) continue;
        if (!lot.lotIsOpenAsOf(as_of)) continue;
        const sym = lot.priceSymbol();
        if (cutovers.contains(sym)) continue; // this symbol already opted in
        // Dedup: one finding per symbol. Findings are rare, so a linear
        // scan over what we've collected is cheaper than a side map.
        var already = false;
        for (found.items) |f| {
            if (std.mem.eql(u8, f.symbol, sym)) {
                already = true;
                break;
            }
        }
        if (already) continue;
        const splits = corpus.get(sym) orelse continue;
        for (splits) |s| {
            // Split strictly after purchase and on/before the as-of date.
            if (lot.open_date.lessThan(s.date) and !as_of.lessThan(s.date)) {
                found.append(allocator, .{ .symbol = sym, .date = s.date }) catch break;
                break;
            }
        }
    }
    return found.toOwnedSlice(allocator) catch &.{};
}

/// CLI wrapper: enrich a freshly loaded `LoadedPortfolio` in place and
/// recompute `positions` from the (possibly) enriched lots so every
/// downstream consumer (summary, per-lot rows, account breakdown) sees
/// effective shares. The recompute is unconditional and cheap
/// (in-memory, no I/O); it yields identical positions when nothing has
/// opted in.
pub fn applySplitAdjustment(
    svc: *zfin.DataService,
    allocator: std.mem.Allocator,
    loaded: *LoadedPortfolio,
    as_of: zfin.Date,
    opts: zfin.FetchOptions,
) void {
    enrichLotsSplits(svc, allocator, loaded.portfolio.lots, loaded.syms, loaded.anchor(), as_of, opts);

    // Positions were aggregated from raw lots at load time; recompute
    // from the (now possibly enriched) lots. On failure, keep the
    // existing positions.
    const new_positions = loaded.portfolio.positions(as_of, allocator) catch return;
    allocator.free(loaded.positions);
    loaded.positions = new_positions;
}

// ── loadFromBytes tests (in-memory; no disk I/O) ─────────────
//
// All tests here construct synthetic SRF byte literals and route
// them through `loadFromBytes` directly. Both production callers
// (working-tree `loadFromPaths` and git-historical
// `loadPortfolioFromPathsAtRev`) share this core, so unit-testing
// it covers the union-merge / parse-error / empty-bytes behavior
// without spinning up temp filesystems or git repos.

const testing = std.testing;

/// Test helper: dupe a slice of byte-string literals into the
/// allocator so `loadFromBytes` can free them on its own.
fn dupeBytes(allocator: std.mem.Allocator, datas: []const []const u8) ![]const []const u8 {
    var owned = try allocator.alloc([]const u8, datas.len);
    errdefer {
        for (owned[0..]) |d| allocator.free(d);
        allocator.free(owned);
    }
    var filled: usize = 0;
    for (datas, 0..) |d, i| {
        owned[i] = try allocator.dupe(u8, d);
        filled = i + 1;
    }
    return owned;
}

test "loadFromBytes: union of two synthetic SRF files" {
    const allocator = testing.allocator;

    const file_a =
        \\#!srfv1
        \\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA
        \\
    ;
    const file_b =
        \\#!srfv1
        \\symbol::MSFT,shares:num:5,open_date::2024-02-01,open_price:num:300,account::Sample Roth
        \\
    ;

    const paths = try allocator.dupe([]const u8, &.{ "a.srf", "b.srf" });
    const datas = try dupeBytes(allocator, &.{ file_a, file_b });

    var loaded = loadFromBytes(testing.io, allocator, paths, null, datas, zfin.Date.fromYmd(2026, 5, 23)) orelse {
        try testing.expect(false);
        return;
    };
    defer loaded.deinit(allocator);

    try testing.expectEqual(@as(usize, 2), loaded.portfolio.lots.len);
    // Lots are appended in file order.
    try testing.expectEqualStrings("AAPL", loaded.portfolio.lots[0].symbol);
    try testing.expectEqualStrings("MSFT", loaded.portfolio.lots[1].symbol);
}

test "loadFromBytes: single file with valid contents" {
    const allocator = testing.allocator;

    const file_a =
        \\#!srfv1
        \\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA
        \\
    ;

    const paths = try allocator.dupe([]const u8, &.{"a.srf"});
    const datas = try dupeBytes(allocator, &.{file_a});

    var loaded = loadFromBytes(testing.io, allocator, paths, null, datas, zfin.Date.fromYmd(2026, 5, 23)) orelse {
        try testing.expect(false);
        return;
    };
    defer loaded.deinit(allocator);

    try testing.expectEqual(@as(usize, 1), loaded.portfolio.lots.len);
    try testing.expectEqualStrings("AAPL", loaded.portfolio.lots[0].symbol);
}

test "loadFromBytes: empty-bytes entry is treated as absent file" {
    // Production use case: `loadPortfolioFromPathsAtRev` returns
    // empty bytes for a path that didn't exist at the requested
    // git rev. The union-merge silently skips it instead of
    // failing - matches "snapshot at a date before this file
    // was added" semantics.
    const allocator = testing.allocator;

    const file_a =
        \\#!srfv1
        \\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA
        \\
    ;
    const file_b: []const u8 = "";

    const paths = try allocator.dupe([]const u8, &.{ "a.srf", "b.srf" });
    const datas = try dupeBytes(allocator, &.{ file_a, file_b });

    var loaded = loadFromBytes(testing.io, allocator, paths, null, datas, zfin.Date.fromYmd(2026, 5, 23)) orelse {
        try testing.expect(false);
        return;
    };
    defer loaded.deinit(allocator);

    try testing.expectEqual(@as(usize, 1), loaded.portfolio.lots.len);
    try testing.expectEqualStrings("AAPL", loaded.portfolio.lots[0].symbol);
}

test "loadFromBytes: all-empty bytes returns empty portfolio" {
    // Production use case: snapshot at a rev that predates ALL
    // tracked portfolio files. No lots; no error.
    const allocator = testing.allocator;

    const paths = try allocator.dupe([]const u8, &.{ "a.srf", "b.srf" });
    const datas = try dupeBytes(allocator, &.{ "", "" });

    var loaded = loadFromBytes(testing.io, allocator, paths, null, datas, zfin.Date.fromYmd(2026, 5, 23)) orelse {
        try testing.expect(false);
        return;
    };
    defer loaded.deinit(allocator);

    try testing.expectEqual(@as(usize, 0), loaded.portfolio.lots.len);
}

// ── loadPortfolioFromPathsAtRev tests (disk + git) ───────────
//
// These spin up a temp git repo so they exercise the full
// `git.show + skip-on-PathMissingInRev` contract end-to-end.
// Kept minimal (one happy-path, one missing-file case); the
// bulk of the logic is in `loadFromBytes`, covered above.

test "loadPortfolioFromPathsAtRev: union of two committed files at HEAD" {
    const allocator = testing.allocator;

    // Skip if `git` isn't on PATH (CI sandbox without git).
    if (!test_git.available(allocator)) return;

    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

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

    const file_a =
        \\#!srfv1
        \\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA
        \\
    ;
    const file_b =
        \\#!srfv1
        \\symbol::MSFT,shares:num:5,open_date::2024-02-01,open_price:num:300,account::Sample Roth
        \\
    ;
    try tmp.dir.writeFile(testing.io, .{ .sub_path = "portfolio.srf", .data = file_a });
    try tmp.dir.writeFile(testing.io, .{ .sub_path = "portfolio_other.srf", .data = file_b });

    try test_git.run(allocator, dir, null, &.{ "init", "-q" });
    try test_git.run(allocator, dir, null, &.{ "config", "user.email", "test@example.com" });
    try test_git.run(allocator, dir, null, &.{ "config", "user.name", "Test" });
    try test_git.run(allocator, dir, null, &.{ "config", "commit.gpgsign", "false" });
    try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf", "portfolio_other.srf" });
    try test_git.run(allocator, dir, null, &.{ "commit", "-q", "-m", "initial" });

    const p1 = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
    defer allocator.free(p1);
    const p2 = try std.fs.path.join(allocator, &.{ dir, "portfolio_other.srf" });
    defer allocator.free(p2);
    const paths = [_][]const u8{ p1, p2 };

    // Pass the RAW process env (GIT_* included): `git.zig` strips them
    // internally, so this exercises that scrubbing - which is what
    // lets this test pass under a git hook where GIT_DIR points at the
    // outer repo (see `git.scrubbedEnv`).
    var env = try testing.environ.createMap(allocator);
    defer env.deinit();

    var loaded = loadPortfolioFromPathsAtRev(testing.io, allocator, &env, &paths, "HEAD", zfin.Date.fromYmd(2026, 5, 23)) orelse {
        try testing.expect(false);
        return;
    };
    defer loaded.deinit(allocator);

    try testing.expectEqual(@as(usize, 2), loaded.portfolio.lots.len);
}

test "loadPortfolioFromPathsAtRev: file added later is silently skipped at earlier rev" {
    const allocator = testing.allocator;

    // Skip if `git` isn't on PATH.
    if (!test_git.available(allocator)) return;

    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

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

    const file_a =
        \\#!srfv1
        \\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA
        \\
    ;
    try tmp.dir.writeFile(testing.io, .{ .sub_path = "portfolio.srf", .data = file_a });

    try test_git.run(allocator, dir, null, &.{ "init", "-q" });
    try test_git.run(allocator, dir, null, &.{ "config", "user.email", "test@example.com" });
    try test_git.run(allocator, dir, null, &.{ "config", "user.name", "Test" });
    try test_git.run(allocator, dir, null, &.{ "config", "commit.gpgsign", "false" });
    try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf" });
    try test_git.run(allocator, dir, null, &.{ "commit", "-q", "-m", "initial" });

    // Add and commit a second portfolio file. After this commit,
    // commit 1 (where portfolio_other.srf doesn't exist yet) is
    // reachable as HEAD~1 - no need to capture its SHA explicitly,
    // which also avoids a direct `git` call that would inherit the
    // hook's GIT_* env. `git show HEAD~1:<path>` and `git show
    // <sha>:<path>` exercise the identical code path in `git.show`.
    const file_b =
        \\#!srfv1
        \\symbol::MSFT,shares:num:5,open_date::2024-02-01,open_price:num:300,account::Sample Roth
        \\
    ;
    try tmp.dir.writeFile(testing.io, .{ .sub_path = "portfolio_other.srf", .data = file_b });
    try test_git.run(allocator, dir, null, &.{ "add", "portfolio_other.srf" });
    try test_git.run(allocator, dir, null, &.{ "commit", "-q", "-m", "add second" });

    const p1 = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
    defer allocator.free(p1);
    const p2 = try std.fs.path.join(allocator, &.{ dir, "portfolio_other.srf" });
    defer allocator.free(p2);
    const paths = [_][]const u8{ p1, p2 };

    // Load at commit 1 (HEAD~1) - second file didn't exist yet.
    // Expect only AAPL from portfolio.srf, no error. Pass the RAW
    // process env; `git.zig` strips GIT_* internally so this works
    // under a git hook (see `git.scrubbedEnv`).
    var env = try testing.environ.createMap(allocator);
    defer env.deinit();

    var loaded = loadPortfolioFromPathsAtRev(testing.io, allocator, &env, &paths, "HEAD~1", zfin.Date.fromYmd(2026, 5, 23)) orelse {
        try testing.expect(false);
        return;
    };
    defer loaded.deinit(allocator);

    try testing.expectEqual(@as(usize, 1), loaded.portfolio.lots.len);
    try testing.expectEqualStrings("AAPL", loaded.portfolio.lots[0].symbol);
}

// ── Split adjustment wiring (seeded cache + metadata) ────────

/// Write a portfolio file, a metadata.srf (optionally with a per-symbol
/// cutover row), and seed one NVDA 10:1 split into the cache under
/// `dir`. Returns the realpath of the temp dir (borrows `path_buf`).
fn seedSplitFixture(io: std.Io, tmp: *std.testing.TmpDir, path_buf: []u8, cutover_line: []const u8) ![]const u8 {
    try tmp.dir.writeFile(io, .{
        .sub_path = "zfintest_split_pf.srf",
        .data =
        \\#!srfv1
        \\symbol::NVDA,shares:num:100,open_date::2020-01-01,open_price:num:40.00,account::Sample Brokerage
        \\
        ,
    });
    var meta_buf: [128]u8 = undefined;
    const meta = try std.fmt.bufPrint(&meta_buf, "#!srfv1\n{s}", .{cutover_line});
    try tmp.dir.writeFile(io, .{ .sub_path = "metadata.srf", .data = meta });

    const dir_len = try tmp.dir.realPathFile(io, ".", path_buf);
    const dir = path_buf[0..dir_len];

    // Seed an NVDA 10:1 split (2024-06-10) into the split cache.
    var store = zfin.cache.Store.init(io, testing.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 });
    return dir;
}

test "applySplitAdjustment: end-to-end enriches loaded positions from seeded cache + cutover" {
    const allocator = testing.allocator;
    const io = testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var path_buf: [std.fs.max_path_bytes]u8 = undefined;
    // Per-symbol cutover before the 2024 split -> the split IS applied.
    const dir = try seedSplitFixture(io, &tmp, &path_buf, "symbol::NVDA,splits_current_through::2024-01-01\n");

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

    const pf_path = try std.fs.path.join(allocator, &.{ dir, "zfintest_split_pf.srf" });
    defer allocator.free(pf_path);
    const paths = try allocator.dupe([]const u8, &.{pf_path});
    defer allocator.free(paths);
    var loaded = loadPortfolioFromPaths(io, allocator, paths, zfin.Date.fromYmd(2026, 1, 1)) orelse
        return error.TestUnexpectedResult;
    defer loaded.deinit(allocator);

    // Before enrichment: raw 100 shares.
    try testing.expectApproxEqAbs(@as(f64, 100), loaded.positions[0].shares, 0.001);

    applySplitAdjustment(&svc, allocator, &loaded, zfin.Date.fromYmd(2026, 1, 1), .{ .skip_network = true });

    // After: 100 * 10 = 1000 effective shares; factor stamped on the lot;
    // cost basis stays invariant (100 * 40 = 4000).
    try testing.expectApproxEqAbs(@as(f64, 10.0), loaded.portfolio.lots[0].split_factor, 0.001);
    try testing.expectApproxEqAbs(@as(f64, 1000), loaded.positions[0].shares, 0.001);
    try testing.expectApproxEqAbs(@as(f64, 4000), loaded.positions[0].total_cost, 0.001);
}

test "enrichLotsSplits: a cutover for an unheld symbol does not defeat the held one" {
    const allocator = testing.allocator;
    const io = testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // The corpus is now narrowed to the opt-in set intersected with what is
    // actually held, because `enrichSplits` can only ever consult a symbol
    // present in `cutovers` - fetching the rest was a provider round trip per
    // cache miss, spent before the TUI paints, on data that got discarded.
    //
    // This pins the intersection: metadata legitimately carries rows for
    // symbols the portfolio has since sold, and one of those must not stop the
    // held symbol from being enriched.
    var path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir = try seedSplitFixture(io, &tmp, &path_buf, "symbol::ZZZZNOTHELD,splits_current_through::2020-01-01\n" ++
        "symbol::NVDA,splits_current_through::2024-01-01\n");

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

    const pf_path = try std.fs.path.join(allocator, &.{ dir, "zfintest_split_pf.srf" });
    defer allocator.free(pf_path);
    const paths = try allocator.dupe([]const u8, &.{pf_path});
    defer allocator.free(paths);
    var loaded = loadPortfolioFromPaths(io, allocator, paths, zfin.Date.fromYmd(2026, 1, 1)) orelse
        return error.TestUnexpectedResult;
    defer loaded.deinit(allocator);

    applySplitAdjustment(&svc, allocator, &loaded, zfin.Date.fromYmd(2026, 1, 1), .{ .skip_network = true });

    // NVDA still enriched from the seeded cache despite the unheld row.
    try testing.expectApproxEqAbs(@as(f64, 10.0), loaded.portfolio.lots[0].split_factor, 0.001);
    try testing.expectApproxEqAbs(@as(f64, 1000), loaded.positions[0].shares, 0.001);
}

test "applySplitAdjustment: no cutover is a no-op (raw shares preserved)" {
    const allocator = testing.allocator;
    const io = testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var path_buf: [std.fs.max_path_bytes]u8 = undefined;
    // metadata.srf with NO cutover row -> opt-in off.
    const dir = try seedSplitFixture(io, &tmp, &path_buf, "");

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

    const pf_path = try std.fs.path.join(allocator, &.{ dir, "zfintest_split_pf.srf" });
    defer allocator.free(pf_path);
    const paths = try allocator.dupe([]const u8, &.{pf_path});
    defer allocator.free(paths);
    var loaded = loadPortfolioFromPaths(io, allocator, paths, zfin.Date.fromYmd(2026, 1, 1)) orelse
        return error.TestUnexpectedResult;
    defer loaded.deinit(allocator);

    applySplitAdjustment(&svc, allocator, &loaded, zfin.Date.fromYmd(2026, 1, 1), .{ .skip_network = true });

    // Opt-in off -> factor stays 1.0, shares stay raw.
    try testing.expectApproxEqAbs(@as(f64, 1.0), loaded.portfolio.lots[0].split_factor, 0.001);
    try testing.expectApproxEqAbs(@as(f64, 100), loaded.positions[0].shares, 0.001);
}

test "enrichLotsSplits: skip_network is honored even when the split cache is stale" {
    // Regression: this used to hardcode default FetchOptions, so
    // `--refresh-data=never` still hit the network for splits. A FRESH cache
    // cannot catch that (fetchCached short-circuits before any network), so
    // the seeded entry is deliberately expired to force the decision point.
    const allocator = testing.allocator;
    const io = testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir = try seedSplitFixture(io, &tmp, &path_buf, "symbol::NVDA,splits_current_through::2024-01-01\n");

    // Re-seed the same split with a long-expired TTL.
    {
        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 = -1_000_000 });
    }

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

    const pf_path = try std.fs.path.join(allocator, &.{ dir, "zfintest_split_pf.srf" });
    defer allocator.free(pf_path);
    const paths = try allocator.dupe([]const u8, &.{pf_path});
    defer allocator.free(paths);
    var loaded = loadPortfolioFromPaths(io, allocator, paths, zfin.Date.fromYmd(2026, 1, 1)) orelse
        return error.TestUnexpectedResult;
    defer loaded.deinit(allocator);

    // Any provider or server call from here on is a policy violation.
    svc.panic_on_network_attempt = true;
    applySplitAdjustment(&svc, allocator, &loaded, zfin.Date.fromYmd(2026, 1, 1), .{ .skip_network = true });

    // Offline still applies the stale split it already had: 100 -> 1000.
    try testing.expectApproxEqAbs(@as(f64, 1000), loaded.positions[0].shares, 0.001);
}

test "findUnhandledSplits: flags post-purchase splits for un-opted-in symbols only" {
    const allocator = testing.allocator;
    const io = testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir = try seedSplitFixture(io, &tmp, &path_buf, ""); // opt-in OFF

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

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

    var lots = [_]zfin.Lot{
        .{ .symbol = "NVDA", .shares = 100, .open_date = zfin.Date.fromYmd(2020, 1, 1), .open_price = 40 },
    };
    const syms = [_][]const u8{"NVDA"};

    // Opt-in off + a split after purchase -> one finding.
    const found = findUnhandledSplits(&svc, allocator, &lots, &syms, anchor, zfin.Date.fromYmd(2026, 1, 1), .{ .skip_network = true });
    defer allocator.free(found);
    try testing.expectEqual(@as(usize, 1), found.len);
    try testing.expectEqualStrings("NVDA", found[0].symbol);
    try testing.expect(found[0].date.eql(zfin.Date.fromYmd(2024, 6, 10)));

    // A lot opened AFTER the split -> no finding for it.
    var lots_after = [_]zfin.Lot{
        .{ .symbol = "NVDA", .shares = 5, .open_date = zfin.Date.fromYmd(2025, 1, 1), .open_price = 120 },
    };
    const none = findUnhandledSplits(&svc, allocator, &lots_after, &syms, anchor, zfin.Date.fromYmd(2026, 1, 1), .{ .skip_network = true });
    defer allocator.free(none);
    try testing.expectEqual(@as(usize, 0), none.len);
}
