//! Per-portfolio data: parses files, fetches prices, computes
//! summary, and runs background workers for candles, snapshots,
//! dividends, and the account map. Owns its own runtime + service
//! references for the lifetime of the value.
//!
//! ## Lifecycle
//!
//! ```
//! var pd = PortfolioData.init(.{ .gpa = gpa, .io = io, .svc = svc });
//! defer pd.deinit();   // cancels in-flight, frees everything
//!
//! const result = try pd.load(paths, today, .{});  // sync
//! _ = result; // LoadResult: counters + failed_syms
//!
//! // Sync data (populated by load() before it returns):
//! const summary = pd.summary.?;
//! if (pd.latest_quote_date) |d| ...
//!
//! // Async data (each method blocks on its worker future):
//! const candles = pd.candles().?;     // ?*const StringHashMap([]const Candle)
//! const snaps = pd.snapshots().?;     // ?[N]HistoricalSnapshot
//! const divs = pd.dividends().?;      // ?*const StringHashMap([]const Dividend)
//! const am = pd.accountMap().?;       // ?*const AccountMap
//!
//! // Reload uses the same paths captured during the first load:
//! _ = try pd.reload(today, .{});
//! ```
//!
//! ## Sync vs async data
//!
//! - **Sync fields** (populated by `load()` before it returns):
//!   `paths`, `file`, `summary`, `latest_quote_date`,
//!   `watchlist_prices`. Read directly via field access.
//! - **Async data** (populated by background workers): candles,
//!   snapshots, dividends, account_map. Access via the matching
//!   method (`pd.candles()` etc.) which blocks on the worker
//!   future. Each datum has its own worker; `dividends()` does
//!   not wait for candles.
//!
//! The field-vs-method split makes the cost of access visible at
//! the call site. A field read never blocks; a method call may
//! block on a still-running worker.
//!
//! ## Worker scheduling
//!
//! `load()` spawns four workers - one per async datum - at the
//! end of its synchronous work. Each worker honors a
//! `start_delay` (configurable via `LoadOptions.delays`) so the
//! caller can prioritize which data lands first when many workers
//! are racing. The `snapshotsWorker` internally awaits the
//! candle worker (snapshots depend on candles); other workers
//! are independent.
//!
//! ## Memory model
//!
//! An `std.heap.ArenaAllocator` backs everything portfolio-scoped
//! except the parsed `file` (which is GPA-allocated because the
//! loader uses one allocator for both kept Portfolio and discarded
//! temporaries). `reload()` cancels in-flight work, drops the file,
//! resets the arena, and re-parses; the previous load's data is
//! reaped in O(1) on the arena reset.
//!
//! ## Cancelation
//!
//! `cancelLoad()` is idempotent and safe to call any time. It
//! cancels every worker future and nulls every async data field,
//! so subsequent method calls return null without blocking.
//! `deinit()` cancels first, then frees.

const std = @import("std");
const log = std.log.scoped(.portfolio_data);

const PortfolioData = @This();

const zfin = @import("root.zig");
const portfolio_loader = @import("portfolio_loader.zig");

const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const Date = zfin.Date;
const Portfolio = zfin.Portfolio;
const PortfolioSummary = zfin.valuation.PortfolioSummary;
const HistoricalSnapshot = zfin.valuation.HistoricalSnapshot;
const HistoricalPeriod = zfin.valuation.HistoricalPeriod;
const Candle = zfin.Candle;
const Dividend = zfin.Dividend;
const AccountMap = zfin.analysis.AccountMap;
const ClassificationMap = zfin.classification.ClassificationMap;
const DataService = zfin.DataService;
const FetchOptions = zfin.FetchOptions;

// ── Public types ──────────────────────────────────────────────

/// Construction options. `gpa` backs the internal arena and the
/// parsed `file`. `io` is threaded through every async/cancel
/// call. `svc` is borrowed for cache reads and live fetches.
pub const InitOptions = struct {
    gpa: Allocator,
    io: std.Io,
    svc: *DataService,
};

/// User-visible outcome of a load() / reload(). All slices borrow
/// from pd's arena and stay valid until the next reload (or
/// deinit). Callers compose status strings from these fields.
pub const LoadResult = struct {
    /// Number of symbols resolved from fresh local cache (no
    /// network round-trip needed).
    cached_count: usize,
    /// Number of symbols synced from `ZFIN_SERVER`.
    server_synced_count: usize,
    /// Number of symbols fetched from rate-limited providers.
    provider_fetched_count: usize,
    /// Number of symbols whose live fetch failed but a stale
    /// cache fallback was used.
    stale_count: usize,
    /// Number of symbols that failed completely (no data).
    failed_count: usize,
    /// Symbol names that failed (for "Failed to load: AAPL, MSFT"
    /// status). Up to first 8; arena-allocated.
    failed_syms: []const []const u8,
    /// Most recent quote date across the held symbols. Drives
    /// "as of close on YYYY-MM-DD" in the TUI status line.
    latest_date: ?Date,

    /// Total number of symbols processed (cached + server +
    /// provider + failed).
    pub fn total(self: LoadResult) usize {
        return self.cached_count + self.server_synced_count + self.provider_fetched_count + self.failed_count;
    }
};

/// Why a load() didn't produce a usable summary. Maps to the
/// status messages the caller composes for the user.
pub const LoadError = error{
    /// `paths` was empty.
    NoPaths,
    /// Couldn't parse any portfolio file from the given paths.
    PortfolioParseFailed,
    /// Parsed OK but no positions resolved -> no allocations to
    /// summarize. ("Run: zfin perf <SYMBOL> first.")
    NoAllocations,
    /// `valuation.portfolioSummary` failed.
    SummaryFailed,
    /// Out of memory on a fatal allocation.
    OutOfMemory,
};

/// Optional callback for per-symbol fetch progress (mirrors
/// `DataService.ProgressCallback`). When non-null, fires once
/// per symbol during the price-fetch step. The TUI uses this
/// for "Loading AAPL..." status updates.
pub const ProgressCallback = DataService.ProgressCallback;

/// Optional callback for aggregate (parallel) fetch progress
/// (mirrors `DataService.AggregateProgressCallback`). Used by
/// callers that want to render "Loading [N/M]..." style UI
/// during the parallel server-sync phase.
pub const AggregateProgressCallback = DataService.AggregateProgressCallback;

/// Per-load options.
pub const LoadOptions = struct {
    /// Per-symbol progress callback. Optional. Fires once per
    /// symbol during the price-fetch step (typically used during
    /// the sequential provider-fallback phase).
    progress: ?ProgressCallback = null,
    /// Aggregate progress callback. Optional. Fires periodically
    /// during the parallel server-sync phase with completed/total
    /// counts. The TUI uses this to render the
    /// "Syncing from server... [N/M]" bar before vaxis takes over.
    aggregate_progress: ?AggregateProgressCallback = null,
    /// Cache policy for this load, straight from the invocation's
    /// `--refresh-data` setting (build it with
    /// `cli.fetchOptionsFromPolicy`). Carried as a whole rather than as
    /// two loose bools: callers already hold a `FetchOptions`, so
    /// flattening it here meant every call site re-assembled it by hand
    /// and a site that forgot silently got network access it was told not
    /// to use. `reload` did exactly that, putting a TUI launched with
    /// `--refresh-data=never` back online on refresh.
    fetch_options: FetchOptions = .{},
    /// Watchlist symbols (from a separate `watchlist.srf` file).
    /// Their prices land in `pd.watchlist_prices`, separate from
    /// the portfolio summary's allocations. pd internally unions
    /// these with portfolio's own `.watch` lots and dedups.
    /// Borrowed; pd does not take ownership of the slice.
    watchlist_syms: []const []const u8 = &.{},
    /// Optional live-quote overlay: symbol -> live last price. When
    /// present, these prices override the candle-derived last close
    /// for matching symbols as the portfolio summary is built (held
    /// positions overlay the summary prices; watchlist symbols overlay
    /// `pd.watchlist_prices`). Symbols absent from the map keep their
    /// candle last close. Used by the TUI refresh key to value the
    /// portfolio with current intraday quotes rather than the prior
    /// daily close. Borrowed; pd does not take ownership.
    live_quotes: ?*const std.StringHashMap(f64) = null,
    /// Wall-clock instant (Unix seconds) at which `live_quotes` were
    /// fetched. Stored on pd as `live_quotes_at_s` when the overlay
    /// actually re-prices a held position, so the renderer can show a
    /// precise "(as of H:MM PM ET)" stamp. Ignored when `live_quotes`
    /// is null. Caller captures it once via
    /// `std.Io.Timestamp.now(io, .real).toSeconds()`.
    live_quotes_at_s: ?i64 = null,
    /// Per-worker start delays. Each background worker sleeps for
    /// its delay before doing any work, letting the caller
    /// deprioritize a specific worker (e.g. push it later so a
    /// hotter path's worker grabs CPU first). Defaults are all
    /// zero - see `WorkerDelays` for the rationale.
    delays: WorkerDelays = .{},
};

/// Per-worker start delays in milliseconds. Workers sleep for
/// their respective delay before doing any work; the sleep is
/// a cancelation point, so cancelLoad() during the delay window
/// causes the worker to exit cleanly without doing anything.
///
/// Defaults are all zero - measured warm-cache timings showed
/// that one worker (candles) dominates wall-clock at ~870ms
/// while the others finish in tens of milliseconds, so there's
/// nothing to stagger in the current workload. Workers race;
/// the runtime schedules them. The knob stays in the API for
/// callers who want to deprioritize a worker (e.g. dial up
/// `account_map_ms` if it ever becomes expensive enough to
/// crowd out interactive paths).
pub const WorkerDelays = struct {
    snapshots_ms: usize = 0,
    candles_ms: usize = 0,
    dividends_ms: usize = 0,
    account_map_ms: usize = 0,
    classification_map_ms: usize = 0,
};

