//! DataService -- unified data access layer for zfin.
//!
//! Encapsulates the "check cache -> fresh? return -> else fetch from provider -> cache -> return"
//! pattern that was previously duplicated between CLI and TUI. Both frontends should use this
//! as their sole data source.
//!
//! Provider selection is internal: each data type routes to the appropriate provider
//! based on available API keys. Callers never need to know which provider was used.

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

const Date = @import("Date.zig");
const Candle = @import("models/candle.zig").Candle;
const Dividend = @import("models/dividend.zig").Dividend;
const Split = @import("models/split.zig").Split;
const OptionsChain = @import("models/option.zig").OptionsChain;
const EarningsEvent = @import("models/earnings.zig").EarningsEvent;
const Quote = @import("models/quote.zig").Quote;
const EtfProfile = @import("models/etf_profile.zig").EtfProfile;
const Config = @import("Config.zig");
const cache = @import("cache/store.zig");
const srf = @import("srf");
const analysis = @import("analytics/analysis.zig");
const transaction_log = @import("models/transaction_log.zig");
const TwelveData = @import("providers/twelvedata.zig").TwelveData;
const Polygon = @import("providers/polygon.zig").Polygon;
const Fmp = @import("providers/fmp.zig").Fmp;
const Cboe = @import("providers/cboe.zig").Cboe;
const AlphaVantage = @import("providers/alphavantage.zig").AlphaVantage;
const alphavantage = @import("providers/alphavantage.zig");
const OpenFigi = @import("providers/openfigi.zig");
const Yahoo = @import("providers/yahoo.zig").Yahoo;
const Tiingo = @import("providers/tiingo.zig").Tiingo;
const fmt = @import("format.zig");
const performance = @import("analytics/performance.zig");
const http = @import("net/http.zig");
const atomic = @import("atomic.zig");

// ── Wall-clock policy ────────────────────────────────────────
//
// `FetchResult.timestamp` records when a given fetch or cached-read
// completed. Each `std.Io.Timestamp.now(self.io, .real)` call in
// this file stamps one specific fetch — a single command invocation
// produces many fetches, each with its own real-time stamp. Threading
// `now_s` in from the caller would collapse all per-fetch timestamps to
// the command-entry time, which is not what callers want when they
// display "fetched 3s ago" for some symbols and "cached 2d ago" for
// others in the same command.

pub const DataError = error{
    NoApiKey,
    FetchFailed,
    CacheError,
    ParseError,
    OutOfMemory,
    /// Transient provider failure (server error, connection issue).
    /// Caller should stop and retry later.
    TransientError,
    /// Provider auth failure (bad API key). Entire refresh should stop.
    AuthError,
};

/// Decide whether a provider failure is permanent enough to merit a
/// negative-cache entry. Negative entries suppress retries until the
/// next manual `--refresh-data=force` / `cache clear`, so writing one is only
/// safe when we're confident more attempts won't succeed.
///
/// Today the only certain-permanent failure is `NotFound`: the symbol
/// just doesn't have data of this type at this provider. Everything
/// else (rate limit, network blip, server 5xx, auth, parse error) is
/// either transient or fixable; recording a negative entry would
/// silently suppress retries for hours/days.
///
/// Rate-limit (`error.RateLimited`) is excluded here because callers
/// handle it specially (single retry after backoff). Anything that
/// reaches this classifier and isn't `NotFound` returns false →
/// caller returns `FetchFailed` without poisoning the cache.
pub fn isPermanentProviderFailure(err: anyerror) bool {
    return err == error.NotFound;
}

/// Re-exported provider types needed by commands via DataService.
pub const CompanyOverview = alphavantage.CompanyOverview;

/// Result of a CUSIP-to-ticker lookup (provider-agnostic).
pub const CusipResult = OpenFigi.FigiResult;

/// Indicates whether the returned data came from cache or was freshly fetched.
pub const Source = enum {
    cached,
    fetched,
};

/// Generic result type for all fetch operations: data payload + provenance metadata.
///
/// `data` is owned by `allocator` — call `result.deinit()` to release
/// it (both the outer slice/struct and any nested owned fields). This
/// replaces the earlier "caller frees with whatever allocator they
/// happen to have" pattern, which was error-prone when the caller's
/// allocator (e.g. an arena) differed from the service's allocator.
pub fn FetchResult(comptime T: type) type {
    return struct {
        data: cache.Store.DataFor(T),
        source: Source,
        timestamp: i64,
        /// Allocator that owns `data`. Populated by the service on
        /// every return path; callers use it via `deinit` rather than
        /// touching it directly.
        allocator: std.mem.Allocator,

        /// Free `data` and any nested owned fields.
        ///
        /// Dispatches at comptime:
        ///   - If `T` has a `freeSlice` helper (Dividend, OptionsChain),
        ///     call it — handles element deinit plus the outer slice.
        ///   - Else if `data` is a slice (Candle, Split, EarningsEvent),
        ///     do a simple slice free.
        ///   - Else if `T` has a `deinit` method (EtfProfile), call it
        ///     on the struct itself.
        pub fn deinit(self: @This()) void {
            const DT = @TypeOf(self.data);
            if (@hasDecl(T, "freeSlice")) {
                T.freeSlice(self.allocator, self.data);
            } else if (@typeInfo(DT) == .pointer) {
                self.allocator.free(self.data);
            } else if (@hasDecl(T, "deinit")) {
                self.data.deinit(self.allocator);
            }
        }
    };
}

// ── PostProcess callbacks ────────────────────────────────────
// These are passed to Store.read to handle type-specific
// concerns: string duping (serialization plumbing) and domain transforms.

/// Dupe the currency string so it outlives the SRF iterator's backing buffer.
fn dividendPostProcess(div: *Dividend, allocator: std.mem.Allocator) anyerror!void {
    if (div.currency) |c| {
        div.currency = try allocator.dupe(u8, c);
    }
}

/// Recompute surprise/surprise_percent from actual and estimate fields.
/// SRF only stores actual and estimate; surprise is derived.
fn earningsPostProcess(ev: *EarningsEvent, _: std.mem.Allocator) anyerror!void {
    if (ev.actual != null and ev.estimate != null) {
        ev.surprise = ev.actual.? - ev.estimate.?;
        if (ev.estimate.? != 0) {
            ev.surprise_percent = (ev.surprise.? / @abs(ev.estimate.?)) * 100.0;
        }
    }
}

