//! 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;

// ── 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,
    /// True forces re-fetch of every symbol regardless of cache
    /// TTL; false honors TTLs. Maps to
    /// `DataService.LoadAllConfig.force_refresh`.
    force_refresh: bool = false,
    /// Skip provider fetches and server sync entirely. Returns
    /// cached data (even if stale); cache miss treated as failure.
    /// Maps to `DataService.LoadAllConfig.skip_network`.
    skip_network: bool = false,
    /// 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,
    /// 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,

/// 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,

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

// ── 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),
        .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.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.awaitWorker(&self.candles_future);
    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.awaitWorker(&self.snapshots_future);
    return self.snapshots_data;
}

/// Per-symbol cached dividends. Blocks on the dividends worker.
pub fn dividends(self: *PortfolioData) ?*const std.StringHashMap([]const Dividend) {
    self.awaitWorker(&self.dividends_future);
    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) });
}

/// 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;
    }
}

// ── 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 = &.{};
    self.summary = null;
    self.latest_quote_date = null;
    self.live_prices_applied = false;
    self.watchlist_prices = null;
    self.snapshots_data = null;
    self.dividends_data = null;
    self.account_map_data = null;
    self.classification_map_data = null;

    // 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.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;

    // ── 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.?;

    // ── Compute positions + symbols ──────────────────────────
    const positions = pf.positions(today, gpa) catch return error.NoAllocations;
    defer gpa.free(positions);

    const syms = pf.stockSymbols(gpa) catch return error.NoAllocations;
    defer gpa.free(syms);

    // ── 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.
    var watchlist_set = std.StringHashMap(void).init(gpa);
    defer watchlist_set.deinit();
    var portfolio_set = std.StringHashMap(void).init(gpa);
    defer portfolio_set.deinit();
    for (syms) |s| portfolio_set.put(s, {}) catch return error.OutOfMemory;
    for (opts.watchlist_syms) |sym| {
        if (!portfolio_set.contains(sym)) watchlist_set.put(sym, {}) catch return error.OutOfMemory;
    }
    for (pf.lots) |lot| {
        if (lot.security_type == .watch) {
            const sym = lot.priceSymbol();
            if (!portfolio_set.contains(sym)) watchlist_set.put(sym, {}) catch return error.OutOfMemory;
        }
    }
    var watch_syms_list: std.ArrayList([]const u8) = .empty;
    defer watch_syms_list.deinit(gpa);
    {
        var it = watchlist_set.keyIterator();
        while (it.next()) |k| watch_syms_list.append(gpa, k.*) 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.items,
        .{
            .force_refresh = opts.force_refresh,
            .skip_network = opts.skip_network,
            .color = false, // pd doesn't render; caller's progress callback owns UI.
        },
        opts.aggregate_progress,
        sym_cb,
    );
    defer load_all.deinit();

    // 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;
    }
    self.watchlist_prices = wp;

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

    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);
}

/// 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 {
    if (self.snapshots_future) |*f| _ = f.cancel(self.io);
    self.snapshots_future = null;
    self.snapshots_data = null;
    if (self.candles_future) |*f| _ = f.cancel(self.io);
    self.candles_future = 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.awaitWorker(&self.candles_future);
    const candle_map = self.candles_data orelse return;
    const summary_ref = self.summary orelse return;

    var prices = std.StringHashMap(f64).init(self.arena.child_allocator);
    defer prices.deinit();
    for (summary_ref.allocations) |alloc| {
        prices.put(alloc.symbol, alloc.current_price) catch return;
    }

    self.snapshots_data = zfin.valuation.computeHistoricalSnapshots(
        as_of,
        positions,
        prices,
        candle_map,
    );
}

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

/// 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.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.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), .{ .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"));
}