// ── State ─────────────────────────────────────────────────────
//
// Public field naming convention:
// - Sync data (populated by `load()` before it returns): plain
//   field, callers read directly.
// - Async data (populated by background workers after `load()`
//   returns): the data field is internal; callers use the
//   matching method (e.g. `pd.candles()`) which blocks on the
//   future before returning the data.
//
// This means callers can tell at the call site which data is
// "ready right now" (field access) vs. "may block" (method call).

arena: ArenaAllocator,

/// Dedicated arena for `candles_data` (the map storage, the
/// duped symbol keys, and the candle slices themselves). Lives
/// ACROSS reloads - only reset on `force_refresh` or `deinit`.
/// This lets a soft reload reuse already-loaded candles instead
/// of re-reading the cache for every held symbol (~870ms warm-
/// cache cost dominated reload time before this).
///
/// Removed-from-portfolio symbols' slices stay in this arena
/// until the next `force_refresh` or `deinit` - accepted
/// trade-off (a few hundred KB at worst over a session) for
/// O(1) bulk free and no per-entry tracking.
candles_arena: ArenaAllocator,

io: std.Io,
svc: *DataService,

/// Cache policy for the current load. Candles and splits are warmed
/// synchronously inside `load()`, where `LoadOptions` is still in scope, so
/// only the dividends warm needs this: it runs inside a worker (to keep
/// Polygon's 4/min bucket off the first-paint path) and a worker cannot see
/// `opts`. Reset on every load/reload.
fetch_options: FetchOptions = .{},

/// Parsed portfolio file path(s). Arena-owned `[]const u8`
/// strings; arena-owned outer slice. `paths[0]` is the anchor
/// for sibling-file derivation. Empty before the first load.
paths: []const []const u8 = &.{},

/// Parsed portfolio.srf (lots, watchlist, classifications). The
/// loader uses one allocator for both kept and discarded data,
/// so this stays GPA-allocated. Freed in deinit + before reload.
file: ?Portfolio = null,

/// Computed summary (allocations, totals, gain/loss). Null
/// before the first successful load.
summary: ?PortfolioSummary = null,

/// Most recent quote date across the portfolio's held symbols.
/// Drives the "as of close on YYYY-MM-DD" line in the TUI.
latest_quote_date: ?Date = null,

/// True when the most recent load overlaid live intraday quotes onto
/// at least one held position (i.e. the summary reflects today's
/// quotes, not the candle close). Set only on the TUI refresh path
/// (`LoadOptions.live_quotes`); false on startup / `R` file reloads.
/// Drives the portfolio "as of" label wording.
live_prices_applied: bool = false,

/// Unix-seconds instant the live intraday quotes were fetched, when
/// `live_prices_applied` is true (else null). Threaded in from the
/// TUI refresh path via `LoadOptions.live_quotes_at_s`; drives the
/// precise "(as of H:MM PM ET)" portfolio footer stamp.
live_quotes_at_s: ?i64 = null,

/// Cached prices for watchlist symbols (no live fetching during
/// render). Allocated in pd's arena.
watchlist_prices: ?std.StringHashMap(f64) = null,

// ── Re-value (live-streaming) state ───────────────────────────
//
// Captured by `load` and reused by `revalue` for the cheap
// price-overlay re-value used during live streaming: apply streamed
// prices over the base candle-close prices and recompute only the
// summary - no file re-read, no worker respawn. Both fields live in
// the main `arena` (reaped on reload), so they are valid only between
// loads; `revalue` bails when they are empty/null.

/// Parsed positions from the last load (shallow arena copy; symbol
/// slices point into `file`, which outlives them). Empty before the
/// first load.
revalue_positions: []const zfin.Position = &.{},
/// Base (candle-close) prices from the last load, keyed by held symbol
/// with arena-duped keys. Null before the first load. A `revalue`
/// overlays streamed prices onto a copy of this.
revalue_base_prices: ?std.StringHashMap(f64) = null,
/// The equity portfolio's move over the session the current prices
/// belong to - the "Day" figure in the portfolio header. Null until the
/// candles worker has landed (day-over-day needs candle history), and
/// recomputed on every `revalue` so it tracks streamed ticks.
///
/// `_data` per this file's convention for worker-produced state: READ IT
/// VIA `dayChange()`, never directly. A direct read renders nothing on
/// the first paint and then has the figure appear once the worker lands -
/// which is precisely the bug that shipped.
///
/// See `valuation.DayChange`: it is keyed on the PRICED date, not on
/// today, so it stays correct outside live mode.
day_change_data: ?zfin.valuation.DayChange = null,
/// Dedicated arena for `revalue`'s working price map + recomputed
/// summary, reset at the start of each call so a long streaming
/// session can't grow memory unbounded. Lives across reloads; released
/// on `deinit`.
revalue_arena: ArenaAllocator,

// ── Async data (access via methods) ──────────────────────────
//
// Each datum has a (future, data) pair. The future is what
// `cancelLoad` cancels and what the matching method awaits.
// On cancelLoad(), every future is canceled and the data field
// is nulled. These fields are not part of the public API; use
// the methods.

candles_future: ?std.Io.Future(void) = null,
/// Per-symbol candle slices. Backed by `candles_arena`, so this
/// map and its contents survive `arena.reset()` on reload - the
/// candles worker only loads symbols that aren't already in the
/// map. Reset wholesale on `force_refresh` (via
/// `candles_arena.reset`) or on `deinit`.
candles_data: ?std.StringHashMap([]const Candle) = null,

snapshots_future: ?std.Io.Future(void) = null,
snapshots_data: ?[HistoricalPeriod.all.len]HistoricalSnapshot = null,

dividends_future: ?std.Io.Future(void) = null,
dividends_data: ?std.StringHashMap([]const Dividend) = null,

account_map_future: ?std.Io.Future(void) = null,
account_map_data: ?AccountMap = null,

classification_map_future: ?std.Io.Future(void) = null,
classification_map_data: ?ClassificationMap = null,

// ── Construction ──────────────────────────────────────────────

pub fn init(opts: InitOptions) PortfolioData {
    return .{
        .arena = .init(opts.gpa),
        .candles_arena = .init(opts.gpa),
        .revalue_arena = .init(opts.gpa),
        .io = opts.io,
        .svc = opts.svc,
    };
}

/// Tear down. Cancels any in-flight async work, frees the
/// parsed file, releases both arenas' pages back to the GPA.
pub fn deinit(self: *PortfolioData) void {
    self.cancelLoad();
    if (self.file) |*pf| pf.deinit();
    // candles_arena owns the candle map's storage, keys, and
    // slice values - single bulk free is enough; no need to
    // walk the map.
    self.candles_arena.deinit();
    self.revalue_arena.deinit();
    self.arena.deinit();
    self.* = undefined;
}

/// Internal arena allocator for everything portfolio-scoped
/// except `file`. Exposed so callers (tests, tabs that need
/// to allocate against the portfolio's lifetime) can attach
/// further arena allocations.
pub fn allocator(self: *PortfolioData) Allocator {
    return self.arena.allocator();
}

// ── Anchor path ──────────────────────────────────────────────

/// First (anchor) portfolio path, used for sibling-file
/// derivation (`accounts.srf`, `metadata.srf`,
/// `transaction_log.srf`, history dir). Returns null when no
/// portfolio is loaded.
pub fn anchorPath(self: *const PortfolioData) ?[]const u8 {
    if (self.paths.len == 0) return null;
    return self.paths[0];
}

// ── Async accessors ───────────────────────────────────────────
//
// Each method blocks on its data's worker future on first call;
// subsequent calls are sync. Returns null when no portfolio is
// loaded or the load was canceled.

/// Per-symbol cached candles. Blocks on the candles worker.
pub fn candles(self: *PortfolioData) ?*const std.StringHashMap([]const Candle) {
    self.awaitWorkerTimed(&self.candles_future, "candles");
    if (self.candles_data) |*m| return m;
    return null;
}

/// Historical-period snapshots (1y / 3y / 5y / etc.). Blocks on
/// the snapshots worker. Note that the snapshots worker
/// internally awaits the candles worker since snapshot
/// computation depends on candle data.
pub fn snapshots(self: *PortfolioData) ?[HistoricalPeriod.all.len]HistoricalSnapshot {
    self.awaitWorkerTimed(&self.snapshots_future, "snapshots");
    return self.snapshots_data;
}

/// The equity portfolio's move over the session the current prices belong
/// to. Blocks on the snapshots worker, which is where it is first computed
/// (day-over-day needs the candle history that worker waits on).
///
/// MUST be read through this rather than off the field: the field is null
/// until that worker lands, so a plain read renders nothing on the first
/// paint and then the figure silently appears once the user switches tabs
/// and comes back. `snapshots()` already pays this same await on the same
/// render, so there is no extra latency - the two land together.
///
/// `revalue` refreshes the field directly on every streamed tick
/// thereafter, by which point the future has long since resolved.
pub fn dayChange(self: *PortfolioData) ?zfin.valuation.DayChange {
    self.awaitWorkerTimed(&self.snapshots_future, "snapshots (from dayChange)");
    return self.day_change_data;
}

/// Per-symbol cached dividends. Blocks on the dividends worker.
pub fn dividends(self: *PortfolioData) ?*const std.StringHashMap([]const Dividend) {
    self.awaitWorkerTimed(&self.dividends_future, "dividends");
    if (self.dividends_data) |*m| return m;
    return null;
}

/// Account-tax-type metadata loaded from `accounts.srf`. Blocks
/// on the account-map worker. Returns null when there's no
/// portfolio loaded or `accounts.srf` doesn't exist / can't
/// be parsed.
pub fn accountMap(self: *PortfolioData) ?*const AccountMap {
    self.awaitWorker(&self.account_map_future);
    if (self.account_map_data) |*am| return am;
    return null;
}

/// Drop the cached account_map and re-spawn its worker so the
/// next `accountMap()` call re-reads `accounts.srf` from disk.
/// Used by the analysis-tab refresh (the user may have edited
/// accounts.srf since the last load). Re-spawn uses the same
/// delay as the original load (caller doesn't get to override
/// it on a refresh - the refresh is user-initiated and the
/// user wants the data soon).
pub fn invalidateAccountMap(self: *PortfolioData) void {
    if (self.account_map_future) |*f| _ = f.cancel(self.io);
    self.account_map_future = null;
    self.account_map_data = null;
    // Re-spawn with delay 0 - refresh is user-initiated.
    self.account_map_future = self.io.async(accountMapWorker, .{ self, @as(usize, 0) });
}