pub const DataService = struct {
    /// Thread-safe wrapper over the caller-provided base allocator.
    ///
    /// Why this exists: `parallelServerSync` spawns worker threads that
    /// each allocate through `DataService` — HTTP client init, TLS cert
    /// bundle parsing, request/response buffers, and `Store.writeRaw`
    /// path joins. The CLI's root allocator is an `ArenaAllocator`
    /// (`src/main.zig`), which is NOT thread-safe. Unsynchronized
    /// concurrent allocs from workers corrupt the arena's free list.
    /// Symptoms seen in the wild:
    ///
    ///   thread N panic: reached unreachable code
    ///     std/mem/Allocator.zig:147    grow
    ///     std/hash_map.zig:1296        addCertsFromFile
    ///     std/crypto/Certificate/Bundle.zig:206 request
    ///     std/http/Client.zig:1789     request
    ///     src/net/http.zig:43          syncFromServer
    ///
    /// and bare segfaults mid-heap on whatever pointer the arena
    /// scrambled that run.
    ///
    /// The wrapper serializes every allocation with a mutex. Cost is
    /// one lock acquire/release per alloc — negligible next to the I/O
    /// Thread-safe allocator used for all DataService-internal allocations.
    ///
    /// In Zig 0.16, the Juicy-Main-provided `init.gpa` (DebugAllocator)
    /// is thread-safe by default when not single-threaded, and
    /// `ArenaAllocator` is thread-safe and lock-free. Callers should
    /// pass whichever thread-safe allocator is appropriate — we no
    /// longer wrap it ourselves.
    ///
    /// DO NOT add an "unwrap" method or pass a non-thread-safe
    /// allocator. The point is that internal callers don't need to
    /// know whether they're running under threads — the allocator
    /// itself guarantees safety.
    allocator: std.mem.Allocator,
    io: std.Io,
    config: Config,

    // Lazily initialized providers (null until first use)
    td: ?TwelveData = null,
    pg: ?Polygon = null,
    fmp: ?Fmp = null,
    cboe: ?Cboe = null,
    av: ?AlphaVantage = null,
    yh: ?Yahoo = null,
    tg: ?Tiingo = null,

    pub fn init(io: std.Io, allocator: std.mem.Allocator, config: Config) DataService {
        const self = DataService{
            .allocator = allocator,
            .io = io,
            .config = config,
        };
        // Missing-key warnings are noise under `zig build test` where
        // every test that spins up a DataService re-emits the whole
        // block. Real users always see them at CLI/TUI startup.
        if (!builtin.is_test) self.logMissingKeys();
        return self;
    }

    /// Log warnings for missing API keys so users know which features are unavailable.
    fn logMissingKeys(self: DataService) void {
        // Primary candle provider
        if (self.config.tiingo_key == null) {
            log.warn("TIINGO_API_KEY not set — candle data will fall back to TwelveData/Yahoo", .{});
        }
        // Dividend/split data
        if (self.config.polygon_key == null) {
            log.warn("POLYGON_API_KEY not set — dividend and split data unavailable", .{});
        }
        // Earnings data
        if (self.config.fmp_key == null) {
            log.warn("FMP_API_KEY not set — earnings data unavailable", .{});
        }
        // ETF profiles
        if (self.config.alphavantage_key == null) {
            log.warn("ALPHAVANTAGE_API_KEY not set — ETF profiles unavailable", .{});
        }
        // Candle fallback
        if (self.config.twelvedata_key == null and self.config.tiingo_key == null) {
            log.warn("TWELVEDATA_API_KEY not set — no candle fallback if Yahoo fails", .{});
        }
        // CUSIP lookups
        if (self.config.openfigi_key == null) {
            log.info("OPENFIGI_API_KEY not set — CUSIP lookups will use anonymous rate limits", .{});
        }
    }

    pub fn deinit(self: *DataService) void {
        if (self.td) |*td| td.deinit();
        if (self.pg) |*pg| pg.deinit();
        if (self.fmp) |*fmp| fmp.deinit();
        if (self.cboe) |*c| c.deinit();
        if (self.av) |*av| av.deinit();
        if (self.yh) |*yh| yh.deinit();
        if (self.tg) |*tg| tg.deinit();
    }

    // ── Provider accessor ──────────────────────────────────────────

    fn getProvider(self: *DataService, comptime T: type) DataError!*T {
        const field_name = comptime providerField(T);
        if (@field(self, field_name)) |*p| return p;
        if (T == Cboe or T == Yahoo) {
            // CBOE and Yahoo have no API key
            @field(self, field_name) = T.init(self.io, self.allocator);
        } else {
            // All we're doing here is lower casing the type name, then
            // appending _key to it, so AlphaVantage -> alphavantage_key
            const config_key = comptime blk: {
                const full = @typeName(T);
                var start: usize = 0;
                for (full, 0..) |c, i| {
                    if (c == '.') start = i + 1;
                }
                const short = full[start..];
                var buf: [short.len + 4]u8 = undefined;
                _ = std.ascii.lowerString(buf[0..short.len], short);
                @memcpy(buf[short.len..][0..4], "_key");
                break :blk buf[0 .. short.len + 4];
            };
            const key = @field(self.config, config_key) orelse return DataError.NoApiKey;
            @field(self, field_name) = T.init(self.io, self.allocator, key);
        }
        return &@field(self, field_name).?;
    }

    fn providerField(comptime T: type) []const u8 {
        inline for (std.meta.fields(DataService)) |f| {
            if (f.type == ?T) return f.name;
        }
        @compileError("unknown provider type");
    }

    // ── Cache helper ─────────────────────────────────────────────

    fn store(self: *DataService) cache.Store {
        return cache.Store.init(self.io, self.allocator, self.config.cache_dir);
    }

    /// Generic fetch-or-cache for simple data types (dividends, splits, options).
    /// Checks cache first; on miss, fetches from the appropriate provider,
    /// writes to cache, and returns. On permanent fetch failure, writes a negative
    /// cache entry. Rate limit failures are retried once.
    fn fetchCached(
        self: *DataService,
        comptime T: type,
        symbol: []const u8,
        comptime postProcess: ?*const fn (*T, std.mem.Allocator) anyerror!void,
    ) DataError!FetchResult(T) {
        var s = self.store();
        const data_type = comptime cache.Store.dataTypeFor(T);

        if (s.read(T, symbol, postProcess, .fresh_only)) |cached| {
            log.debug("{s}: {s} fresh in local cache", .{ symbol, @tagName(data_type) });
            return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator };
        }

        // Try server sync before hitting providers
        if (self.syncFromServer(symbol, data_type)) {
            if (s.read(T, symbol, postProcess, .fresh_only)) |cached| {
                log.debug("{s}: {s} synced from server and fresh", .{ symbol, @tagName(data_type) });
                return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator };
            }
            log.debug("{s}: {s} synced from server but stale, falling through to provider", .{ symbol, @tagName(data_type) });
        }

        log.debug("{s}: fetching {s} from provider", .{ symbol, @tagName(data_type) });
        const fetched = self.fetchFromProvider(T, symbol) catch |err| {
            if (err == error.RateLimited) {
                // Wait and retry once
                self.rateLimitBackoff();
                const retried = self.fetchFromProvider(T, symbol) catch {
                    return DataError.FetchFailed;
                };
                s.write(T, symbol, retried, data_type.ttl());
                return .{ .data = retried, .source = .fetched, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator };
            }
            // Only NotFound (provider says "this symbol genuinely has
            // no data of this type") gets a negative-cache entry.
            // Transient failures (network, 5xx, auth misconfig, parse
            // error) propagate as FetchFailed without poisoning the
            // cache, so the next call retries naturally.
            if (isPermanentProviderFailure(err)) {
                s.writeNegative(symbol, data_type);
            }
            return DataError.FetchFailed;
        };

        s.write(T, symbol, fetched, data_type.ttl());
        return .{ .data = fetched, .source = .fetched, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator };
    }

    /// Dispatch a fetch to the correct provider based on model type.
    fn fetchFromProvider(self: *DataService, comptime T: type, symbol: []const u8) !cache.Store.DataFor(T) {
        return switch (T) {
            Dividend => {
                var pg = try self.getProvider(Polygon);
                return pg.fetchDividends(self.allocator, symbol, null, null);
            },
            Split => {
                var pg = try self.getProvider(Polygon);
                return pg.fetchSplits(self.allocator, symbol);
            },
            OptionsChain => {
                var cboe = try self.getProvider(Cboe);
                return cboe.fetchOptionsChain(self.allocator, symbol);
            },
            else => @compileError("unsupported type for fetchFromProvider"),
        };
    }

    /// Invalidate cached data for a symbol so the next get* call forces a fresh fetch.
    pub fn invalidate(self: *DataService, symbol: []const u8, data_type: cache.DataType) void {
        var s = self.store();
        s.clearData(symbol, data_type);
        // Also clear candle metadata when invalidating candle data
        if (data_type == .candles_daily) {
            s.clearData(symbol, .candles_meta);
        }
    }

    // ── Public data methods ──────────────────────────────────────

    /// Fetch candles from providers with error classification.
    ///
    /// Error handling:
    ///   - ServerError/RateLimited/RequestFailed from Tiingo → TransientError (stop refresh, retry later)
    ///   - NotFound/ParseError/InvalidResponse from Tiingo → try Yahoo (symbol-level issue)
    ///   - Unauthorized → TransientError (config problem, stop refresh)
    ///
    /// The `preferred` param controls incremental fetch consistency: use the same
    /// provider that sourced the existing cache data.
    fn fetchCandlesFromProviders(
        self: *DataService,
        symbol: []const u8,
        from: Date,
        to: Date,
        preferred: cache.Store.CandleProvider,
    ) (DataError || error{NotFound})!struct { candles: []Candle, provider: cache.Store.CandleProvider } {
        // If preferred is Yahoo (degraded symbol), try Yahoo first
        if (preferred == .yahoo) {
            if (self.getProvider(Yahoo)) |yh| {
                if (yh.fetchCandles(self.allocator, symbol, from, to)) |candles| {
                    log.debug("{s}: candles from Yahoo (preferred)", .{symbol});
                    return .{ .candles = candles, .provider = .yahoo };
                } else |err| {
                    log.warn("{s}: Yahoo (preferred) failed: {s}", .{ symbol, @errorName(err) });
                }
            } else |_| {}
        }

        // Primary: Tiingo
        if (self.getProvider(Tiingo)) |tg| {
            if (tg.fetchCandles(self.allocator, symbol, from, to)) |candles| {
                log.debug("{s}: candles from Tiingo", .{symbol});
                return .{ .candles = candles, .provider = .tiingo };
            } else |err| {
                log.warn("{s}: Tiingo failed: {s}", .{ symbol, @errorName(err) });

                if (err == error.Unauthorized) {
                    log.err("{s}: Tiingo auth failed — check TIINGO_API_KEY", .{symbol});
                    return DataError.AuthError;
                }

                if (err == error.RateLimited) {
                    // Rate limited: back off and retry — this is expected, not a failure
                    log.info("{s}: Tiingo rate limited, backing off", .{symbol});
                    self.rateLimitBackoff();
                    if (tg.fetchCandles(self.allocator, symbol, from, to)) |candles| {
                        log.debug("{s}: candles from Tiingo (after rate limit backoff)", .{symbol});
                        return .{ .candles = candles, .provider = .tiingo };
                    } else |retry_err| {
                        log.warn("{s}: Tiingo retry after backoff failed: {s}", .{ symbol, @errorName(retry_err) });
                        if (retry_err == error.RateLimited) {
                            // Still rate limited after backoff — one more try
                            self.rateLimitBackoff();
                            if (tg.fetchCandles(self.allocator, symbol, from, to)) |candles| {
                                log.debug("{s}: candles from Tiingo (after second backoff)", .{symbol});
                                return .{ .candles = candles, .provider = .tiingo };
                            } else |_| {}
                        }
                        // Exhausted rate limit retries — treat as transient
                        return DataError.TransientError;
                    }
                }

                if (isTransientError(err)) {
                    // Server error or connection failure — stop, don't fall back
                    return DataError.TransientError;
                }

                // NotFound, ParseError, InvalidResponse — symbol-level issue, try Yahoo
                log.info("{s}: Tiingo does not have this symbol, trying Yahoo", .{symbol});
            }
        } else |_| {
            log.warn("{s}: Tiingo provider not available (no API key?)", .{symbol});
        }

        // Fallback: Yahoo (symbol not on Tiingo)
        if (preferred != .yahoo) {
            if (self.getProvider(Yahoo)) |yh| {
                if (yh.fetchCandles(self.allocator, symbol, from, to)) |candles| {
                    log.info("{s}: candles from Yahoo (Tiingo fallback)", .{symbol});
                    return .{ .candles = candles, .provider = .yahoo };
                } else |err| {
                    log.warn("{s}: Yahoo fallback also failed: {s}", .{ symbol, @errorName(err) });
                }
            } else |_| {
                log.warn("{s}: Yahoo provider not available", .{symbol});
            }
        }

        return DataError.FetchFailed;
    }

    /// Classify whether a provider error is transient (provider is down).
    /// ServerError = HTTP 5xx, RequestFailed = connection/network failure.
    /// Note: RateLimited and Unauthorized are handled separately.
    fn isTransientError(err: anyerror) bool {
        return err == error.ServerError or
            err == error.RequestFailed;
    }

    /// Fetch daily candles for a symbol (10+ years for trailing returns).
    /// Checks cache first; fetches from Tiingo (primary) or Yahoo (fallback) if stale/missing.
    /// Uses incremental updates: when the cache is stale, only fetches
    /// candles newer than the last cached date rather than re-fetching
    /// the entire history.
    pub fn getCandles(self: *DataService, symbol: []const u8) DataError!FetchResult(Candle) {
        var s = self.store();
        const today = fmt.todayDate(self.io);

        // Check candle metadata for freshness (tiny file, no candle deserialization)
        const meta_result = s.readCandleMeta(symbol);
        if (meta_result) |mr| {
            const m = mr.meta;

            // If cached data is from TwelveData (deprecated for candles due to
            // unreliable adj_close), skip cache and fall through to full re-fetch.
            if (m.provider == .twelvedata) {
                log.debug("{s}: cached candles from TwelveData — forcing full re-fetch", .{symbol});
            } else if (s.isCandleMetaFresh(symbol)) {
                // Fresh — deserialize candles and return
                log.debug("{s}: candles fresh in local cache", .{symbol});
                if (s.read(Candle, symbol, null, .any)) |r|
                    return .{ .data = r.data, .source = .cached, .timestamp = mr.created, .allocator = self.allocator };
            } else {
                // Stale — try server sync before incremental fetch
                if (self.syncCandlesFromServer(symbol)) {
                    if (s.isCandleMetaFresh(symbol)) {
                        log.debug("{s}: candles synced from server and fresh", .{symbol});
                        if (s.read(Candle, symbol, null, .any)) |r|
                            return .{ .data = r.data, .source = .cached, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator };
                    }
                    log.debug("{s}: candles synced from server but stale, falling through to incremental fetch", .{symbol});
                }

                // Stale — try incremental update using last_date from meta
                const fetch_from = m.last_date.addDays(1);

                // If last cached date is today or later, just refresh the TTL (meta only)
                if (!fetch_from.lessThan(today)) {
                    s.updateCandleMeta(symbol, m.last_close, m.last_date, m.provider, m.fail_count);
                    if (s.read(Candle, symbol, null, .any)) |r|
                        return .{ .data = r.data, .source = .cached, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator };
                } else {
                    // Incremental fetch from day after last cached candle
                    const result = self.fetchCandlesFromProviders(symbol, fetch_from, today, m.provider) catch |err| {
                        if (err == DataError.TransientError) {
                            // Increment fail_count for this symbol
                            const new_fail_count = m.fail_count +| 1; // saturating add
                            log.warn("{s}: transient failure (fail_count now {d})", .{ symbol, new_fail_count });
                            s.updateCandleMeta(symbol, m.last_close, m.last_date, m.provider, new_fail_count);

                            // If degraded (fail_count >= 3), return stale data rather than failing
                            if (new_fail_count >= 3) {
                                log.warn("{s}: degraded after {d} consecutive failures, returning stale data", .{ symbol, new_fail_count });
                                if (s.read(Candle, symbol, null, .any)) |r|
                                    return .{ .data = r.data, .source = .cached, .timestamp = mr.created, .allocator = self.allocator };
                            }
                            return DataError.TransientError;
                        }
                        // Non-transient failure — return stale data if available
                        if (s.read(Candle, symbol, null, .any)) |r|
                            return .{ .data = r.data, .source = .cached, .timestamp = mr.created, .allocator = self.allocator };
                        return DataError.FetchFailed;
                    };
                    const new_candles = result.candles;

                    if (new_candles.len == 0) {
                        // No new candles (weekend/holiday) — refresh TTL, reset fail_count
                        self.allocator.free(new_candles);
                        s.updateCandleMeta(symbol, m.last_close, m.last_date, result.provider, 0);
                        if (s.read(Candle, symbol, null, .any)) |r|
                            return .{ .data = r.data, .source = .cached, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator };
                    } else {
                        // Append new candles to existing file + update meta, reset fail_count
                        s.appendCandles(symbol, new_candles, result.provider, 0);
                        if (s.read(Candle, symbol, null, .any)) |r| {
                            self.allocator.free(new_candles);
                            return .{ .data = r.data, .source = .fetched, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator };
                        }
                        return .{ .data = new_candles, .source = .fetched, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator };
                    }
                }
            }
        }

        // No usable cache — try server sync first
        if (self.syncCandlesFromServer(symbol)) {
            if (s.isCandleMetaFresh(symbol)) {
                log.debug("{s}: candles synced from server and fresh (no prior cache)", .{symbol});
                if (s.read(Candle, symbol, null, .any)) |r|
                    return .{ .data = r.data, .source = .cached, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator };
            }
            log.debug("{s}: candles synced from server but stale, falling through to full fetch", .{symbol});
        }

        // No usable cache — full fetch (~10 years, plus buffer for leap years)
        log.debug("{s}: fetching full candle history from provider", .{symbol});
        const from = today.addDays(-3700);

        const result = self.fetchCandlesFromProviders(symbol, from, today, .tiingo) catch |err| {
            if (err == DataError.TransientError) {
                // On a fresh fetch, increment fail_count if we have meta
                if (meta_result) |mr| {
                    const new_fail_count = mr.meta.fail_count +| 1;
                    s.updateCandleMeta(symbol, mr.meta.last_close, mr.meta.last_date, mr.meta.provider, new_fail_count);
                }
                return DataError.TransientError;
            }
            // FetchFailed at this point means BOTH Tiingo and Yahoo
            // returned NotFound (or Yahoo was unavailable on top of
            // Tiingo NotFound) — symbol genuinely has no candle data
            // anywhere we look. Negative-cache the result so we don't
            // keep retrying nonexistent symbols.
            s.writeNegative(symbol, .candles_daily);
            return DataError.FetchFailed;
        };

        if (result.candles.len > 0) {
            s.cacheCandles(symbol, result.candles, result.provider, 0); // reset fail_count on success
        }

        return .{ .data = result.candles, .source = .fetched, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator };
    }

    /// Fetch dividend history for a symbol.
    pub fn getDividends(self: *DataService, symbol: []const u8) DataError!FetchResult(Dividend) {
        return self.fetchCached(Dividend, symbol, dividendPostProcess);
    }

    /// Fetch split history for a symbol.
    pub fn getSplits(self: *DataService, symbol: []const u8) DataError!FetchResult(Split) {
        return self.fetchCached(Split, symbol, null);
    }

    /// Fetch options chain for a symbol (all expirations, no API key needed).
    pub fn getOptions(self: *DataService, symbol: []const u8) DataError!FetchResult(OptionsChain) {
        return self.fetchCached(OptionsChain, symbol, null);
    }

    /// Fetch earnings history for a symbol.
    /// Checks cache first; fetches from FMP if stale/missing.
    /// Smart refresh: even if cache is fresh, re-fetches when a past earnings
    /// date has no actual results yet (i.e. results just came out).
    pub fn getEarnings(self: *DataService, symbol: []const u8) DataError!FetchResult(EarningsEvent) {
        // Mutual funds (5-letter tickers ending in X) don't have quarterly earnings.
        if (isMutualFund(symbol)) {
            return .{ .data = &.{}, .source = .cached, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator };
        }

        var s = self.store();
        const today = fmt.todayDate(self.io);

        if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| {
            // Check if any past/today earnings event is still missing actual results.
            // If so, the announcement likely just happened — force a refresh.
            const needs_refresh = for (cached.data) |ev| {
                if (ev.actual == null and !today.lessThan(ev.date)) break true;
            } else false;

            if (!needs_refresh) {
                log.debug("{s}: earnings fresh in local cache", .{symbol});
                return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator };
            }
            // Stale: free cached events and re-fetch below
            self.allocator.free(cached.data);
        }

        // Try server sync before hitting FMP
        if (self.syncFromServer(symbol, .earnings)) {
            if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| {
                log.debug("{s}: earnings synced from server and fresh", .{symbol});
                return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator };
            }
            log.debug("{s}: earnings synced from server but stale, falling through to provider", .{symbol});
        }

        log.debug("{s}: fetching earnings from provider", .{symbol});
        var fmp = try self.getProvider(Fmp);

        const fetched = fmp.fetchEarnings(self.allocator, symbol) catch |err| blk: {
            if (err == error.RateLimited) {
                self.rateLimitBackoff();
                break :blk fmp.fetchEarnings(self.allocator, symbol) catch {
                    return DataError.FetchFailed;
                };
            }
            if (isPermanentProviderFailure(err)) {
                s.writeNegative(symbol, .earnings);
            }
            return DataError.FetchFailed;
        };

        s.write(EarningsEvent, symbol, fetched, cache.Ttl.earnings);

        return .{ .data = fetched, .source = .fetched, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator };
    }

    /// Fetch ETF profile for a symbol.
    /// Checks cache first; fetches from Alpha Vantage if stale/missing.
    pub fn getEtfProfile(self: *DataService, symbol: []const u8) DataError!FetchResult(EtfProfile) {
        var s = self.store();

        if (s.read(EtfProfile, symbol, null, .fresh_only)) |cached|
            return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp, .allocator = self.allocator };

        var av = try self.getProvider(AlphaVantage);
        const fetched = av.fetchEtfProfile(self.allocator, symbol) catch |err| blk: {
            if (err == error.RateLimited) {
                self.rateLimitBackoff();
                break :blk av.fetchEtfProfile(self.allocator, symbol) catch {
                    return DataError.FetchFailed;
                };
            }
            if (isPermanentProviderFailure(err)) {
                s.writeNegative(symbol, .etf_profile);
            }
            return DataError.FetchFailed;
        };

        s.write(EtfProfile, symbol, fetched, cache.Ttl.etf_profile);

        return .{ .data = fetched, .source = .fetched, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds(), .allocator = self.allocator };
    }

    /// Fetch a real-time quote for a symbol.
    /// Yahoo Finance is primary (free, no API key, no 15-min delay).
    /// Falls back to TwelveData if Yahoo fails.
    pub fn getQuote(self: *DataService, symbol: []const u8) DataError!Quote {
        // Primary: Yahoo Finance (free, real-time)
        if (self.getProvider(Yahoo)) |yh| {
            if (yh.fetchQuote(self.allocator, symbol)) |quote| {
                log.debug("{s}: quote from Yahoo", .{symbol});
                return quote;
            } else |_| {}
        } else |_| {}

        // Fallback: TwelveData (requires API key, may be 15-min delayed)
        var td = try self.getProvider(TwelveData);
        log.debug("{s}: quote fallback to TwelveData", .{symbol});
        return td.fetchQuote(self.allocator, symbol) catch
            return DataError.FetchFailed;
    }

    /// Fetch company overview (sector, industry, country, market cap) from Alpha Vantage.
    /// No cache -- always fetches fresh. Caller must free the returned string fields.
    pub fn getCompanyOverview(self: *DataService, symbol: []const u8) DataError!CompanyOverview {
        var av = try self.getProvider(AlphaVantage);
        return av.fetchCompanyOverview(self.allocator, symbol) catch
            return DataError.FetchFailed;
    }

    /// Compute trailing returns for a symbol (fetches candles + dividends).
    /// Returns both as-of-date and month-end trailing returns.
    /// As-of-date: end = latest close. Matches Morningstar "Trailing Returns" page.
    /// Month-end:  end = last business day of prior month. Matches Morningstar "Performance" page.
    pub fn getTrailingReturns(self: *DataService, symbol: []const u8) DataError!struct {
        asof_price: performance.TrailingReturns,
        asof_total: ?performance.TrailingReturns,
        me_price: performance.TrailingReturns,
        me_total: ?performance.TrailingReturns,
        candles: []Candle,
        dividends: ?[]Dividend,
        source: Source,
        timestamp: i64,
    } {
        const candle_result = try self.getCandles(symbol);
        const c = candle_result.data;
        if (c.len == 0) return DataError.FetchFailed;

        const today = fmt.todayDate(self.io);

        // As-of-date (end = last candle)
        const asof_price = performance.trailingReturns(c);
        // Month-end (end = last business day of prior month)
        const me_price = performance.trailingReturnsMonthEnd(c, today);

        // Try to get dividends (non-fatal if unavailable)
        var divs: ?[]Dividend = null;
        var asof_total: ?performance.TrailingReturns = null;
        var me_total: ?performance.TrailingReturns = null;

        if (self.getDividends(symbol)) |div_result| {
            divs = div_result.data;
            const asof_div = performance.trailingReturnsWithDividends(c, div_result.data);
            const me_div = performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today);
            // Dividend reinvestment is preferred (compounds correctly).
            // adj_close fills gaps where dividend data is insufficient
            // (e.g. stable-NAV funds with short candle history).
            asof_total = performance.withDividendFallback(asof_div, asof_price);
            me_total = performance.withDividendFallback(me_div, me_price);
        } else |_| {}

        return .{
            .asof_price = asof_price,
            .asof_total = asof_total,
            .me_price = me_price,
            .me_total = me_total,
            .candles = c,
            .dividends = divs,
            .source = candle_result.source,
            .timestamp = candle_result.timestamp,
        };
    }

    /// Check if candle data is fresh in cache without full deserialization.
    pub fn isCandleCacheFresh(self: *DataService, symbol: []const u8) bool {
        var s = self.store();
        return s.isCandleMetaFresh(symbol);
    }

    /// Read only the latest close price from cached candles (no full deserialization).
    /// Returns null if no cached data exists.
    pub fn getCachedLastClose(self: *DataService, symbol: []const u8) ?f64 {
        var s = self.store();
        return s.readLastClose(symbol);
    }

    /// Read the latest cached candle date for `symbol` without deserializing
    /// the full candle history. Returns null if no cached metadata exists.
    ///
    /// Callers should pair this with `isCandleCacheFresh` before trusting
    /// the date: a stale cache entry can return a date from days or weeks
    /// ago, which is fine for diagnostics but wrong for anything that
    /// needs "the current market date".
    pub fn getCachedLastDate(self: *DataService, symbol: []const u8) ?Date {
        var s = self.store();
        const mr = s.readCandleMeta(symbol) orelse return null;
        return mr.meta.last_date;
    }

    /// Estimate wait time (in seconds) before the next TwelveData API call can proceed.
    /// Returns 0 if a request can be made immediately. Returns null if no API key.
    pub fn estimateWaitSeconds(self: *DataService) ?u64 {
        if (self.td) |*td| {
            const ns = td.rate_limiter.estimateWaitNs();
            return if (ns == 0) 0 else @max(1, ns / std.time.ns_per_s);
        }
        return null;
    }

    /// Read candles from cache only (no network fetch). Used by TUI for display.
    /// Returns null if no cached data exists or if the entry is a negative cache (fetch_failed).
    ///
    /// Returns a `FetchResult(Candle)` so the caller can `result.deinit()`
    /// without needing to know the service's internal allocator.
    pub fn getCachedCandles(self: *DataService, symbol: []const u8) ?FetchResult(Candle) {
        var s = self.store();
        if (s.isNegative(symbol, .candles_daily)) return null;
        const result = s.read(Candle, symbol, null, .any) orelse return null;
        return .{ .data = result.data, .source = .cached, .timestamp = result.timestamp, .allocator = self.allocator };
    }

    /// Read dividends from cache only (no network fetch).
    pub fn getCachedDividends(self: *DataService, symbol: []const u8) ?[]Dividend {
        var s = self.store();
        const result = s.read(Dividend, symbol, dividendPostProcess, .any) orelse return null;
        return result.data;
    }

    /// Read earnings from cache only (no network fetch).
    pub fn getCachedEarnings(self: *DataService, symbol: []const u8) ?[]EarningsEvent {
        var s = self.store();
        const result = s.read(EarningsEvent, symbol, earningsPostProcess, .any) orelse return null;
        return result.data;
    }

    /// Read options from cache only (no network fetch).
    pub fn getCachedOptions(self: *DataService, symbol: []const u8) ?[]OptionsChain {
        var s = self.store();
        const result = s.read(OptionsChain, symbol, null, .any) orelse return null;
        return result.data;
    }

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

    /// Result of loading prices for a set of symbols.
    pub const PriceLoadResult = struct {
        /// Number of symbols whose price came from fresh cache.
        cached_count: usize,
        /// Number of symbols successfully fetched from API.
        fetched_count: usize,
        /// Number of symbols where API fetch failed.
        fail_count: usize,
        /// Number of failed symbols that fell back to stale cache.
        stale_count: usize,
        /// Latest candle date seen across all symbols.
        latest_date: ?Date,
    };

    /// Status emitted for each symbol during price loading.
    pub const SymbolStatus = enum {
        /// Price resolved from fresh cache.
        cached,
        /// About to attempt an API fetch (emitted before the network call).
        fetching,
        /// Price fetched successfully from API.
        fetched,
        /// API fetch failed but stale cached price was used.
        failed_used_stale,
        /// API fetch failed and no cached price exists.
        failed,
    };

    /// Callback for progress reporting during price loading.
    /// `context` is an opaque pointer to caller-owned state.
    pub const ProgressCallback = struct {
        context: *anyopaque,
        on_progress: *const fn (ctx: *anyopaque, index: usize, total: usize, symbol: []const u8, status: SymbolStatus) void,

        fn emit(self: ProgressCallback, index: usize, total: usize, symbol: []const u8, status: SymbolStatus) void {
            self.on_progress(self.context, index, total, symbol, status);
        }
    };

    /// Load current prices for a list of symbols into `prices`.
    ///
    /// For each symbol the resolution order is:
    ///   1. Fresh cache  -> use cached last close (fast, no deserialization)
    ///   2. API fetch     -> use latest candle close
    ///   3. Stale cache   -> use last close from expired cache entry
    ///
    /// If `force_refresh` is true, cache is invalidated before checking freshness.
    /// If `progress` is provided, it is called for each symbol with the outcome.
    pub fn loadPrices(
        self: *DataService,
        symbols: []const []const u8,
        prices: *std.StringHashMap(f64),
        force_refresh: bool,
        progress: ?ProgressCallback,
    ) PriceLoadResult {
        var result = PriceLoadResult{
            .cached_count = 0,
            .fetched_count = 0,
            .fail_count = 0,
            .stale_count = 0,
            .latest_date = null,
        };
        const total = symbols.len;

        for (symbols, 0..) |sym, i| {
            if (force_refresh) {
                self.invalidate(sym, .candles_daily);
            }

            // 1. Fresh cache — fast path (no full deserialization)
            if (!force_refresh and self.isCandleCacheFresh(sym)) {
                if (self.getCachedLastClose(sym)) |close| {
                    prices.put(sym, close) catch {};
                }
                result.cached_count += 1;
                if (progress) |p| p.emit(i, total, sym, .cached);
                continue;
            }

            // About to fetch — notify caller (so it can show rate-limit waits etc.)
            if (progress) |p| p.emit(i, total, sym, .fetching);

            // 2. Try API fetch
            if (self.getCandles(sym)) |candle_result| {
                defer self.allocator.free(candle_result.data);
                if (candle_result.data.len > 0) {
                    const last = candle_result.data[candle_result.data.len - 1];
                    prices.put(sym, last.close) catch {};
                    if (result.latest_date == null or last.date.days > result.latest_date.?.days) {
                        result.latest_date = last.date;
                    }
                }
                result.fetched_count += 1;
                if (progress) |p| p.emit(i, total, sym, .fetched);
                continue;
            } else |_| {}

            // 3. Fetch failed — fall back to stale cache
            result.fail_count += 1;
            if (self.getCachedLastClose(sym)) |close| {
                prices.put(sym, close) catch {};
                result.stale_count += 1;
                if (progress) |p| p.emit(i, total, sym, .failed_used_stale);
            } else {
                if (progress) |p| p.emit(i, total, sym, .failed);
            }
        }

        return result;
    }

    // ── Consolidated Price Loading (Parallel Server + Sequential Provider) ──

    /// Configuration for loadAllPrices.
    pub const LoadAllConfig = struct {
        force_refresh: bool = false,
        color: bool = true,
        /// Maximum concurrent server sync requests. 0 = auto (8).
        max_concurrent: usize = 0,
    };

    /// Result of loadAllPrices operation.
    pub const LoadAllResult = struct {
        prices: std.StringHashMap(f64),
        /// Number of symbols resolved from fresh local cache.
        cached_count: usize,
        /// Number of symbols synced from server.
        server_synced_count: usize,
        /// Number of symbols fetched from providers (rate-limited APIs).
        provider_fetched_count: usize,
        /// Number of symbols that failed all sources but used stale cache.
        stale_count: usize,
        /// Number of symbols that failed completely (no data).
        failed_count: usize,
        /// Latest candle date seen.
        latest_date: ?Date,

        /// Free the prices hashmap. Call this if you don't transfer ownership.
        pub fn deinit(self: *LoadAllResult) void {
            self.prices.deinit();
        }
    };

    /// Progress callback for aggregate (parallel) progress reporting.
    /// Called periodically during parallel operations with current counts.
    pub const AggregateProgressCallback = struct {
        context: *anyopaque,
        on_progress: *const fn (ctx: *anyopaque, completed: usize, total: usize, phase: Phase) void,

        pub const Phase = enum {
            /// Checking local cache
            cache_check,
            /// Syncing from ZFIN_SERVER
            server_sync,
            /// Fetching from rate-limited providers
            provider_fetch,
            /// Done
            complete,
        };

        fn emit(self: AggregateProgressCallback, completed: usize, total: usize, phase: Phase) void {
            self.on_progress(self.context, completed, total, phase);
        }
    };

    /// Thread-safe counter for parallel progress tracking.
    const AtomicCounter = struct {
        value: std.atomic.Value(usize) = std.atomic.Value(usize).init(0),

        fn increment(self: *AtomicCounter) usize {
            return self.value.fetchAdd(1, .monotonic);
        }

        fn load(self: *const AtomicCounter) usize {
            return self.value.load(.monotonic);
        }
    };

    /// Per-symbol result from parallel server sync.
    const ServerSyncResult = struct {
        symbol: []const u8,
        success: bool,
    };

    /// Load prices for portfolio and watchlist symbols with automatic parallelization.
    ///
    /// When ZFIN_SERVER is configured:
    ///   1. Check local cache (fast, parallel-safe)
    ///   2. Parallel sync from server for cache misses
    ///   3. Sequential provider fallback for server failures
    ///
    /// When ZFIN_SERVER is not configured:
    ///   Falls back to sequential loading with per-symbol progress.
    ///
    /// Progress is reported via `aggregate_progress` for parallel phases
    /// and `symbol_progress` for sequential provider fallback.
    pub fn loadAllPrices(
        self: *DataService,
        portfolio_syms: ?[]const []const u8,
        watch_syms: []const []const u8,
        config: LoadAllConfig,
        aggregate_progress: ?AggregateProgressCallback,
        symbol_progress: ?ProgressCallback,
    ) LoadAllResult {
        var result = LoadAllResult{
            .prices = std.StringHashMap(f64).init(self.allocator),
            .cached_count = 0,
            .server_synced_count = 0,
            .provider_fetched_count = 0,
            .stale_count = 0,
            .failed_count = 0,
            .latest_date = null,
        };

        // Combine all symbols
        const portfolio_count = if (portfolio_syms) |ps| ps.len else 0;
        const watch_count = watch_syms.len;
        const total_count = portfolio_count + watch_count;

        if (total_count == 0) return result;

        // Build combined symbol list
        var all_symbols = std.ArrayList([]const u8).initCapacity(self.allocator, total_count) catch return result;
        defer all_symbols.deinit(self.allocator);

        if (portfolio_syms) |ps| {
            for (ps) |sym| all_symbols.append(self.allocator, sym) catch {};
        }
        for (watch_syms) |sym| all_symbols.append(self.allocator, sym) catch {};

        // Invalidate cache if force refresh
        if (config.force_refresh) {
            for (all_symbols.items) |sym| {
                self.invalidate(sym, .candles_daily);
            }
        }

        // Phase 1: Check local cache (fast path)
        var needs_fetch: std.ArrayList([]const u8) = .empty;
        defer needs_fetch.deinit(self.allocator);

        if (aggregate_progress) |p| p.emit(0, total_count, .cache_check);

        for (all_symbols.items) |sym| {
            if (!config.force_refresh and self.isCandleCacheFresh(sym)) {
                if (self.getCachedLastClose(sym)) |close| {
                    result.prices.put(sym, close) catch {};
                    self.updateLatestDate(&result, sym);
                }
                result.cached_count += 1;
            } else {
                needs_fetch.append(self.allocator, sym) catch {};
            }
        }

        if (aggregate_progress) |p| p.emit(result.cached_count, total_count, .cache_check);

        if (needs_fetch.items.len == 0) {
            if (aggregate_progress) |p| p.emit(total_count, total_count, .complete);
            return result;
        }

        // Phase 2: Server sync (parallel if server configured)
        var server_failures: std.ArrayList([]const u8) = .empty;
        defer server_failures.deinit(self.allocator);

        if (self.config.server_url != null) {
            self.parallelServerSync(
                needs_fetch.items,
                &result,
                &server_failures,
                config,
                aggregate_progress,
                total_count,
            );
        } else {
            // No server — all need provider fetch
            for (needs_fetch.items) |sym| {
                server_failures.append(self.allocator, sym) catch {};
            }
        }

        // Phase 3: Sequential provider fallback for server failures
        if (server_failures.items.len > 0) {
            if (aggregate_progress) |p| p.emit(
                result.cached_count + result.server_synced_count,
                total_count,
                .provider_fetch,
            );

            self.sequentialProviderFetch(
                server_failures.items,
                &result,
                symbol_progress,
                total_count - server_failures.items.len, // offset for progress display
            );
        }

        if (aggregate_progress) |p| p.emit(total_count, total_count, .complete);
        return result;
    }

    /// Parallel server sync using thread pool.
    fn parallelServerSync(
        self: *DataService,
        symbols: []const []const u8,
        result: *LoadAllResult,
        failures: *std.ArrayList([]const u8),
        config: LoadAllConfig,
        aggregate_progress: ?AggregateProgressCallback,
        total_count: usize,
    ) void {
        const max_threads = if (config.max_concurrent > 0) config.max_concurrent else 8;
        const thread_count = @min(symbols.len, max_threads);

        if (aggregate_progress) |p| p.emit(result.cached_count, total_count, .server_sync);

        // Shared state for worker threads
        var completed = AtomicCounter{};
        var next_index = AtomicCounter{};
        const sync_results = self.allocator.alloc(ServerSyncResult, symbols.len) catch {
            // Allocation failed — fall back to marking all as failures
            for (symbols) |sym| failures.append(self.allocator, sym) catch {};
            return;
        };
        defer self.allocator.free(sync_results);

        // Initialize results
        for (sync_results, 0..) |*sr, i| {
            sr.* = .{ .symbol = symbols[i], .success = false };
        }

        // Spawn worker threads
        var threads = self.allocator.alloc(std.Thread, thread_count) catch {
            for (symbols) |sym| failures.append(self.allocator, sym) catch {};
            return;
        };
        defer self.allocator.free(threads);

        const WorkerContext = struct {
            svc: *DataService,
            symbols: []const []const u8,
            results: []ServerSyncResult,
            next_index: *AtomicCounter,
            completed: *AtomicCounter,
        };

        var ctx = WorkerContext{
            .svc = self,
            .symbols = symbols,
            .results = sync_results,
            .next_index = &next_index,
            .completed = &completed,
        };

        const worker = struct {
            fn run(wctx: *WorkerContext) void {
                while (true) {
                    const idx = wctx.next_index.increment();
                    if (idx >= wctx.symbols.len) break;

                    const sym = wctx.symbols[idx];
                    const success = wctx.svc.syncCandlesFromServer(sym);
                    wctx.results[idx].success = success;
                    _ = wctx.completed.increment();
                }
            }
        };

        // Start threads
        var spawned: usize = 0;
        for (threads) |*t| {
            t.* = std.Thread.spawn(.{}, worker.run, .{&ctx}) catch continue;
            spawned += 1;
        }

        // Progress reporting while waiting
        if (aggregate_progress) |p| {
            while (completed.load() < symbols.len) {
                std.Io.sleep(self.io, std.Io.Duration.fromMilliseconds(50), .awake) catch {};
                p.emit(result.cached_count + completed.load(), total_count, .server_sync);
            }
        }

        // Wait for all threads
        for (threads[0..spawned]) |t| {
            t.join();
        }

        // Process results
        for (sync_results) |sr| {
            if (sr.success) {
                // Server sync succeeded — read from cache
                if (self.getCachedLastClose(sr.symbol)) |close| {
                    result.prices.put(sr.symbol, close) catch {};
                    self.updateLatestDate(result, sr.symbol);
                    result.server_synced_count += 1;
                } else {
                    // Sync said success but can't read cache — treat as failure
                    failures.append(self.allocator, sr.symbol) catch {};
                }
            } else {
                failures.append(self.allocator, sr.symbol) catch {};
            }
        }
    }

    /// Sequential provider fetch for symbols that failed server sync.
    fn sequentialProviderFetch(
        self: *DataService,
        symbols: []const []const u8,
        result: *LoadAllResult,
        progress: ?ProgressCallback,
        index_offset: usize,
    ) void {
        const total = index_offset + symbols.len;

        for (symbols, 0..) |sym, i| {
            const display_idx = index_offset + i;

            // Notify: about to fetch
            if (progress) |p| p.emit(display_idx, total, sym, .fetching);

            // Try provider fetch
            if (self.getCandles(sym)) |candle_result| {
                defer self.allocator.free(candle_result.data);
                if (candle_result.data.len > 0) {
                    const last = candle_result.data[candle_result.data.len - 1];
                    result.prices.put(sym, last.close) catch {};
                    if (result.latest_date == null or last.date.days > result.latest_date.?.days) {
                        result.latest_date = last.date;
                    }
                }
                result.provider_fetched_count += 1;
                if (progress) |p| p.emit(display_idx, total, sym, .fetched);
                continue;
            } else |_| {}

            // Provider failed — try stale cache
            result.failed_count += 1;
            if (self.getCachedLastClose(sym)) |close| {
                result.prices.put(sym, close) catch {};
                result.stale_count += 1;
                if (progress) |p| p.emit(display_idx, total, sym, .failed_used_stale);
            } else {
                if (progress) |p| p.emit(display_idx, total, sym, .failed);
            }
        }
    }

    /// Update latest_date in result from cached candle metadata.
    fn updateLatestDate(self: *DataService, result: *LoadAllResult, symbol: []const u8) void {
        var s = self.store();
        if (s.readCandleMeta(symbol)) |cm| {
            const d = cm.meta.last_date;
            if (result.latest_date == null or d.days > result.latest_date.?.days) {
                result.latest_date = d;
            }
        }
    }

    // ── CUSIP Resolution ──────────────────────────────────────────

    /// Look up multiple CUSIPs in a single batch request via OpenFIGI.
    /// Results array is parallel to the input cusips array (same length, same order).
    /// Caller owns the returned slice and all strings within each CusipResult.
    pub fn lookupCusips(self: *DataService, cusips: []const []const u8) DataError![]CusipResult {
        return OpenFigi.lookupCusips(self.io, self.allocator, cusips, self.config.openfigi_key) catch
            return DataError.FetchFailed;
    }

    /// Look up a CUSIP via OpenFIGI API. Returns the ticker if found, null otherwise.
    /// Results are cached in {cache_dir}/cusip_tickers.srf.
    /// Caller owns the returned string.
    pub fn lookupCusip(self: *DataService, cusip: []const u8) ?[]const u8 {
        // Check local cache first
        if (self.getCachedCusipTicker(cusip)) |t| return t;

        // Try OpenFIGI
        const result = OpenFigi.lookupCusip(self.allocator, cusip, self.config.openfigi_key) catch return null;
        defer {
            if (result.name) |n| self.allocator.free(n);
            if (result.security_type) |s| self.allocator.free(s);
        }

        if (result.ticker) |ticker| {
            // Cache the mapping
            self.cacheCusipTicker(cusip, ticker);
            return ticker; // caller takes ownership
        }

        return null;
    }

    /// A single CUSIP-to-ticker mapping record in the cache file.
    const CusipEntry = struct {
        cusip: []const u8 = "",
        ticker: []const u8 = "",
    };

    /// Read a cached CUSIP->ticker mapping. Returns null if not cached.
    /// Caller owns the returned string.
    fn getCachedCusipTicker(self: *DataService, cusip: []const u8) ?[]const u8 {
        const path = std.fs.path.join(self.allocator, &.{ self.config.cache_dir, "cusip_tickers.srf" }) catch return null;
        defer self.allocator.free(path);

        const data = std.fs.cwd().readFileAlloc(self.allocator, path, 64 * 1024) catch return null;
        defer self.allocator.free(data);

        var reader = std.Io.Reader.fixed(data);
        var it = srf.iterator(&reader, self.allocator, .{ .alloc_strings = false }) catch return null;
        defer it.deinit();

        while (it.next() catch return null) |fields| {
            const entry = fields.to(CusipEntry) catch continue;
            if (std.mem.eql(u8, entry.cusip, cusip) and entry.ticker.len > 0) {
                return self.allocator.dupe(u8, entry.ticker) catch null;
            }
        }
        return null;
    }

    /// Append a CUSIP->ticker mapping to the cache file.
    ///
    /// Implemented as read-append-atomic-write (rather than a direct
    /// open-for-append) so a concurrent reader never sees a file with a
    /// valid header plus partial trailing record. See `cache/store.zig
    /// appendRaw` for the same pattern and rationale.
    pub fn cacheCusipTicker(self: *DataService, cusip: []const u8, ticker: []const u8) void {
        const path = std.fs.path.join(self.allocator, &.{ self.config.cache_dir, "cusip_tickers.srf" }) catch return;
        defer self.allocator.free(path);

        // Ensure cache dir exists
        if (std.fs.path.dirnamePosix(path)) |dir| {
            std.Io.Dir.cwd().createDirPath(self.io, dir) catch {};
        }

        // Read existing cache if present.
        const existing = std.Io.Dir.cwd().readFileAlloc(self.io, path, self.allocator, .limited(4 * 1024 * 1024)) catch |err| switch (err) {
            error.FileNotFound => @as([]u8, &.{}),
            else => return,
        };
        const owns_existing = existing.len > 0;
        defer if (owns_existing) self.allocator.free(existing);

        // Serialize the new entry (with `#!srfv1` directives only if the
        // cache file doesn't exist yet).
        const emit_directives = !owns_existing;
        const entry = [_]CusipEntry{.{ .cusip = cusip, .ticker = ticker }};
        var aw: std.Io.Writer.Allocating = .init(self.allocator);
        defer aw.deinit();
        aw.writer.print("{f}", .{srf.fmtFrom(CusipEntry, self.allocator, &entry, .{ .emit_directives = emit_directives })}) catch return;
        const encoded = aw.writer.buffered();
        if (encoded.len == 0) return;

        // Concat existing + new, then atomic-write.
        const combined = self.allocator.alloc(u8, existing.len + encoded.len) catch return;
        defer self.allocator.free(combined);
        @memcpy(combined[0..existing.len], existing);
        @memcpy(combined[existing.len..], encoded);

        atomic.writeFileAtomic(self.io, self.allocator, path, combined) catch {};
    }

    // ── Utility ──────────────────────────────────────────────────

    /// Sleep before retrying after a rate limit error.
    /// Uses the provider's rate limiter if available, otherwise a fixed 10s backoff.
    fn rateLimitBackoff(self: *DataService) void {
        if (self.td) |*td| {
            td.rate_limiter.backoff();
        } else {
            std.Io.sleep(self.io, std.Io.Duration.fromSeconds(10), .awake) catch {};
        }
    }

    // ── Server sync ──────────────────────────────────────────────

    /// Try to sync a cache file from the configured zfin-server.
    /// Returns true if the file was successfully synced, false on any error.
    /// Silently returns false if no server is configured.
    ///
    /// Applies a single retry with a short delay when the first attempt
    /// fails at the HTTP layer OR produces a torn body (integrity
    /// mismatch / `looksCompleteSrf` rejection). Motivation: refreshes
    /// fan out 20+ symbols across 8 parallel threads, and the tear
    /// pattern we've observed so far looks transient per-connection.
    /// One retry papers over single-packet hiccups without dramatically
    /// extending refresh wall time. If the retry also fails the
    /// archive grows by one more `.bin`/`.meta` pair — two captures
    /// from the same refresh are the most valuable diagnostic signal
    /// we can produce (same body shape? same byte offset? same time
    /// delta? all answers we can't get from a single failure).
    fn syncFromServer(self: *DataService, symbol: []const u8, data_type: cache.DataType) bool {
        const server_url = self.config.server_url orelse return false;
        const endpoint = switch (data_type) {
            .candles_daily => "/candles",
            .candles_meta => "/candles_meta",
            .dividends => "/dividends",
            .earnings => "/earnings",
            .options => "/options",
            .splits => return false, // not served
            .etf_profile => return false, // not served
            .meta => return false,
        };

        const full_url = std.fmt.allocPrint(self.allocator, "{s}/{s}{s}", .{ server_url, symbol, endpoint }) catch return false;
        defer self.allocator.free(full_url);

        const max_attempts: u8 = 2;
        const retry_delay_ms: u64 = 250;

        var attempt: u8 = 0;
        while (attempt < max_attempts) : (attempt += 1) {
            if (attempt > 0) {
                log.debug(
                    "{s}: retrying {s} server sync (attempt {d}/{d}) after {d}ms delay",
                    .{ symbol, @tagName(data_type), attempt + 1, max_attempts, retry_delay_ms },
                );
                std.Io.sleep(self.io, std.Io.Duration.fromMilliseconds(retry_delay_ms), .awake) catch {};
            }
            switch (self.tryOneSync(symbol, data_type, full_url)) {
                .ok => return true,
                // Torn or network error — retry if attempts remain.
                .torn, .net_err => {},
            }
        }
        return false;
    }

    const SyncAttempt = enum { ok, torn, net_err };

    /// One attempt at syncing a file from the server. Archives a torn
    /// body when detected but does NOT retry — the caller decides that.
    fn tryOneSync(self: *DataService, symbol: []const u8, data_type: cache.DataType, full_url: []const u8) SyncAttempt {
        log.debug("{s}: syncing {s} from server", .{ symbol, @tagName(data_type) });

        var client = http.Client.init(self.io, self.allocator);
        defer client.deinit();

        var response = client.get(full_url) catch |err| {
            log.debug("{s}: server sync failed for {s}: {s}", .{ symbol, @tagName(data_type), @errorName(err) });
            return .net_err;
        };
        defer response.deinit();

        // Integrity check: if the server advertised an ETag in
        // `"sha256:<hex>"` form, compare the body's actual sha256
        // against it. Catches mid-stream truncation that Zig's
        // std.http.Client.fetch silently accepts on the Content-Length
        // path (EndOfStream from a cut transport is swallowed as a
        // normal termination). Archive the mismatching body with the
        // advertised etag so post-mortem can see exactly what was
        // promised vs what arrived. Deployments with no ETag or a
        // non-sha256 etag fall through to `looksCompleteSrf` below
        // (backward-compatible with pre-fix servers).
        switch (response.verifyIntegrity()) {
            .mismatch => |m| {
                cache.Store.archiveTornBody(
                    self.io,
                    self.allocator,
                    self.config.cache_dir,
                    symbol,
                    data_type,
                    response.body,
                    .{
                        .failure_reason = .etag_mismatch,
                        .http_status = @intFromEnum(response.status),
                        .server_url = full_url,
                        .server_etag = response.etag,
                    },
                ) catch |err| {
                    log.debug(
                        "{s}: failed to archive etag-mismatch {s} body: {s}",
                        .{ symbol, @tagName(data_type), @errorName(err) },
                    );
                };
                log.debug(
                    "{s}: {s} server response failed integrity check ({d} bytes, expected sha256={s}, actual={s}) — archived under _torn/, not writing to cache",
                    .{ symbol, @tagName(data_type), response.body.len, m.expected_hex, m.actual_hex },
                );
                return .torn;
            },
            .ok, .not_applicable => {},
        }

        // Validate the response body looks like a complete SRF file before
        // writing it to cache. This guards against HTTP body truncation
        // (TCP reset, Content-Length mismatch, proxy that flushed a
        // partial response, etc.) — torn bodies get written atomically
        // to the cache otherwise, producing the classic SRF parse error
        // on the next read:
        //   error(srf): custom parse of value YYYY-MM failed : InvalidDateFormat
        //
        // When the check rejects a body, archive the raw bytes + context
        // under `{cache_dir}/_torn/` so the next time this recurs we
        // have ammunition for root-cause analysis. The log line is kept
        // at debug level on purpose — user explicitly asked that routine
        // rejections not be noisy in production runs. The `.meta`
        // sidecar on disk is the durable signal.
        if (!cache.Store.looksCompleteSrf(response.body)) {
            cache.Store.archiveTornBody(
                self.io,
                self.allocator,
                self.config.cache_dir,
                symbol,
                data_type,
                response.body,
                .{
                    .failure_reason = .looks_complete_srf_failed,
                    .http_status = @intFromEnum(response.status),
                    .server_url = full_url,
                    .server_etag = response.etag,
                },
            ) catch |err| {
                log.debug(
                    "{s}: failed to archive torn {s} body: {s}",
                    .{ symbol, @tagName(data_type), @errorName(err) },
                );
            };
            log.debug(
                "{s}: rejecting torn {s} server response ({d} bytes) — archived under _torn/, not writing to cache",
                .{ symbol, @tagName(data_type), response.body.len },
            );
            return .torn;
        }

        // Write to local cache
        var s = self.store();
        s.writeRaw(symbol, data_type, response.body) catch |err| {
            log.debug("{s}: failed to write synced {s} to cache: {s}", .{ symbol, @tagName(data_type), @errorName(err) });
            return .net_err;
        };
        log.debug("{s}: synced {s} from server ({d} bytes)", .{ symbol, @tagName(data_type), response.body.len });
        return .ok;
    }

    /// Sync candle data (both daily and meta) from the server.
    fn syncCandlesFromServer(self: *DataService, symbol: []const u8) bool {
        const daily = self.syncFromServer(symbol, .candles_daily);
        const meta = self.syncFromServer(symbol, .candles_meta);
        return daily and meta;
    }

    /// Mutual funds use 5-letter tickers ending in X (e.g. FDSCX, VSTCX, FAGIX).
    /// These don't have quarterly earnings — skip the fetch rather than
    /// round-tripping to the provider just to get an empty response.
    fn isMutualFund(symbol: []const u8) bool {
        return symbol.len == 5 and symbol[4] == 'X';
    }

    // ── User config files ─────────────────────────────────────────

    /// Load and parse accounts.srf from the same directory as the given portfolio path.
    /// Returns null if the file doesn't exist or can't be parsed.
    /// Caller owns the returned AccountMap and must call deinit().
    pub fn loadAccountMap(self: *DataService, portfolio_path: []const u8) ?analysis.AccountMap {
        const dir_end = if (std.mem.lastIndexOfScalar(u8, portfolio_path, std.fs.path.sep)) |idx| idx + 1 else 0;
        const acct_path = std.fmt.allocPrint(self.allocator, "{s}accounts.srf", .{portfolio_path[0..dir_end]}) catch return null;
        defer self.allocator.free(acct_path);

        const data = std.Io.Dir.cwd().readFileAlloc(self.io, acct_path, self.allocator, .limited(1024 * 1024)) catch return null;
        defer self.allocator.free(data);

        return analysis.parseAccountsFile(self.allocator, data) catch null;
    }

    /// Load and parse `transaction_log.srf` from the same directory as
    /// the given portfolio path. Returns null if the file doesn't
    /// exist or can't be parsed — the contributions pipeline falls
    /// back to the pre-transaction-log behavior (no transfer netting)
    /// when null is returned.
    ///
    /// Caller owns the returned `TransactionLog` and must call
    /// `deinit()`.
    pub fn loadTransferLog(self: *DataService, portfolio_path: []const u8) ?transaction_log.TransactionLog {
        const dir_end = if (std.mem.lastIndexOfScalar(u8, portfolio_path, std.fs.path.sep)) |idx| idx + 1 else 0;
        const path = std.fmt.allocPrint(self.allocator, "{s}transaction_log.srf", .{portfolio_path[0..dir_end]}) catch return null;
        defer self.allocator.free(path);

        const data = std.Io.Dir.cwd().readFileAlloc(self.io, path, self.allocator, .limited(1024 * 1024)) catch return null;
        defer self.allocator.free(data);

        return transaction_log.parseTransactionLogFile(self.allocator, data) catch null;
    }
};

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