/// Per-symbol classification metadata loaded from `metadata.srf`.
/// Blocks on the classification-map worker. Returns null when
/// there's no portfolio loaded or `metadata.srf` doesn't exist /
/// can't be parsed.
///
/// Used by analysis_tab, review_tab, and the App-level symbol
/// overlay. Sharing a single PortfolioData-scoped copy avoids
/// the duplicate-load problem the per-tab caches had.
pub fn classificationMap(self: *PortfolioData) ?*const ClassificationMap {
    self.awaitWorker(&self.classification_map_future);
    if (self.classification_map_data) |*cm| return cm;
    return null;
}

/// Drop the cached classification_map and re-spawn its worker so
/// the next `classificationMap()` call re-reads `metadata.srf`
/// from disk. Re-spawn uses delay 0 (refresh is user-initiated).
///
/// The classification_map's storage lives in the per-load arena;
/// nulling the field is sufficient - the next `arena.reset()`
/// reaps the bytes. We don't call `cm.deinit()` here because
/// that would touch the arena and double-free at reset time.
pub fn invalidateClassificationMap(self: *PortfolioData) void {
    if (self.classification_map_future) |*f| _ = f.cancel(self.io);
    self.classification_map_future = null;
    self.classification_map_data = null;
    self.classification_map_future = self.io.async(classificationMapWorker, .{ self, @as(usize, 0) });
}

/// Prime ONLY the `metadata.srf` classification map for `paths_in`,
/// without the full `load()` (no price fetch, no summary, no
/// candle/dividend/account workers). Spawns the same
/// `classificationMapWorker` that `load()` uses, so a subsequent
/// `classificationMap()` resolves curated security names identically.
///
/// Used by the TUI's explicit-symbol launch (`zfin AAPL`), which skips
/// the portfolio load entirely but still wants the quote tab (and the
/// 'K' overlay) to show the `metadata.srf` security name the way the CLI
/// `quote` command does. No-op when `paths_in` is empty or a portfolio
/// is already loaded (a full `load()` populates the map itself).
///
/// Intentional side effect: capturing `paths_in` here also lets the `R`
/// (reload_portfolio) key run a first full `load()` from symbol mode -
/// see `portfolio_tab.reloadPortfolioFile` - so the user can "upgrade" a
/// symbol-only session into the full portfolio view on demand.
pub fn primeClassificationMap(self: *PortfolioData, paths_in: []const []const u8) void {
    if (paths_in.len == 0 or self.paths.len != 0) return;

    // Dupe the anchor paths into the per-load arena so `anchorPath()`
    // (and the worker's metadata.srf derivation) outlive `paths_in`.
    const arena_alloc = self.allocator();
    const paths_dup = arena_alloc.alloc([]const u8, paths_in.len) catch return;
    for (paths_in, 0..) |p, i| {
        paths_dup[i] = arena_alloc.dupe(u8, p) catch return;
    }
    self.paths = paths_dup;

    if (self.classification_map_future) |*f| _ = f.cancel(self.io);
    self.classification_map_future = self.io.async(classificationMapWorker, .{ self, @as(usize, 0) });
}

/// Drain a worker future. Idempotent (Future.await is itself
/// idempotent); safe to call every time.
fn awaitWorker(self: *PortfolioData, fut: *?std.Io.Future(void)) void {
    if (fut.*) |*f| {
        f.await(self.io);
        fut.* = null;
    }
}

fn timing(self: *PortfolioData, comptime fmt: []const u8, args: anytype) void {
    // `Config.timing` resolves ZFIN_TIMING once at init; this used to re-parse
    // the env var here, with an identical copy of the predicate in
    // `DataService.loadAllPrices`.
    if (!self.svc.config.timing) return;
    // `log.info`, not `log.debug`: release builds compile debug logging out, and
    // an instrument that vanishes in the build people install is no instrument.
    // Gated on the env var, so a normal run stays silent either way.
    log.info("timing: " ++ fmt, args);
}

/// Same, but reports how long the caller was blocked.
///
/// These awaits are the wait the user actually feels: `load` returns as soon as
/// prices are in, then the first render calls a blocking accessor and sits
/// there. `load`-phase timing cannot see any of it, which is why a run that
/// felt like ten seconds reported two.
fn awaitWorkerTimed(self: *PortfolioData, fut: *?std.Io.Future(void), name: []const u8) void {
    if (fut.* == null) return;
    const t0 = std.Io.Timestamp.now(self.io, .real);
    self.awaitWorker(fut);
    const ms = @divFloor(std.Io.Timestamp.now(self.io, .real).nanoseconds - t0.nanoseconds, std.time.ns_per_ms);
    if (ms >= 1) self.timing("blocked on {s} worker: {d}ms", .{ name, ms });
}

// ── Loading ──────────────────────────────────────────────────

/// Overlay live quotes onto the candle-derived price maps before the
/// summary is built. Held symbols (in `portfolio_set`) win in
/// `prices`; watchlist symbols (in `watchlist_set`) win in `wp` (keys
/// duped into `arena` so they outlive the load). Symbols in neither
/// set are ignored. Held puts may insert a new entry borrowing the
/// live map's key, which the caller guarantees outlives the summary
/// build. Factored out of `load` so the overlay logic is unit-testable
/// without the full load machinery.
///
/// Returns the number of HELD positions whose price was overlaid with
/// a live quote. The caller uses a non-zero count as the signal that
/// the portfolio summary now reflects live intraday prices rather than
/// the candle close (drives the "as of" label).
fn applyLiveQuoteOverlay(
    arena: Allocator,
    prices: *std.StringHashMap(f64),
    wp: *std.StringHashMap(f64),
    portfolio_set: *const std.StringHashMap(void),
    watchlist_set: *const std.StringHashMap(void),
    live: *const std.StringHashMap(f64),
) error{OutOfMemory}!usize {
    var held_overrides: usize = 0;
    var lit = live.iterator();
    while (lit.next()) |e| {
        const sym = e.key_ptr.*;
        const px = e.value_ptr.*;
        if (portfolio_set.contains(sym)) {
            // put() updates the value, keeping the existing
            // (load_all-borrowed) key when present; on insert it
            // borrows the live map's key, which outlives this call.
            prices.put(sym, px) catch return error.OutOfMemory;
            held_overrides += 1;
        } else if (watchlist_set.contains(sym)) {
            const owned = arena.dupe(u8, sym) catch continue;
            wp.put(owned, px) catch |err| log.warn("live watchlist price put({s}): {t}", .{ sym, err });
        }
    }
    return held_overrides;
}