test "isPermanentProviderFailure: NotFound is permanent" {
    try std.testing.expect(isPermanentProviderFailure(error.NotFound));
}

test "isPermanentProviderFailure: RequestFailed is transient" {
    try std.testing.expect(!isPermanentProviderFailure(error.RequestFailed));
}

test "isPermanentProviderFailure: ServerError is transient" {
    try std.testing.expect(!isPermanentProviderFailure(error.ServerError));
}

test "isPermanentProviderFailure: Unauthorized is transient" {
    // Auth misconfigs are user-fixable (set the API key); not a reason
    // to permanently suppress retries.
    try std.testing.expect(!isPermanentProviderFailure(error.Unauthorized));
}

test "isPermanentProviderFailure: InvalidResponse is transient" {
    // Parse errors are usually a provider format change or one-off
    // garbage response — retrying later is fine.
    try std.testing.expect(!isPermanentProviderFailure(error.InvalidResponse));
}

test "isPermanentProviderFailure: PaymentRequired is transient" {
    // FMP marks plan-locked symbols with HTTP 402; user can upgrade
    // their plan or rotate providers, so don't poison the cache.
    try std.testing.expect(!isPermanentProviderFailure(error.PaymentRequired));
}

test "isPermanentProviderFailure: RateLimited is transient" {
    // Rate-limit is the textbook transient case; the caller already
    // handles it specially with backoff + retry.
    try std.testing.expect(!isPermanentProviderFailure(error.RateLimited));
}