/// Kick off a portfolio load. Synchronous: parses the portfolio
/// files, fetches prices, computes the summary, populates
/// `latest_quote_date` / `watchlist_prices`, then spawns four
/// background workers (candles, snapshots, dividends, account_map)
/// that finish in their own time.
///
/// Returns a LoadResult on success carrying counters + failed
/// symbol names for the caller's status UI. Returns LoadError
/// on failure; pd is left in a clean state (paths captured if
/// they were valid, but `summary == null` so callers branching on
/// `pd.summary` see nothing).
///
/// Calling load() while a previous load is in-flight cancels the
/// previous load first.
///
/// `paths_in` are duplicated into pd's arena; caller retains
/// ownership of the original slice.
pub fn load(
    self: *PortfolioData,
    paths_in: []const []const u8,
    today: Date,
    opts: LoadOptions,
) LoadError!LoadResult {
    self.cancelLoad();
    if (self.file) |*pf| pf.deinit();
    self.file = null;
    _ = self.arena.reset(.retain_capacity);
    self.paths = &.{};
    // The reset above reaped the previous load's re-value state (it
    // lived in this arena). Null it now so a stray `revalue` between
    // here and the recapture below can't read freed memory.
    self.revalue_positions = &.{};
    self.revalue_base_prices = null;
    self.day_change_data = null;
    self.summary = null;
    self.latest_quote_date = null;
    self.live_prices_applied = false;
    self.live_quotes_at_s = null;
    self.watchlist_prices = null;
    self.snapshots_data = null;
    self.dividends_data = null;
    self.account_map_data = null;
    self.classification_map_data = null;

    // Capture the cache policy for the workers that fetch (dividends).
    self.fetch_options = opts.fetch_options;

    // candles_data lives in candles_arena and survives across
    // reloads - kept entries are reused, only new symbols hit
    // the cache. force_refresh wipes it wholesale.
    if (opts.fetch_options.force_refresh) {
        _ = self.candles_arena.reset(.retain_capacity);
        self.candles_data = null;
    }

    if (paths_in.len == 0) return error.NoPaths;

    // Capture paths in the arena so reload() can re-use them.
    const arena_alloc = self.allocator();
    const paths_dup = arena_alloc.alloc([]const u8, paths_in.len) catch return error.OutOfMemory;
    for (paths_in, 0..) |p, i| {
        paths_dup[i] = arena_alloc.dupe(u8, p) catch return error.OutOfMemory;
    }
    self.paths = paths_dup;

    // Phase timing for everything that runs BEFORE the TUI paints. These
    // phases are synchronous by necessity - positions cannot be aggregated
    // until lots are split-adjusted, and the summary cannot be built until
    // prices are in - so any network work here is time the user spends looking
    // at an unchanged terminal. When that happens (typically the first run of a
    // day, as candle and split TTLs lapse), these lines are what identify which
    // phase to blame.
    const t_start = std.Io.Timestamp.now(self.io, .real);
    var t_mark = t_start;
    const Phase = struct {
        fn done(pd: *PortfolioData, mark: *std.Io.Timestamp, name: []const u8) void {
            const now = std.Io.Timestamp.now(pd.io, .real);
            const ms = @divFloor(now.nanoseconds - mark.nanoseconds, std.time.ns_per_ms);
            mark.* = now;
            if (ms >= 1) pd.timing("load phase {s}: {d}ms", .{ name, ms });
        }
    };

    // ── Parse portfolio files ────────────────────────────────
    const gpa = self.arena.child_allocator;
    const loaded = portfolio_loader.loadPortfolioFromPaths(self.io, gpa, self.paths, today) orelse
        return error.PortfolioParseFailed;
    // Take the parsed Portfolio; free the rest of LoadedPortfolio's
    // auxiliary data (we recompute positions and syms below from
    // our own arena, and the path strings are already captured in
    // self.paths).
    self.file = loaded.portfolio;
    gpa.free(loaded.syms);
    gpa.free(loaded.positions);
    for (loaded.file_datas) |d| gpa.free(d);
    gpa.free(loaded.file_datas);
    gpa.free(loaded.paths);
    if (loaded.resolved_paths) |rp| rp.deinit();

    const pf = self.file.?;
    Phase.done(self, &t_mark, "parse");

    // ── Compute symbols + positions ──────────────────────────
    // Symbols first: the split corpus is fetched per-symbol.
    const syms = pf.stockSymbols(gpa) catch return error.NoAllocations;
    defer gpa.free(syms);

    // Opt-in split adjustment before positions are aggregated, so the
    // TUI's positions and valuation carry effective shares. No-op
    // unless `splits_current_through` is set in metadata.srf.
    portfolio_loader.enrichLotsSplits(self.svc, gpa, pf.lots, syms, self.paths[0], today, self.fetch_options);
    Phase.done(self, &t_mark, "splits");

    const positions = pf.positions(today, gpa) catch return error.NoAllocations;
    defer gpa.free(positions);

    // ── Compute watchlist symbols ────────────────────────────
    //
    // Union of caller-supplied watchlist syms (typically from a separate
    // `watchlist.srf` file) and portfolio's own `.watch` lots, with held
    // symbols (already in `syms`) excluded so we never double-fetch.
    //
    // Shared with the CLI rather than reimplemented here. This was the third
    // hand-rolled copy of the same set logic, and the copies had drifted: the
    // CLI's omitted `watchlist.srf` entirely, so a watchlist-only symbol was
    // displayed from whatever the cache held and never fetched. One
    // implementation means that class of divergence cannot recur.
    //
    // Also now order-stable - watch lots then watchlist entries. The previous
    // HashMap-iteration build made the "[5/28] Loading X" progress order vary
    // between runs for no reason.
    const watch_syms_list = pf.extraPriceSymbols(gpa, syms, opts.watchlist_syms) catch return error.OutOfMemory;
    defer gpa.free(watch_syms_list);

    // Lookup sets for splitting the unified price map below. Derived FROM the
    // two symbol lists rather than rebuilt from the lots, so they cannot
    // disagree with what was actually fetched.
    var portfolio_set = std.StringHashMap(void).init(gpa);
    defer portfolio_set.deinit();
    for (syms) |s| portfolio_set.put(s, {}) catch return error.OutOfMemory;
    var watchlist_set = std.StringHashMap(void).init(gpa);
    defer watchlist_set.deinit();
    for (watch_syms_list) |s| watchlist_set.put(s, {}) catch return error.OutOfMemory;

    // ── Fetch prices ──────────────────────────────────────────
    //
    // Single parallel pass through `svc.loadAllPrices` covers
    // both portfolio and watchlist symbols. The returned map is
    // unified - we split it below by membership into the
    // portfolio-summary `prices` map and the
    // `watchlist_prices` map.
    const failed_syms_buf = arena_alloc.alloc([]const u8, 8) catch return error.OutOfMemory;
    var failed_n: usize = 0;
    var fail_capture = FailCapture{
        .pd = self,
        .buf = failed_syms_buf,
        .n = &failed_n,
        .user = opts.progress,
    };
    const sym_cb: ProgressCallback = .{
        .context = @ptrCast(&fail_capture),
        .on_progress = FailCapture.onProgress,
    };

    var load_all = self.svc.loadAllPrices(
        syms,
        watch_syms_list,
        opts.fetch_options,
        opts.aggregate_progress,
        sym_cb,
    );
    defer load_all.deinit();
    Phase.done(self, &t_mark, "prices");

    // Split the unified prices map: portfolio symbols go into
    // `prices` (for summary build below); watchlist symbols
    // go into `wp`. Dupe watchlist keys into the arena so they
    // outlive the loadAllResult we deinit at function exit.
    var prices = std.StringHashMap(f64).init(gpa);
    defer prices.deinit();
    var wp = std.StringHashMap(f64).init(arena_alloc);
    var price_iter = load_all.prices.iterator();
    while (price_iter.next()) |entry| {
        const k = entry.key_ptr.*;
        const v = entry.value_ptr.*;
        if (portfolio_set.contains(k)) {
            prices.put(k, v) catch return error.OutOfMemory;
        } else if (watchlist_set.contains(k)) {
            const owned = arena_alloc.dupe(u8, k) catch continue;
            wp.put(owned, v) catch |err| {
                // Free the just-duped key... except it's
                // arena-allocated, so the arena reaps it.
                // Skip this watchlist price; a missing
                // watchlist entry degrades display only.
                log.warn("watchlist price put({s}): {t}", .{ k, err });
            };
        }
    }
    self.latest_quote_date = load_all.latest_date;

    // Live-quote overlay: when the caller supplies current intraday
    // quotes (TUI refresh), they win over the candle-derived last
    // close for matching symbols. Held symbols overlay `prices` (fed
    // to the summary below); watchlist symbols overlay `wp`. Symbols
    // absent from the overlay keep their candle close - the candle
    // layer (built above) is always the fallback.
    if (opts.live_quotes) |live| {
        const held_overrides = try applyLiveQuoteOverlay(arena_alloc, &prices, &wp, &portfolio_set, &watchlist_set, live);
        // At least one held position is now priced from a live quote,
        // so the summary reflects today's intraday prices rather than
        // the candle close. Drives the portfolio "as of" label.
        self.live_prices_applied = held_overrides > 0;
        // Stamp the fetch instant only when the overlay actually took
        // effect, so the footer's precise "(as of H:MM PM ET)" reflects
        // a real re-price (not a no-op refresh).
        self.live_quotes_at_s = if (held_overrides > 0) opts.live_quotes_at_s else null;
    }
    self.watchlist_prices = wp;

    // Capture re-value state for live streaming BEFORE buildFallbackPrices
    // mutates `prices` (so the base is the candle-close + any startup
    // overlay, without the manual/avg-cost fallbacks that revalue
    // re-derives each call). Keys are duped into the arena since the
    // load_all-borrowed keys don't outlive this function.
    self.revalue_positions = dupePositions(arena_alloc, positions);
    self.revalue_base_prices = dupePricesArena(arena_alloc, &prices);

    // ── Build summary ─────────────────────────────────────────
    var manual_price_set = zfin.valuation.buildFallbackPrices(arena_alloc, pf.lots, positions, &prices) catch
        return error.OutOfMemory;
    defer manual_price_set.deinit();

    const sum = zfin.valuation.portfolioSummary(today, arena_alloc, pf, positions, prices, manual_price_set) catch
        return error.SummaryFailed;
    if (sum.allocations.len == 0) {
        return error.NoAllocations;
    }
    self.summary = sum;

    // ── Spawn workers ─────────────────────────────────────────
    // syms is GPA-scratch (freed at this function's return);
    // workers outlive that, so dupe into the arena.
    const syms_arena = arena_alloc.alloc([]const u8, syms.len) catch return error.OutOfMemory;
    for (syms, 0..) |s, i| {
        syms_arena[i] = arena_alloc.dupe(u8, s) catch s;
    }
    const positions_arena = dupePositions(arena_alloc, positions);

    // Build the candles to-load list: symbols not already in
    // candles_data. On a soft reload with no symbol changes,
    // this is empty and the worker exits ~instantly. On a fresh
    // load (or force_refresh), this is the full symbol set.
    // Allocated against the per-load arena - used only for the
    // duration of the worker's run.
    const candles_to_load = blk: {
        if (self.candles_data) |*existing| {
            const dst = arena_alloc.alloc([]const u8, syms_arena.len) catch
                return error.OutOfMemory;
            var n: usize = 0;
            for (syms_arena) |s| {
                if (!existing.contains(s)) {
                    dst[n] = s;
                    n += 1;
                }
            }
            break :blk dst[0..n];
        }
        break :blk syms_arena;
    };

    Phase.done(self, &t_mark, "summary");
    {
        const total_ms = @divFloor(std.Io.Timestamp.now(self.io, .real).nanoseconds - t_start.nanoseconds, std.time.ns_per_ms);
        self.timing("load TOTAL (synchronous, before first paint): {d}ms", .{total_ms});
    }

    self.candles_future = self.io.async(candlesWorker, .{ self, candles_to_load, opts.delays.candles_ms });
    self.snapshots_future = self.io.async(snapshotsWorker, .{ self, today, positions_arena, opts.delays.snapshots_ms });
    self.dividends_future = self.io.async(dividendsWorker, .{ self, opts.delays.dividends_ms });
    self.account_map_future = self.io.async(accountMapWorker, .{ self, opts.delays.account_map_ms });
    self.classification_map_future = self.io.async(classificationMapWorker, .{ self, opts.delays.classification_map_ms });

    return .{
        .cached_count = load_all.cached_count,
        .server_synced_count = load_all.server_synced_count,
        .provider_fetched_count = load_all.provider_fetched_count,
        .stale_count = load_all.stale_count,
        .failed_count = load_all.failed_count,
        .failed_syms = failed_syms_buf[0..failed_n],
        .latest_date = load_all.latest_date,
    };
}

/// Re-run the previous load against the captured paths. The
/// in-flight load (if any) is canceled first.
pub fn reload(self: *PortfolioData, today: Date, opts: LoadOptions) LoadError!LoadResult {
    if (self.paths.len == 0) return error.NoPaths;
    // Save the path strings to the GPA before load() resets the
    // arena. After the reset they'd be reaped underneath us.
    const gpa = self.arena.child_allocator;
    var saved_strs = gpa.alloc([]u8, self.paths.len) catch return error.OutOfMemory;
    defer {
        for (saved_strs) |s| gpa.free(s);
        gpa.free(saved_strs);
    }
    for (self.paths, 0..) |p, i| {
        saved_strs[i] = gpa.dupe(u8, p) catch return error.OutOfMemory;
    }
    const saved = gpa.alloc([]const u8, self.paths.len) catch return error.OutOfMemory;
    defer gpa.free(saved);
    for (saved_strs, 0..) |s, i| saved[i] = s;
    return self.load(saved, today, opts);
}

/// Cheap live re-value: apply a streamed-price `overlay` over the base
/// (candle-close) prices captured by the last `load`, then recompute
/// ONLY the summary. No file I/O, no worker respawn - safe to call on a
/// sub-second tick while streaming.
///
/// `overlay` maps held symbol -> latest price; its keys must match the
/// summary's price keys (held symbols), and non-matching keys are
/// ignored. Symbols absent from the overlay keep their base price, so
/// non-streaming assets (mutual funds, CDs, cash) stay put.
///
/// Returns true when the summary was recomputed; false (a no-op) before
/// the first successful load, or if the previous load captured no
/// re-value state. On any internal failure the previous summary is left
/// intact and false is returned.
pub fn revalue(self: *PortfolioData, today: Date, overlay: *const std.StringHashMap(f64)) bool {
    const pf = self.file orelse return false;
    const base = if (self.revalue_base_prices) |*b| b else return false;
    const positions = self.revalue_positions;
    if (positions.len == 0) return false;

    // Reset bounds memory across a long streaming session: each call
    // reuses the same arena for its working price map and the new
    // summary; the previous call's summary is reaped here.
    _ = self.revalue_arena.reset(.retain_capacity);
    const ra = self.revalue_arena.allocator();

    // Working prices = base candle-close prices, with the streamed
    // overlay applied over matching held symbols.
    var prices = std.StringHashMap(f64).init(ra);
    var bit = base.iterator();
    while (bit.next()) |e| prices.put(e.key_ptr.*, e.value_ptr.*) catch return false;
    // Streamed overlay: override a matching base price, or INSERT a new
    // one. The insert path matters for holdings with no candle history
    // (e.g. 24/7 crypto like BTC-USD) - otherwise they'd fall back to
    // avg cost and silently ignore the live tick.
    var oit = overlay.iterator();
    while (oit.next()) |e| prices.put(e.key_ptr.*, e.value_ptr.*) catch return false;

    const manual_price_set = zfin.valuation.buildFallbackPrices(ra, pf.lots, positions, &prices) catch return false;
    const sum = zfin.valuation.portfolioSummary(today, ra, pf, positions, prices, manual_price_set) catch return false;
    if (sum.allocations.len == 0) return false;

    self.summary = sum;
    // Flip the "as of" wording when the overlay re-priced at least one
    // held position (mirrors the load-overlay path).
    var applied = false;
    for (positions) |pos| {
        if (pos.shares > 0 and overlay.contains(pos.symbol)) {
            applied = true;
            break;
        }
    }
    self.live_prices_applied = applied;
    self.recomputeDayChange(today, prices, manual_price_set);
    return true;
}

/// Recompute `day_change_data` from the price map the summary was just built
/// from, so the two can never disagree about what a position is worth.
///
/// Silently leaves `day_change_data` alone when the inputs aren't there yet:
/// the candles map arrives on a background worker, so on a cold start the
/// header simply omits the figure until it lands - the same gating the
/// `Historical:` line already has.
///
/// The session being reported is the one the CURRENT prices belong to, not
/// today: with a live overlay applied that is today, otherwise it is the
/// newest quote date across held symbols. Getting this wrong is what makes
/// a weekend view report a $0.00 day - see `valuation.DayChange`.
fn recomputeDayChange(
    self: *PortfolioData,
    today: Date,
    prices: std.StringHashMap(f64),
    manual_prices: ?std.StringHashMap(void),
) void {
    const candle_map = self.candles_data orelse return;
    if (self.revalue_positions.len == 0) return;
    // Lots are needed only to tell a hand-priced holding (permanently
    // un-day-changeable, so not a gap) from a security that should have
    // priced and didn't.
    const pf = self.file orelse return;
    const priced_date = if (self.live_prices_applied)
        today
    else
        self.latest_quote_date orelse return;

    self.day_change_data = zfin.valuation.computeDayChange(
        priced_date,
        self.revalue_positions,
        pf.lots,
        prices,
        manual_prices,
        candle_map,
    );
}

/// Cancel any in-flight load and pending background workers.
/// Safe to call at any time including when nothing is in-flight.
/// After cancel, snapshots / dividends / account_map data is
/// nulled - `pd.snapshots()` etc. return null without blocking.
///
/// `candles_data` is intentionally NOT nulled: any partial
/// entries the candles worker managed to populate before
/// cancel are valid (they came from successful cache reads),
/// and they'll be reused on the next load. The candles_arena
/// retains them; the next force_refresh or deinit reaps.
///
/// Cancellation order matters: snapshots depends on candles
/// (snapshotsWorker internally awaits the candles future).
/// Cancel snapshots FIRST so its worker exits before we
/// cancel the candles future - otherwise we'd race `cancel`
/// against `await` on the same future.
pub fn cancelLoad(self: *PortfolioData) void {
    // Cancel DEPENDENCIES BEFORE DEPENDENTS. `snapshotsWorker` opens by
    // draining `candles_future`, and `awaitWorker` is a plain await, not a
    // cancellable wait - so cancelling snapshots first blocks until the candle
    // worker finishes on its own. On the first run of a day every symbol's
    // latest candle has expired, which turned quitting the TUI into a wait for
    // the whole price refresh. Cancelling candles first lets its per-symbol
    // cancel check fire, which unblocks the snapshots await immediately.
    if (self.candles_future) |*f| _ = f.cancel(self.io);
    self.candles_future = null;
    if (self.snapshots_future) |*f| _ = f.cancel(self.io);
    self.snapshots_future = null;
    self.snapshots_data = null;
    // Derived from the snapshots worker's inputs, so it dies with them.
    self.day_change_data = null;
    if (self.dividends_future) |*f| _ = f.cancel(self.io);
    self.dividends_future = null;
    self.dividends_data = null;
    if (self.account_map_future) |*f| _ = f.cancel(self.io);
    self.account_map_future = null;
    self.account_map_data = null;
    if (self.classification_map_future) |*f| _ = f.cancel(self.io);
    self.classification_map_future = null;
    self.classification_map_data = null;
}

// ── Internal: workers ────────────────────────────────────────
//
// Each worker:
//   1. Sleeps for its `delay_ms` (cancellable; cancel during
//      delay returns error.Canceled and the worker exits).
//   2. Does its work.
//   3. Stores the result on pd.
//
// Errors during work are absorbed silently - the corresponding
// data field stays null, and the accessor method returns null.
// Callers are expected to handle null gracefully (degrade UX,
// not crash).

fn candlesWorker(self: *PortfolioData, to_load: []const []const u8, delay_ms: usize) void {
    self.io.sleep(.fromMilliseconds(@intCast(delay_ms)), .real) catch return;

    // Lazy-init the persistent candle map on first use.
    // Subsequent loads find it non-null and append.
    const candles_alloc = self.candles_arena.allocator();
    if (self.candles_data == null) {
        self.candles_data = std.StringHashMap([]const Candle).init(candles_alloc);
    }
    var map = &self.candles_data.?;

    for (to_load) |sym| {
        self.io.checkCancel() catch return;
        if (self.svc.getCachedCandles(candles_alloc, sym)) |cs| {
            // Dupe the symbol key into candles_arena so it
            // outlives the per-load arena that owns `to_load`.
            const key = candles_alloc.dupe(u8, sym) catch continue;
            map.put(key, cs.data) catch continue;
        }
    }
}

fn snapshotsWorker(self: *PortfolioData, as_of: Date, positions: []const zfin.Position, delay_ms: usize) void {
    self.io.sleep(.fromMilliseconds(@intCast(delay_ms)), .real) catch return;
    // Snapshots depend on the candles map. Drain the candles
    // future first; if it was canceled (or never produced
    // data), the map is null and we abort.
    self.awaitWorkerTimed(&self.candles_future, "candles (from snapshots worker)");
    // The await above returns as soon as candles is cancelled, which is the
    // point of cancelling it first - but that means arriving here mid-teardown
    // is normal. Bail rather than spending the compute on a result nobody will
    // read.
    self.io.checkCancel() catch return;
    const candle_map = self.candles_data orelse return;

    // `computeHistoricalSnapshots` wants RAW base-ticker prices - it applies
    // each position's `price_ratio` itself. `revalue_base_prices` is exactly
    // that map (candle closes, captured in `load` before the fallback
    // derivation), and it is what the CLI path passes too, so both paths now
    // agree by construction.
    //
    // This used to rebuild the map from `summary.allocations[].current_price`,
    // which is NOT raw: for an unmerged allocation that price already has the
    // lot's ratio folded in, so applying `price_ratio` again squared it. The
    // historical side comes from raw candles and was correct, so the ratio
    // between the two was wrong and every percentage on the TUI's
    // `Historical:` line broke for a lone `ticker::` + `price_ratio::` lot.
    // See the "Per-LOT display rows" block in `models/portfolio.zig` for the
    // same trap in the lot-row renderers.
    const prices = self.revalue_base_prices orelse return;

    self.snapshots_data = zfin.valuation.computeHistoricalSnapshots(
        as_of,
        positions,
        prices,
        // `prices` here is the pre-fallback candle-close map, so it holds no
        // overrides and needs no manual set.
        null,
        candle_map,
    );

    // First chance to compute the day change: it needs candle history for
    // the prior close, which is what this worker just waited on. `revalue`
    // refreshes it on every streamed tick thereafter.
    //
    // `prices` here is the pre-fallback base map, so a position priced only
    // by a manual override or avg-cost fallback is absent and lands in
    // `positions_total` without contributing - which is honest, since
    // neither has a day-over-day meaning. Hence no manual set to pass.
    self.recomputeDayChange(as_of, prices, null);
}