test "isMutualFund identifies mutual funds" {
    // Standard mutual fund tickers (5 letters ending in X)
    try std.testing.expect(DataService.isMutualFund("FDSCX"));
    try std.testing.expect(DataService.isMutualFund("VSTCX"));
    try std.testing.expect(DataService.isMutualFund("FAGIX"));
    try std.testing.expect(DataService.isMutualFund("VFINX"));

    // Not mutual funds
    try std.testing.expect(!DataService.isMutualFund("AAPL"));
    try std.testing.expect(!DataService.isMutualFund("VTI"));
    try std.testing.expect(!DataService.isMutualFund("SPY"));
    try std.testing.expect(!DataService.isMutualFund("GOOGL"));
    try std.testing.expect(!DataService.isMutualFund("")); // empty
    try std.testing.expect(!DataService.isMutualFund("X")); // too short
    try std.testing.expect(!DataService.isMutualFund("FDSCA")); // 5 letters but not ending in X
    try std.testing.expect(!DataService.isMutualFund("FDSCXA")); // 6 letters ending in A
}

test "DataService init/deinit lifecycle" {
    const allocator = std.testing.allocator;
    const config = Config{
        .cache_dir = "/tmp/zfin-test-cache",
    };
    var svc = DataService.init(std.testing.io, allocator, config);
    defer svc.deinit();

    // Should be able to access config
    try std.testing.expectEqualStrings("/tmp/zfin-test-cache", svc.config.cache_dir);
    // Providers should be null (lazy init)
    try std.testing.expect(svc.td == null);
    try std.testing.expect(svc.pg == null);
    try std.testing.expect(svc.fmp == null);
    try std.testing.expect(svc.yh == null);
    try std.testing.expect(svc.tg == null);
}