/// Warm the dividend cache, then read it into the map.
///
/// The warm lives here rather than in `load()` on purpose. Polygon serves
/// dividends from a 4/min bucket, so a cold cache with no `ZFIN_SERVER`
/// could take minutes - unacceptable in front of a first paint. Inside the
/// worker, that cost only lands when a consumer calls `dividends()` and
/// blocks on the future, which today is the review tab alone. `load()`
/// still initiates it, matching every other datum.
///
/// Unconditional: unlike split adjustment, dividends have no per-symbol
/// opt-in to gate on.
fn dividendsWorker(self: *PortfolioData, delay_ms: usize) void {
    self.io.sleep(.fromMilliseconds(@intCast(delay_ms)), .real) catch return;
    const summary_ref = self.summary orelse return;
    const arena_alloc = self.allocator();

    // Warm first: `getCachedDividends` below cannot populate the cache, so
    // without this the map silently omits every symbol never fetched by a
    // per-symbol command. The warm is best-effort - on allocation failure
    // we skip it and still read whatever is already cached, because a
    // failed optimization must not cost us the data we already have.
    //
    // Skipped entirely under `skip_network`: there is nothing to warm, the
    // read loop below already covers the cache, and going through
    // `getDividends` would emit a "stale-cached returned (skip_network)"
    // info line per symbol for no benefit.
    warm: {
        if (self.fetch_options.skip_network) break :warm;
        const div_syms = arena_alloc.alloc([]const u8, summary_ref.allocations.len) catch break :warm;
        for (summary_ref.allocations, div_syms) |alloc, *dst| dst.* = alloc.symbol;
        self.io.checkCancel() catch return;
        self.svc.loadAllDividends(div_syms, self.fetch_options);
    }

    var map = std.StringHashMap([]const Dividend).init(arena_alloc);
    for (summary_ref.allocations) |alloc| {
        self.io.checkCancel() catch return;
        if (self.svc.getCachedDividends(arena_alloc, alloc.symbol)) |fr| {
            map.put(alloc.symbol, fr.data) catch continue;
        }
    }
    self.dividends_data = map;
}

fn accountMapWorker(self: *PortfolioData, delay_ms: usize) void {
    self.io.sleep(.fromMilliseconds(@intCast(delay_ms)), .real) catch return;
    const ppath = self.anchorPath() orelse return;
    self.account_map_data = self.svc.loadAccountMap(self.allocator(), ppath);
}

fn classificationMapWorker(self: *PortfolioData, delay_ms: usize) void {
    self.io.sleep(.fromMilliseconds(@intCast(delay_ms)), .real) catch return;
    const ppath = self.anchorPath() orelse return;
    // Derive metadata.srf path: same directory as the portfolio
    // anchor. Mirrors the per-tab loaders we're consolidating.
    const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0;
    const meta_path = std.fmt.allocPrint(self.arena.child_allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return;
    defer self.arena.child_allocator.free(meta_path);

    const file_data = std.Io.Dir.cwd().readFileAlloc(self.io, meta_path, self.arena.child_allocator, .limited(1024 * 1024)) catch return;
    defer self.arena.child_allocator.free(file_data);

    self.classification_map_data = zfin.classification.parseClassificationFile(self.allocator(), file_data) catch null;
}

fn dupePositions(arena: Allocator, src: []const zfin.Position) []const zfin.Position {
    const dup = arena.alloc(zfin.Position, src.len) catch return &.{};
    @memcpy(dup, src);
    return dup;
}

/// Copy a price map into `arena` with arena-duped keys, so it outlives
/// the (load_all-borrowed) source keys. Used to capture the base prices
/// for `revalue`. Best-effort: a failed entry is skipped (a missing
/// base price just degrades that position to its fallback in revalue).
fn dupePricesArena(arena: Allocator, src: *std.StringHashMap(f64)) std.StringHashMap(f64) {
    var out = std.StringHashMap(f64).init(arena);
    var it = src.iterator();
    while (it.next()) |e| {
        const k = arena.dupe(u8, e.key_ptr.*) catch continue;
        out.put(k, e.value_ptr.*) catch continue;
    }
    return out;
}

/// Adapter that captures the first 8 failed symbols into pd's
/// arena while still forwarding to the caller's progress callback.
const FailCapture = struct {
    pd: *PortfolioData,
    buf: [][]const u8,
    n: *usize,
    user: ?ProgressCallback,

    fn onProgress(
        ctx: *anyopaque,
        idx: usize,
        total: usize,
        symbol: []const u8,
        status: DataService.SymbolStatus,
    ) void {
        const self: *FailCapture = @ptrCast(@alignCast(ctx));
        switch (status) {
            .failed, .failed_used_stale => {
                if (self.n.* < self.buf.len) {
                    const owned = self.pd.allocator().dupe(u8, symbol) catch return;
                    self.buf[self.n.*] = owned;
                    self.n.* += 1;
                }
            },
            else => {},
        }
        if (self.user) |u| u.on_progress(u.context, idx, total, symbol, status);
    }
};

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

const testing = std.testing;

test "PortfolioData.init: all-null state" {
    var svc: DataService = .{
        .allocator = testing.allocator,
        .io = testing.io,
        .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
    };
    var pd = PortfolioData.init(.{
        .gpa = testing.allocator,
        .io = testing.io,
        .svc = &svc,
    });
    defer pd.deinit();

    try testing.expect(pd.summary == null);
    try testing.expect(pd.file == null);
    try testing.expect(pd.latest_quote_date == null);
    try testing.expect(pd.watchlist_prices == null);
    try testing.expect(pd.anchorPath() == null);
    try testing.expectEqual(@as(usize, 0), pd.paths.len);
}

test "PortfolioData.cancelLoad: idempotent on idle state" {
    var svc: DataService = .{
        .allocator = testing.allocator,
        .io = testing.io,
        .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
    };
    var pd = PortfolioData.init(.{
        .gpa = testing.allocator,
        .io = testing.io,
        .svc = &svc,
    });
    defer pd.deinit();

    pd.cancelLoad();
    pd.cancelLoad(); // second time is a no-op
    // After cancel: futures cleared; per-load data fields
    // (snapshots, dividends, account_map) nulled. candles_data
    // is intentionally NOT cleared by cancel - it persists
    // across reloads. Idle pd has it null because nothing has
    // ever populated it.
    try testing.expect(pd.candles_future == null);
    try testing.expect(pd.candles_data == null);
    try testing.expect(pd.dividends_future == null);
    try testing.expect(pd.dividends_data == null);
    try testing.expect(pd.snapshots_future == null);
    try testing.expect(pd.snapshots_data == null);
    try testing.expect(pd.account_map_future == null);
    try testing.expect(pd.account_map_data == null);
    try testing.expect(pd.classification_map_future == null);
    try testing.expect(pd.classification_map_data == null);
}

test "PortfolioData.dayChange: reads through the accessor, not the raw field" {
    // The regression: the portfolio header read the raw field as a plain
    // field. It is populated by the snapshots worker, so on the FIRST paint
    // it was still null and the session-change line was simply missing -
    // then appeared once the user switched tabs and came back and the
    // worker had landed. `snapshots()` avoids that by awaiting the same
    // future; `dayChange()` must do the same.
    var svc: DataService = .{
        .allocator = testing.allocator,
        .io = testing.io,
        .config = .{ .cache_dir = "./.tmp/zfin-pd-daychange-cache" },
    };
    var pd = PortfolioData.init(.{ .gpa = testing.allocator, .io = testing.io, .svc = &svc });
    defer pd.deinit();

    // Nothing loaded: null, and no hang on the absent future.
    try testing.expect(pd.dayChange() == null);

    // Once the worker has produced a figure, the accessor surfaces it.
    pd.day_change_data = .{
        .priced_date = Date.fromYmd(2026, 8, 26),
        .change = -7180.04,
        .prev_stock_value = 100_000,
        .curr_stock_value = 92_819.96,
        .positions_covered = 23,
        .positions_priceable = 23,
        .positions_total = 25,
        .uncovered_value = 0,
    };
    const dc = pd.dayChange() orelse return error.TestUnexpectedResult;
    try testing.expectApproxEqAbs(@as(f64, -7180.04), dc.change, 0.001);
    try testing.expect(dc.hasData());
    // 23 of 23 POSSIBLE positions covered; the other 2 are hand-priced and
    // can never be day-changed, so this counts as complete and the header
    // shows no shortfall marker.
    try testing.expect(dc.complete());

    // cancelLoad drops it with the rest of the worker-derived state.
    pd.cancelLoad();
    try testing.expect(pd.dayChange() == null);
}

test "PortfolioData.primeClassificationMap: spawns the classification worker without a full load" {
    var svc: DataService = .{
        .allocator = testing.allocator,
        .io = testing.io,
        .config = .{ .cache_dir = "./.tmp/zfin-pd-prime-cache" },
    };
    var pd = PortfolioData.init(.{ .gpa = testing.allocator, .io = testing.io, .svc = &svc });
    defer pd.deinit();

    // Explicit-symbol launch shape: nothing loaded yet, so the map is
    // null - exactly the state that left the TUI quote tab nameless.
    try testing.expectEqual(@as(usize, 0), pd.paths.len);
    try testing.expect(pd.classification_map_future == null);
    try testing.expect(pd.classificationMap() == null);

    // Empty paths: no-op (still nothing loaded).
    pd.primeClassificationMap(&.{});
    try testing.expectEqual(@as(usize, 0), pd.paths.len);
    try testing.expect(pd.classification_map_future == null);

    // A real anchor sets the path and spawns the SAME worker `load()`
    // uses, so a later `classificationMap()` reads metadata.srf and
    // resolves the curated name the way the CLI does.
    pd.primeClassificationMap(&.{"./.tmp/zfin-pd-prime-test/portfolio.srf"});
    try testing.expectEqual(@as(usize, 1), pd.paths.len);
    try testing.expect(pd.classification_map_future != null);
    try testing.expectEqualStrings("./.tmp/zfin-pd-prime-test/portfolio.srf", pd.anchorPath().?);

    // Already primed: a second call must not clobber the loaded paths.
    pd.primeClassificationMap(&.{"./.tmp/other/portfolio.srf"});
    try testing.expectEqual(@as(usize, 1), pd.paths.len);
    try testing.expectEqualStrings("./.tmp/zfin-pd-prime-test/portfolio.srf", pd.anchorPath().?);

    // Drain the spawned worker so teardown leaves no dangling future.
    _ = pd.classificationMap();
}

test "PortfolioData.candles: returns null after cancelLoad with no data" {
    var svc: DataService = .{
        .allocator = testing.allocator,
        .io = testing.io,
        .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
    };
    var pd = PortfolioData.init(.{
        .gpa = testing.allocator,
        .io = testing.io,
        .svc = &svc,
    });
    defer pd.deinit();
    pd.cancelLoad();
    try testing.expect(pd.candles() == null);
    try testing.expect(pd.dividends() == null);
    try testing.expect(pd.snapshots() == null);
    try testing.expect(pd.accountMap() == null);
    try testing.expect(pd.classificationMap() == null);
}

test "PortfolioData.load: NoPaths error on empty paths slice" {
    var svc: DataService = .{
        .allocator = testing.allocator,
        .io = testing.io,
        .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
    };
    var pd = PortfolioData.init(.{
        .gpa = testing.allocator,
        .io = testing.io,
        .svc = &svc,
    });
    defer pd.deinit();

    try testing.expectError(error.NoPaths, pd.load(&.{}, Date.fromYmd(2026, 1, 1), .{}));
}

test "PortfolioData.load: the cache policy is captured for the fetching workers" {
    // Regression: `LoadOptions` used to carry `force_refresh`/`skip_network`
    // as loose bools, so every call site re-assembled them and a site that
    // forgot got network access it had been told not to use. `reload` was
    // exactly such a site, putting a `--refresh-data=never` TUI session back
    // online on refresh. Carrying the whole `FetchOptions` makes the policy
    // impossible to drop silently, and this pins that it reaches the field
    // the dividends worker reads.
    var svc: DataService = .{
        .allocator = testing.allocator,
        .io = testing.io,
        .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
    };
    var pd = PortfolioData.init(.{
        .gpa = testing.allocator,
        .io = testing.io,
        .svc = &svc,
    });
    defer pd.deinit();

    // Default is network-allowed.
    try testing.expect(!pd.fetch_options.skip_network);

    // load() captures the policy before it can fail on paths, so offline
    // intent survives even a failed load.
    try testing.expectError(error.NoPaths, pd.load(&.{}, Date.fromYmd(2026, 1, 1), .{
        .fetch_options = .{ .skip_network = true },
    }));
    try testing.expect(pd.fetch_options.skip_network);
}

test "PortfolioData.reload: NoPaths error before any successful load" {
    var svc: DataService = .{
        .allocator = testing.allocator,
        .io = testing.io,
        .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
    };
    var pd = PortfolioData.init(.{
        .gpa = testing.allocator,
        .io = testing.io,
        .svc = &svc,
    });
    defer pd.deinit();

    try testing.expectError(error.NoPaths, pd.reload(Date.fromYmd(2026, 1, 1), .{}));
}

test "PortfolioData.invalidateAccountMap: nulls the cached map" {
    var svc: DataService = .{
        .allocator = testing.allocator,
        .io = testing.io,
        .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
    };
    var pd = PortfolioData.init(.{
        .gpa = testing.allocator,
        .io = testing.io,
        .svc = &svc,
    });
    defer pd.deinit();

    pd.account_map_data = AccountMap{
        .entries = &.{},
        .allocator = pd.allocator(),
    };
    try testing.expect(pd.account_map_data != null);

    pd.invalidateAccountMap();
    // Data is nulled. A fresh worker is spawned with delay=0 so
    // the next accountMap() call will block briefly waiting for
    // the loadAccountMap call against the (still-null) anchor
    // path. We don't await it here; deinit will cancel.
    try testing.expect(pd.account_map_data == null);
    try testing.expect(pd.account_map_future != null);
}

test "PortfolioData.anchorPath: returns null when no paths captured" {
    var svc: DataService = .{
        .allocator = testing.allocator,
        .io = testing.io,
        .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
    };
    var pd = PortfolioData.init(.{
        .gpa = testing.allocator,
        .io = testing.io,
        .svc = &svc,
    });
    defer pd.deinit();
    try testing.expect(pd.anchorPath() == null);
}

test "PortfolioData.anchorPath: returns first path when paths populated" {
    var svc: DataService = .{
        .allocator = testing.allocator,
        .io = testing.io,
        .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
    };
    var pd = PortfolioData.init(.{
        .gpa = testing.allocator,
        .io = testing.io,
        .svc = &svc,
    });
    defer pd.deinit();

    // Synthesize a paths slice in the arena (mirrors what
    // load() does internally). Caller never sets paths
    // directly; this is white-box testing the accessor.
    const arena = pd.allocator();
    const dup = try arena.alloc([]const u8, 2);
    dup[0] = try arena.dupe(u8, "/example/portfolio.srf");
    dup[1] = try arena.dupe(u8, "/example/portfolio_other.srf");
    pd.paths = dup;

    try testing.expectEqualStrings("/example/portfolio.srf", pd.anchorPath().?);
}

test "PortfolioData.candles: returns null before any load" {
    var svc: DataService = .{
        .allocator = testing.allocator,
        .io = testing.io,
        .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
    };
    var pd = PortfolioData.init(.{
        .gpa = testing.allocator,
        .io = testing.io,
        .svc = &svc,
    });
    defer pd.deinit();
    // No load happened, no worker future. The accessor's
    // awaitWorker is a no-op when the future is null, and the
    // data field is null, so the accessor returns null without
    // blocking.
    try testing.expect(pd.candles() == null);
    try testing.expect(pd.dividends() == null);
    try testing.expect(pd.snapshots() == null);
    try testing.expect(pd.accountMap() == null);
}

test "PortfolioData.WorkerDelays: defaults are all zero" {
    const d: WorkerDelays = .{};
    try testing.expectEqual(@as(usize, 0), d.snapshots_ms);
    try testing.expectEqual(@as(usize, 0), d.candles_ms);
    try testing.expectEqual(@as(usize, 0), d.dividends_ms);
    try testing.expectEqual(@as(usize, 0), d.account_map_ms);
    try testing.expectEqual(@as(usize, 0), d.classification_map_ms);
}

// ── Persistent candle cache tests ────────────────────────────
//
// candles_data lives in candles_arena and survives reloads.
// These tests white-box that property without spinning up a
// real DataService - pre-populating the map directly and
// inspecting state.

test "applyLiveQuoteOverlay: held wins in prices, watchlist in wp, others ignored" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const a = arena.allocator();

    // Candle-derived base layer.
    var prices = std.StringHashMap(f64).init(testing.allocator);
    defer prices.deinit();
    try prices.put("AAPL", 100.0); // held; live should override
    var wp = std.StringHashMap(f64).init(testing.allocator);
    defer wp.deinit();
    try wp.put("TSLA", 200.0); // watchlist; live should override

    var portfolio_set = std.StringHashMap(void).init(testing.allocator);
    defer portfolio_set.deinit();
    try portfolio_set.put("AAPL", {});
    try portfolio_set.put("MSFT", {}); // held, no base price yet

    var watchlist_set = std.StringHashMap(void).init(testing.allocator);
    defer watchlist_set.deinit();
    try watchlist_set.put("TSLA", {});

    var live = std.StringHashMap(f64).init(testing.allocator);
    defer live.deinit();
    try live.put("AAPL", 111.0); // held override
    try live.put("MSFT", 222.0); // held insert (no prior base)
    try live.put("TSLA", 333.0); // watchlist override
    try live.put("NVDA", 999.0); // in neither set -> ignored

    const held_overrides = try applyLiveQuoteOverlay(a, &prices, &wp, &portfolio_set, &watchlist_set, &live);

    // AAPL (override) + MSFT (insert) are held; TSLA is watchlist;
    // NVDA is neither. Only held positions count toward the signal
    // that the summary now reflects live intraday prices.
    try testing.expectEqual(@as(usize, 2), held_overrides);
    try testing.expectEqual(@as(f64, 111.0), prices.get("AAPL").?);
    try testing.expectEqual(@as(f64, 222.0), prices.get("MSFT").?);
    try testing.expectEqual(@as(f64, 333.0), wp.get("TSLA").?);
    // NVDA is neither held nor watchlisted - it must not leak into
    // either map (it would have no shares/row to attach to).
    try testing.expect(!prices.contains("NVDA"));
    try testing.expect(!wp.contains("NVDA"));
}

test "applyLiveQuoteOverlay: watchlist-only live quotes report zero held overrides" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const a = arena.allocator();

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

    var portfolio_set = std.StringHashMap(void).init(testing.allocator);
    defer portfolio_set.deinit();
    try portfolio_set.put("AAPL", {}); // held, but no live quote arrives

    var watchlist_set = std.StringHashMap(void).init(testing.allocator);
    defer watchlist_set.deinit();
    try watchlist_set.put("TSLA", {});

    var live = std.StringHashMap(f64).init(testing.allocator);
    defer live.deinit();
    try live.put("TSLA", 333.0); // watchlist only

    const held_overrides = try applyLiveQuoteOverlay(a, &prices, &wp, &portfolio_set, &watchlist_set, &live);

    // No held position got a live price, so the summary still reflects
    // the candle close -> the "as of" label must stay date-based.
    try testing.expectEqual(@as(usize, 0), held_overrides);
    try testing.expectEqual(@as(f64, 333.0), wp.get("TSLA").?);
    try testing.expect(!prices.contains("AAPL"));
}