test "DataService store helper creates valid store" {
    const allocator = std.testing.allocator;
    const config = Config{
        .cache_dir = "/tmp/zfin-test-cache",
    };
    var svc = DataService.init(std.testing.io, allocator, config);
    defer svc.deinit();

    const s = svc.store();
    try std.testing.expectEqualStrings("/tmp/zfin-test-cache", s.cache_dir);
}

test "DataService getProvider returns NoApiKey without key" {
    const allocator = std.testing.allocator;
    const config = Config{
        .cache_dir = "/tmp/zfin-test-cache",
        // No API keys set
    };
    var svc = DataService.init(std.testing.io, allocator, config);
    defer svc.deinit();

    // TwelveData requires API key
    const td_result = svc.getProvider(TwelveData);
    try std.testing.expectError(DataError.NoApiKey, td_result);

    // Polygon requires API key
    const pg_result = svc.getProvider(Polygon);
    try std.testing.expectError(DataError.NoApiKey, pg_result);

    // Yahoo doesn't require API key
    const yh_result = svc.getProvider(Yahoo);
    try std.testing.expect(yh_result != error.NoApiKey);
}

test "DataService getProvider initializes provider with key" {
    const allocator = std.testing.allocator;
    const config = Config{
        .cache_dir = "/tmp/zfin-test-cache",
        .tiingo_key = "test-tiingo-key",
    };
    var svc = DataService.init(std.testing.io, allocator, config);
    defer svc.deinit();

    // First call initializes
    const tg1 = try svc.getProvider(Tiingo);
    try std.testing.expect(svc.tg != null);

    // Second call returns same instance
    const tg2 = try svc.getProvider(Tiingo);
    try std.testing.expect(tg1 == tg2);
}

test "DataService PriceLoadResult default values" {
    const result = DataService.PriceLoadResult{
        .cached_count = 0,
        .fetched_count = 0,
        .fail_count = 0,
        .stale_count = 0,
        .latest_date = null,
    };
    try std.testing.expectEqual(@as(usize, 0), result.cached_count);
    try std.testing.expect(result.latest_date == null);
}

test "DataService LoadAllResult default values" {
    const allocator = std.testing.allocator;
    var result = DataService.LoadAllResult{
        .prices = std.StringHashMap(f64).init(allocator),
        .cached_count = 0,
        .server_synced_count = 0,
        .provider_fetched_count = 0,
        .stale_count = 0,
        .failed_count = 0,
        .latest_date = null,
    };
    defer result.deinit();

    try std.testing.expectEqual(@as(usize, 0), result.prices.count());
}

test "FetchResult type construction" {
    // Verify FetchResult works for different types
    const candle_result = FetchResult(Candle){
        .data = &.{},
        .source = .cached,
        .timestamp = 0,
        .allocator = std.testing.allocator,
    };
    try std.testing.expect(candle_result.source == .cached);

    const div_result = FetchResult(Dividend){
        .data = &.{},
        .source = .fetched,
        .timestamp = 12345,
        .allocator = std.testing.allocator,
    };
    try std.testing.expect(div_result.source == .fetched);
    try std.testing.expectEqual(@as(i64, 12345), div_result.timestamp);
}