test "PortfolioData.candles_data: deinit frees candles_arena cleanly" {
    var svc: DataService = .{
        .allocator = testing.allocator,
        .io = testing.io,
        .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
    };
    var pd = PortfolioData.init(.{
        .gpa = testing.allocator,
        .io = testing.io,
        .svc = &svc,
    });

    // Populate candles_data manually using candles_arena. With
    // testing.allocator's leak detection, deinit must reclaim
    // every byte.
    const ca = pd.candles_arena.allocator();
    pd.candles_data = std.StringHashMap([]const Candle).init(ca);
    const key1 = try ca.dupe(u8, "VTI");
    const key2 = try ca.dupe(u8, "BND");
    const slice1 = try ca.alloc(Candle, 2);
    slice1[0] = .{
        .date = Date.fromYmd(2026, 1, 1),
        .open = 1.0,
        .high = 1.0,
        .low = 1.0,
        .close = 1.0,
        .adj_close = 1.0,
        .volume = 0,
    };
    slice1[1] = slice1[0];
    const slice2 = try ca.alloc(Candle, 1);
    slice2[0] = slice1[0];
    try pd.candles_data.?.put(key1, slice1);
    try pd.candles_data.?.put(key2, slice2);

    pd.deinit(); // must not leak
}

test "PortfolioData.candles_data: force_refresh resets the candles arena" {
    var svc: DataService = .{
        .allocator = testing.allocator,
        .io = testing.io,
        .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
    };
    var pd = PortfolioData.init(.{
        .gpa = testing.allocator,
        .io = testing.io,
        .svc = &svc,
    });
    defer pd.deinit();

    // Pre-populate.
    const ca = pd.candles_arena.allocator();
    pd.candles_data = std.StringHashMap([]const Candle).init(ca);
    const key = try ca.dupe(u8, "VTI");
    const slice = try ca.alloc(Candle, 0);
    try pd.candles_data.?.put(key, slice);
    try testing.expect(pd.candles_data != null);

    // load() with force_refresh=true and empty paths should
    // wipe candles_data before failing on NoPaths. The
    // candles_arena is reset, candles_data is nulled.
    try testing.expectError(error.NoPaths, pd.load(&.{}, Date.fromYmd(2026, 1, 1), .{ .fetch_options = .{ .force_refresh = true } }));
    try testing.expect(pd.candles_data == null);
}

test "PortfolioData.candles_data: soft load preserves the candle map" {
    var svc: DataService = .{
        .allocator = testing.allocator,
        .io = testing.io,
        .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
    };
    var pd = PortfolioData.init(.{
        .gpa = testing.allocator,
        .io = testing.io,
        .svc = &svc,
    });
    defer pd.deinit();

    // Pre-populate.
    const ca = pd.candles_arena.allocator();
    pd.candles_data = std.StringHashMap([]const Candle).init(ca);
    const key = try ca.dupe(u8, "VTI");
    const slice = try ca.alloc(Candle, 0);
    try pd.candles_data.?.put(key, slice);

    // Soft load with empty paths fails on NoPaths but does NOT
    // touch candles_data (force_refresh = false).
    try testing.expectError(error.NoPaths, pd.load(&.{}, Date.fromYmd(2026, 1, 1), .{}));
    try testing.expect(pd.candles_data != null);
    try testing.expect(pd.candles_data.?.contains("VTI"));
}

test "PortfolioData.revalue: overlays streamed prices and recomputes the total" {
    var svc: DataService = .{
        .allocator = testing.allocator,
        .io = testing.io,
        .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
    };
    var pd = PortfolioData.init(.{ .gpa = testing.allocator, .io = testing.io, .svc = &svc });
    defer {
        // `file` below is an empty stack Portfolio, not heap-owned; clear
        // it so deinit doesn't try to free it.
        pd.file = null;
        pd.deinit();
    }

    var positions = [_]zfin.Position{
        .{ .symbol = "AAPL", .shares = 10, .avg_cost = 150.0, .total_cost = 1500.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
        .{ .symbol = "SPY", .shares = 5, .avg_cost = 400.0, .total_cost = 2000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
    };
    pd.file = .{ .lots = &.{}, .allocator = testing.allocator };
    pd.revalue_positions = &positions;

    var base = std.StringHashMap(f64).init(testing.allocator);
    defer base.deinit();
    try base.put("AAPL", 200.0);
    try base.put("SPY", 700.0);
    pd.revalue_base_prices = base;

    const today = Date.fromYmd(2026, 5, 8);

    // No overlay: total = 10*200 + 5*700 = 5500; nothing overridden.
    var no_overlay = std.StringHashMap(f64).init(testing.allocator);
    defer no_overlay.deinit();
    try testing.expect(pd.revalue(today, &no_overlay));
    try testing.expectApproxEqAbs(@as(f64, 5500.0), pd.summary.?.total_value, 0.01);
    try testing.expect(!pd.live_prices_applied);

    // Overlay AAPL -> 210: total = 10*210 + 5*700 = 5600.
    var overlay = std.StringHashMap(f64).init(testing.allocator);
    defer overlay.deinit();
    try overlay.put("AAPL", 210.0);
    try testing.expect(pd.revalue(today, &overlay));
    try testing.expectApproxEqAbs(@as(f64, 5600.0), pd.summary.?.total_value, 0.01);
    try testing.expect(pd.live_prices_applied);

    // A repeat call reuses the revalue arena (memory stays bounded) and
    // yields the same result - exercises the per-call arena reset.
    try testing.expect(pd.revalue(today, &overlay));
    try testing.expectApproxEqAbs(@as(f64, 5600.0), pd.summary.?.total_value, 0.01);
}

test "PortfolioData.revalue: prices a candle-less holding from the overlay" {
    var svc: DataService = .{
        .allocator = testing.allocator,
        .io = testing.io,
        .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
    };
    var pd = PortfolioData.init(.{ .gpa = testing.allocator, .io = testing.io, .svc = &svc });
    defer {
        pd.file = null;
        pd.deinit();
    }

    // A 24/7 holding with NO candle history (absent from base prices).
    var positions = [_]zfin.Position{
        .{ .symbol = "BTC-USD", .shares = 2, .avg_cost = 50000.0, .total_cost = 100000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
    };
    pd.file = .{ .lots = &.{}, .allocator = testing.allocator };
    pd.revalue_positions = &positions;

    var base = std.StringHashMap(f64).init(testing.allocator); // empty: no candles
    defer base.deinit();
    pd.revalue_base_prices = base;

    const today = Date.fromYmd(2026, 5, 8);

    // No overlay -> avg-cost fallback: 2 * 50000 = 100000.
    var no_overlay = std.StringHashMap(f64).init(testing.allocator);
    defer no_overlay.deinit();
    try testing.expect(pd.revalue(today, &no_overlay));
    try testing.expectApproxEqAbs(@as(f64, 100000.0), pd.summary.?.total_value, 0.01);
    try testing.expect(!pd.live_prices_applied);

    // Overlay BTC-USD -> 60000 (inserted, not in base): 2 * 60000 = 120000.
    var overlay = std.StringHashMap(f64).init(testing.allocator);
    defer overlay.deinit();
    try overlay.put("BTC-USD", 60000.0);
    try testing.expect(pd.revalue(today, &overlay));
    try testing.expectApproxEqAbs(@as(f64, 120000.0), pd.summary.?.total_value, 0.01);
    try testing.expect(pd.live_prices_applied);
}

test "PortfolioData.revalue: no-op before any load" {
    var svc: DataService = .{
        .allocator = testing.allocator,
        .io = testing.io,
        .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
    };
    var pd = PortfolioData.init(.{ .gpa = testing.allocator, .io = testing.io, .svc = &svc });
    defer pd.deinit();

    var overlay = std.StringHashMap(f64).init(testing.allocator);
    defer overlay.deinit();
    // No positions / base prices captured -> false, and summary stays null.
    try testing.expect(!pd.revalue(Date.fromYmd(2026, 5, 8), &overlay));
    try testing.expect(pd.summary == null);
}

test "PortfolioData.revalue: returns false when the recompute yields no allocations" {
    var svc: DataService = .{
        .allocator = testing.allocator,
        .io = testing.io,
        .config = .{ .cache_dir = "./.tmp/zfin-pd-test-cache" },
    };
    var pd = PortfolioData.init(.{ .gpa = testing.allocator, .io = testing.io, .svc = &svc });
    defer {
        pd.file = null;
        pd.deinit();
    }

    // A single zero-share position: portfolioSummary skips shares <= 0,
    // so the summary has no allocations and revalue reports failure
    // (leaving any prior summary untouched).
    var positions = [_]zfin.Position{
        .{ .symbol = "ZERO", .shares = 0, .avg_cost = 0, .total_cost = 0, .open_lots = 0, .closed_lots = 0, .realized_gain_loss = 0 },
    };
    pd.file = .{ .lots = &.{}, .allocator = testing.allocator };
    pd.revalue_positions = &positions;
    var base = std.StringHashMap(f64).init(testing.allocator);
    defer base.deinit();
    pd.revalue_base_prices = base;

    var overlay = std.StringHashMap(f64).init(testing.allocator);
    defer overlay.deinit();
    try testing.expect(!pd.revalue(Date.fromYmd(2026, 5, 8), &overlay));
}

test "dupePricesArena: copies entries with arena-owned keys" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const a = arena.allocator();

    var src = std.StringHashMap(f64).init(testing.allocator);
    defer src.deinit();
    try src.put("AAPL", 1.5);
    try src.put("SPY", 2.5);

    // Copy lives in the arena (keys duped there), so it survives `src`
    // being freed; reaped wholesale by arena.deinit (no out.deinit).
    var out = dupePricesArena(a, &src);
    src.clearAndFree(); // drop the source + its keys

    try testing.expectEqual(@as(usize, 2), out.count());
    try testing.expectApproxEqAbs(@as(f64, 1.5), out.get("AAPL").?, 0.0001);
    try testing.expectApproxEqAbs(@as(f64, 2.5), out.get("SPY").?, 0.0001);
}
