const std = @import("std");
const log = std.log.scoped(.cache);
const srf = @import("srf");
const atomic = @import("../atomic.zig");
const version = @import("../version.zig");
const Date = @import("../Date.zig");
const Candle = @import("../models/candle.zig").Candle;
const Dividend = @import("../models/dividend.zig").Dividend;
const DividendType = @import("../models/dividend.zig").DividendType;
const Split = @import("../models/split.zig").Split;
const EarningsEvent = @import("../models/earnings.zig").EarningsEvent;
const ReportTime = @import("../models/earnings.zig").ReportTime;
const EtfProfile = @import("../models/etf_profile.zig").EtfProfile;
const Holding = @import("../models/etf_profile.zig").Holding;
const SectorWeight = @import("../models/etf_profile.zig").SectorWeight;

// ── Wall-clock policy ────────────────────────────────────────
//
// Every `std.Io.Timestamp.now(...)` call in this file is intentional:
// the cache layer's job is to record *when data landed on disk* and
// compute expiry relative to that. Threading a `now_s: i64` in from the
// caller wouldn't buy anything — we'd just push the clock read up one
// frame. The torn-SRF diagnostic filenames (line ~473) additionally
// require millisecond precision to avoid collisions, which a
// caller-provided second-resolution `now_s` couldn't give us.
const Lot = @import("../models/portfolio.zig").Lot;
const LotType = @import("../models/portfolio.zig").LotType;
const Portfolio = @import("../models/portfolio.zig").Portfolio;
const OptionsChain = @import("../models/option.zig").OptionsChain;
const OptionContract = @import("../models/option.zig").OptionContract;

/// TTL durations in seconds for cache expiry.
pub const Ttl = struct {
    const s_per_day = std.time.s_per_day;
    /// Historical candles older than 1 day never expire
    pub const candles_historical: i64 = -1; // infinite
    /// Latest day's candle refreshes every 23h45m (15-min buffer for cron jitter)
    pub const candles_latest: i64 = s_per_day - 15 * std.time.s_per_min;
    /// Dividend data refreshes biweekly
    pub const dividends: i64 = 14 * s_per_day;
    /// Split data refreshes biweekly
    pub const splits: i64 = 14 * s_per_day;
    /// Options chains refresh hourly
    pub const options: i64 = std.time.s_per_hour;
    /// Earnings refresh monthly, with smart refresh after announcements
    pub const earnings: i64 = 30 * s_per_day;
    /// ETF profiles refresh monthly
    pub const etf_profile: i64 = 30 * s_per_day;
};

pub const DataType = enum {
    candles_daily,
    candles_meta,
    dividends,
    splits,
    options,
    earnings,
    etf_profile,
    meta,

    pub fn fileName(self: DataType) []const u8 {
        return switch (self) {
            .candles_daily => "candles_daily.srf",
            .candles_meta => "candles_meta.srf",
            .dividends => "dividends.srf",
            .splits => "splits.srf",
            .options => "options.srf",
            .earnings => "earnings.srf",
            .etf_profile => "etf_profile.srf",
            .meta => "meta.srf",
        };
    }

    pub fn ttl(self: DataType) i64 {
        return switch (self) {
            .dividends => Ttl.dividends,
            .splits => Ttl.splits,
            .options => Ttl.options,
            .earnings => Ttl.earnings,
            .etf_profile => Ttl.etf_profile,
            .candles_daily, .candles_meta, .meta => 0,
        };
    }
};

/// Persistent SRF-backed cache with per-symbol, per-data-type files.
///
/// Layout:
///   {cache_dir}/{SYMBOL}/candles_daily.srf
///   {cache_dir}/{SYMBOL}/dividends.srf
///   {cache_dir}/{SYMBOL}/meta.srf
///   ...
pub const Store = struct {
    cache_dir: []const u8,
    allocator: std.mem.Allocator,
    io: std.Io,

    /// Optional post-processing callback applied to each record during deserialization.
    /// Used to dupe strings that outlive the SRF iterator, or apply domain-specific transforms.
    pub const PostProcessFn = fn (*anyopaque, std.mem.Allocator) anyerror!void;

    pub fn init(io: std.Io, allocator: std.mem.Allocator, cache_dir: []const u8) Store {
        return .{
            .io = io,
            .cache_dir = cache_dir,
            .allocator = allocator,
        };
    }

    // ── Generic typed API ────────────────────────────────────────

    /// Map a model type to its cache DataType.
    pub fn dataTypeFor(comptime T: type) DataType {
        return switch (T) {
            Candle => .candles_daily,
            Dividend => .dividends,
            Split => .splits,
            EarningsEvent => .earnings,
            OptionsChain => .options,
            EtfProfile => .etf_profile,
            else => @compileError("unsupported type for Store"),
        };
    }

    /// The data payload for a given type: single struct for EtfProfile, slice for everything else.
    pub fn DataFor(comptime T: type) type {
        return if (T == EtfProfile) EtfProfile else []T;
    }

    pub fn CacheResult(comptime T: type) type {
        return struct { data: DataFor(T), timestamp: i64 };
    }

    pub const Freshness = enum { fresh_only, any };

    /// Read and deserialize cached data. With `.fresh_only`, returns null if stale.
    /// With `.any`, returns data regardless of freshness.
    pub fn read(
        self: *Store,
        comptime T: type,
        symbol: []const u8,
        comptime postProcess: ?*const fn (*T, std.mem.Allocator) anyerror!void,
        comptime freshness: Freshness,
    ) ?CacheResult(T) {
        const raw = self.readRaw(symbol, dataTypeFor(T)) catch return null;
        const data = raw orelse return null;
        defer self.allocator.free(data);

        // Read-path self-heal: if a cached candles_daily file doesn't
        // look like a complete SRF doc, archive the torn body for
        // post-mortem and wipe the candle pair (daily + meta) so the
        // next call re-fetches from scratch. Limited to Candle on
        // purpose — it's the only data type with a recurring tear
        // history + sibling meta to keep in sync, and the only one
        // where a silent re-parse-miss every run would be wasted work.
        if (T == Candle and !looksCompleteSrf(data)) {
            self.selfHealTornCandles(symbol, data);
            return null;
        }

        if (T == EtfProfile or T == OptionsChain) {
            const is_negative = std.mem.eql(u8, data, negative_cache_content);
            if (is_negative) {
                if (freshness == .fresh_only) {
                    // Negative entries are always fresh — return empty data
                    if (T == EtfProfile)
                        return .{ .data = EtfProfile{ .symbol = "" }, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds() };
                    if (T == OptionsChain)
                        return .{ .data = &.{}, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds() };
                }
                return null;
            }

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

            if (freshness == .fresh_only) {
                if (it.expires == null) return null;
                if (!it.isFresh(self.io)) return null;
            }
            const timestamp = it.created orelse std.Io.Timestamp.now(self.io, .real).toSeconds();

            if (T == EtfProfile) {
                const profile = deserializeEtfProfile(self.allocator, &it) catch return null;
                return .{ .data = profile, .timestamp = timestamp };
            }
            if (T == OptionsChain) {
                const items = deserializeOptions(self.allocator, &it) catch return null;
                return .{ .data = items, .timestamp = timestamp };
            }
        }

        return readSlice(T, self.io, self.allocator, data, postProcess, freshness);
    }

    /// Serialize data and write to cache with the given TTL.
    /// Accepts a slice for most types, or a single struct for EtfProfile.
    pub fn write(
        self: *Store,
        comptime T: type,
        symbol: []const u8,
        items: DataFor(T),
        ttl: i64,
    ) void {
        const expires = std.Io.Timestamp.now(self.io, .real).toSeconds() + ttl;
        const data_type = dataTypeFor(T);
        if (T == EtfProfile) {
            const srf_data = serializeEtfProfile(self.io, self.allocator, items, .{ .expires = expires }) catch |err| {
                log.warn("{s}: failed to serialize ETF profile: {s}", .{ symbol, @errorName(err) });
                return;
            };
            defer self.allocator.free(srf_data);
            self.writeRaw(symbol, data_type, srf_data) catch |err| {
                log.warn("{s}: failed to write ETF profile to cache: {s}", .{ symbol, @errorName(err) });
            };
            return;
        }
        if (T == OptionsChain) {
            const srf_data = serializeOptions(self.io, self.allocator, items, .{ .expires = expires }) catch |err| {
                log.warn("{s}: failed to serialize options: {s}", .{ symbol, @errorName(err) });
                return;
            };
            defer self.allocator.free(srf_data);
            self.writeRaw(symbol, data_type, srf_data) catch |err| {
                log.warn("{s}: failed to write options to cache: {s}", .{ symbol, @errorName(err) });
            };
            return;
        }
        const srf_data = serializeWithMeta(T, self.io, self.allocator, items, .{ .expires = expires }) catch |err| {
            log.warn("{s}: failed to serialize {s}: {s}", .{ symbol, @tagName(data_type), @errorName(err) });
            return;
        };
        defer self.allocator.free(srf_data);
        self.writeRaw(symbol, data_type, srf_data) catch |err| {
            log.warn("{s}: failed to write {s} to cache: {s}", .{ symbol, @tagName(data_type), @errorName(err) });
        };
    }

    // ── Candle-specific API ──────────────────────────────────────

    /// Write a full set of candles to cache (no expiry — historical facts don't expire).
    /// Also updates candle metadata.
    pub fn cacheCandles(self: *Store, symbol: []const u8, candles: []const Candle, provider: CandleProvider, fail_count: u8) void {
        if (serializeCandles(self.allocator, candles, .{})) |srf_data| {
            defer self.allocator.free(srf_data);
            self.writeRaw(symbol, .candles_daily, srf_data) catch |err| {
                log.warn("{s}: failed to write candles to cache: {s}", .{ symbol, @errorName(err) });
            };
        } else |err| {
            log.warn("{s}: failed to serialize candles: {s}", .{ symbol, @errorName(err) });
        }

        if (candles.len > 0) {
            const last = candles[candles.len - 1];
            self.updateCandleMeta(symbol, last.close, last.date, provider, fail_count);
        }
    }

    /// Append new candle records to the existing cache file.
    /// Falls back to a full rewrite if append fails (e.g. file doesn't exist).
    /// Also updates candle metadata.
    pub fn appendCandles(self: *Store, symbol: []const u8, new_candles: []const Candle, provider: CandleProvider, fail_count: u8) void {
        if (new_candles.len == 0) return;

        if (serializeCandles(self.allocator, new_candles, .{ .emit_directives = false })) |srf_data| {
            defer self.allocator.free(srf_data);
            self.appendRaw(symbol, .candles_daily, srf_data) catch |append_err| {
                // Append failed (file missing?) — fall back to full load + rewrite
                log.debug("{s}: append failed ({s}), falling back to full rewrite", .{ symbol, @errorName(append_err) });
                if (self.read(Candle, symbol, null, .any)) |existing| {
                    defer self.allocator.free(existing.data);
                    const merged = self.allocator.alloc(Candle, existing.data.len + new_candles.len) catch return;
                    defer self.allocator.free(merged);
                    @memcpy(merged[0..existing.data.len], existing.data);
                    @memcpy(merged[existing.data.len..], new_candles);
                    if (serializeCandles(self.allocator, merged, .{})) |full_data| {
                        defer self.allocator.free(full_data);
                        self.writeRaw(symbol, .candles_daily, full_data) catch |err| {
                            log.warn("{s}: failed to write merged candles to cache: {s}", .{ symbol, @errorName(err) });
                        };
                    } else |err| {
                        log.warn("{s}: failed to serialize merged candles: {s}", .{ symbol, @errorName(err) });
                    }
                }
            };
        } else |err| {
            log.warn("{s}: failed to serialize new candles for append: {s}", .{ symbol, @errorName(err) });
        }

        const last = new_candles[new_candles.len - 1];
        self.updateCandleMeta(symbol, last.close, last.date, provider, fail_count);
    }

    /// Write (or refresh) candle metadata with a specific provider source.
    pub fn updateCandleMeta(self: *Store, symbol: []const u8, last_close: f64, last_date: Date, provider: CandleProvider, fail_count: u8) void {
        const expires = std.Io.Timestamp.now(self.io, .real).toSeconds() + Ttl.candles_latest;
        const meta = CandleMeta{
            .last_close = last_close,
            .last_date = last_date,
            .provider = provider,
            .fail_count = fail_count,
        };
        if (serializeCandleMeta(self.io, self.allocator, meta, .{ .expires = expires })) |meta_data| {
            defer self.allocator.free(meta_data);
            self.writeRaw(symbol, .candles_meta, meta_data) catch |err| {
                log.warn("{s}: failed to write candle metadata: {s}", .{ symbol, @errorName(err) });
            };
        } else |err| {
            log.warn("{s}: failed to serialize candle metadata: {s}", .{ symbol, @errorName(err) });
        }
    }

    // ── Cache management ─────────────────────────────────────────

    /// Ensure the cache directory for a symbol exists.
    pub fn ensureSymbolDir(self: *Store, symbol: []const u8) !void {
        const path = try self.symbolPath(symbol, "");
        defer self.allocator.free(path);
        std.Io.Dir.cwd().createDirPath(self.io, path) catch |err| switch (err) {
            error.PathAlreadyExists => {},
            else => return err,
        };
    }

    /// Clear all cached data for a symbol.
    pub fn clearSymbol(self: *Store, symbol: []const u8) !void {
        const path = try self.symbolPath(symbol, "");
        defer self.allocator.free(path);
        std.Io.Dir.cwd().deleteTree(self.io, path) catch {};
    }

    /// Content of a negative cache entry (fetch failed, don't retry until --refresh).
    pub const negative_cache_content = "#!srfv1\n# fetch_failed\n";

    /// Write a negative cache entry for a symbol + data type.
    /// This records that a fetch was attempted and failed, preventing repeated
    /// network requests for symbols that will never resolve.
    /// Cleared by --refresh (which calls clearData/invalidate).
    pub fn writeNegative(self: *Store, symbol: []const u8, data_type: DataType) void {
        self.writeRaw(symbol, data_type, negative_cache_content) catch {};
    }

    /// Validate that a byte buffer looks like a complete SRF file.
    ///
    /// A well-formed SRF file should:
    ///   - Be non-empty.
    ///   - Start with the `#!srfv1` version directive (the negative-cache
    ///     marker also starts this way, so a single check covers both).
    ///   - End with a newline. Every record and every directive is
    ///     newline-terminated; a completely-written file always has a
    ///     trailing `\n`.
    ///
    /// Torn HTTP body writes land short of the final newline. The FRDM
    /// corruption on 2026-05-02 is the canonical example: the tail of
    /// `candles_daily.srf` ended with `date::2026-04` mid-record, no
    /// comma, no OHLCV fields, no newline. Atomic file writes correctly
    /// persist that truncated body — `writeFileAtomic` is about rename
    /// integrity, not payload validity. This helper is the payload-
    /// validity check that cache-write callers should gate on when the
    /// bytes come from an untrusted source (server sync, external file
    /// import). Locally-serialized payloads are complete by construction
    /// and don't need to run through this.
    pub fn looksCompleteSrf(data: []const u8) bool {
        if (data.len == 0) return false;
        if (!std.mem.startsWith(u8, data, "#!srfv1")) return false;
        if (data[data.len - 1] != '\n') return false;
        return true;
    }

    /// Why a body was classified as torn. Surfaced in the archived
    /// `.meta` sidecar so post-mortems can distinguish between detection
    /// paths.
    pub const FailureReason = enum {
        /// `looksCompleteSrf` rejected the body bytes after an HTTP
        /// response. The data reached us from the network but didn't
        /// satisfy the structural sanity check.
        looks_complete_srf_failed,
        /// A cached file on disk failed to parse when read. Indicates
        /// either an earlier torn write that slipped past the guard, or
        /// local filesystem corruption.
        srf_parse_failed,
        /// The integrity digest advertised by the server (e.g. an
        /// `ETag: "sha256:..."`) did not match the body bytes we
        /// received. Reserved for the HTTP integrity work in Milestone 2.
        etag_mismatch,
    };

    /// Context captured alongside a torn body. All fields are optional
    /// because the exact detection site determines which signals are
    /// available. Consumed by `archiveTornBody` to produce the SRF
    /// `.meta` sidecar.
    pub const TornMeta = struct {
        failure_reason: FailureReason,
        /// HTTP status code when the body originated from an HTTP
        /// response. Null for parse failures discovered on local read.
        http_status: ?u16 = null,
        /// Raw `Content-Length` header value from the HTTP response,
        /// when the detection path has access to it.
        http_content_length: ?u64 = null,
        /// Source URL when the body came from an HTTP response.
        server_url: ?[]const u8 = null,
        /// Raw value of the server's `ETag` header, if present. Captured
        /// verbatim (including surrounding quotes and any `sha256:` or
        /// other scheme prefix) so post-mortem can see exactly what the
        /// server advertised. Paired with `body_sha256` in the sidecar,
        /// an `etag_mismatch` failure is trivially explained.
        server_etag: ?[]const u8 = null,
    };

    /// Schema for the SRF `.meta` sidecar emitted by `archiveTornBody`.
    /// Each field becomes a `key:type:value` entry in a single record
    /// under a `#!srfv1` header. Optional fields with `null` defaults
    /// are silently skipped by `srf.fmtFrom` when unset — which is the
    /// behavior we want for the http_*, server_*, and `?[]const u8`
    /// fields that only some detection paths populate.
    const TearRecord = struct {
        /// Fixed marker so `_torn/*.meta` is distinguishable at a glance
        /// from any other SRF record a future reader might encounter in
        /// the cache tree.
        type: []const u8,
        symbol: []const u8,
        data_type: DataType,
        unix_ts: i64,
        iso_ts: []const u8,
        body_length: u64,
        body_sha256: []const u8,
        failure_reason: FailureReason,
        zfin_commit: []const u8,
        http_status: ?u16 = null,
        http_content_length: ?u64 = null,
        server_url: ?[]const u8 = null,
        server_etag: ?[]const u8 = null,
        last_200_bytes_hex: []const u8,
    };

    /// Archive a torn cache body for post-mortem diagnosis.
    ///
    /// Writes two sibling files under `{cache_dir}/_torn/`:
    ///   `{symbol}_{data_type}_{unix_ms}.bin`  — the raw bytes as received
    ///   `{symbol}_{data_type}_{unix_ms}.meta` — SRF-formatted context
    ///
    /// Filenames carry a millisecond-resolution timestamp so a retry
    /// loop that tears twice within the same wall-clock second
    /// produces distinct archive pairs — two back-to-back captures are
    /// the most valuable forensic signal we can produce (byte offsets,
    /// tail shapes, and time deltas are all impossible to infer from a
    /// single failure).
    ///
    /// The `.meta` file mirrors the rest of the cache's on-disk format
    /// so tooling can iterate it with the standard SRF reader. See
    /// `TearRecord` above for the schema. Required fields are always
    /// emitted; `http_status`, `http_content_length`, `server_url`,
    /// and `server_etag` appear only when the caller populated them
    /// in `TornMeta`.
    ///
    /// Best-effort: a failure here should never mask the original
    /// detection path's return value. Callers log.debug the outcome and
    /// move on.
    pub fn archiveTornBody(
        io: std.Io,
        allocator: std.mem.Allocator,
        cache_dir: []const u8,
        symbol: []const u8,
        data_type: DataType,
        bytes: []const u8,
        meta: TornMeta,
    ) !void {
        // Ensure the _torn/ directory exists.
        const torn_dir = try std.fs.path.join(allocator, &.{ cache_dir, "_torn" });
        defer allocator.free(torn_dir);
        std.Io.Dir.cwd().createDirPath(io, torn_dir) catch |err| switch (err) {
            error.PathAlreadyExists => {},
            else => return err,
        };

        // Second-resolution timestamp for the SRF `unix_ts` field (plus
        // ISO rendering). Millisecond-resolution timestamp is used in
        // the filename so retries within the same wall-clock second
        // produce distinct archive entries rather than overwriting each
        // other — two back-to-back tears from a refresh retry are the
        // most valuable forensic signal we can capture.
        const ts = std.Io.Timestamp.now(io, .real).toSeconds();
        const ts_ms = @divTrunc(std.Io.Timestamp.now(io, .real).nanoseconds, std.time.ns_per_ms);

        const bin_name = try std.fmt.allocPrint(
            allocator,
            "{s}_{s}_{d}.bin",
            .{ symbol, @tagName(data_type), ts_ms },
        );
        defer allocator.free(bin_name);
        const bin_path = try std.fs.path.join(allocator, &.{ torn_dir, bin_name });
        defer allocator.free(bin_path);

        const meta_name = try std.fmt.allocPrint(
            allocator,
            "{s}_{s}_{d}.meta",
            .{ symbol, @tagName(data_type), ts_ms },
        );
        defer allocator.free(meta_name);
        const meta_path = try std.fs.path.join(allocator, &.{ torn_dir, meta_name });
        defer allocator.free(meta_path);

        // Write the raw body first — if this fails we don't bother with
        // the sidecar, since the sidecar is only useful paired with bytes.
        try atomic.writeFileAtomic(io, allocator, bin_path, bytes);

        // Compute sha256 of the body for the sidecar.
        var hash: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
        std.crypto.hash.sha2.Sha256.hash(bytes, &hash, .{});
        var hash_hex: [std.crypto.hash.sha2.Sha256.digest_length * 2]u8 = undefined;
        _ = std.fmt.bufPrint(&hash_hex, "{x}", .{&hash}) catch unreachable;

        // ISO-8601 UTC timestamp — computed by hand to avoid pulling in
        // a dependency. Format: YYYY-MM-DDTHH:MM:SSZ.
        const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = @intCast(ts) };
        const day_seconds = epoch_seconds.getDaySeconds();
        const epoch_day = epoch_seconds.getEpochDay();
        const year_day = epoch_day.calculateYearDay();
        const month_day = year_day.calculateMonthDay();
        var iso_buf: [32]u8 = undefined;
        const iso_ts = std.fmt.bufPrint(&iso_buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{
            year_day.year,
            @intFromEnum(month_day.month),
            month_day.day_index + 1,
            day_seconds.getHoursIntoDay(),
            day_seconds.getMinutesIntoHour(),
            day_seconds.getSecondsIntoMinute(),
        }) catch unreachable;

        // Last 200 bytes as lowercase hex so the sidecar is
        // grep-friendly regardless of what binary garbage sits in the
        // torn tail.
        const tail_len: usize = @min(bytes.len, 200);
        const tail = bytes[bytes.len - tail_len ..];
        const tail_hex = try allocator.alloc(u8, tail_len * 2);
        defer allocator.free(tail_hex);
        _ = std.fmt.bufPrint(tail_hex, "{x}", .{tail}) catch unreachable;

        const record = TearRecord{
            .type = "tear_metadata",
            .symbol = symbol,
            .data_type = data_type,
            .unix_ts = ts,
            .iso_ts = iso_ts,
            .body_length = bytes.len,
            .body_sha256 = &hash_hex,
            .failure_reason = meta.failure_reason,
            .zfin_commit = version.version_string,
            .http_status = meta.http_status,
            .http_content_length = meta.http_content_length,
            .server_url = meta.server_url,
            .server_etag = meta.server_etag,
            .last_200_bytes_hex = tail_hex,
        };

        var aw: std.Io.Writer.Allocating = .init(allocator);
        defer aw.deinit();
        const records = [_]TearRecord{record};
        try aw.writer.print("{f}", .{srf.fmtFrom(TearRecord, allocator, &records, .{})});

        try atomic.writeFileAtomic(io, allocator, meta_path, aw.writer.buffered());
    }

    /// Read-path self-heal for candle data. On detecting a torn
    /// `candles_daily.srf` at read time, archive the bytes with full
    /// diagnostic context, then wipe both `candles_daily.srf` and its
    /// companion `candles_meta.srf` so the next fetch cycle pulls a
    /// clean pair.
    ///
    /// Best-effort throughout — archive failures are logged at debug
    /// and do NOT block the cache invalidation. The goal is to keep
    /// the read path recoverable; diagnostics are a bonus.
    fn selfHealTornCandles(self: *Store, symbol: []const u8, data: []const u8) void {
        archiveTornBody(
            self.io,
            self.allocator,
            self.cache_dir,
            symbol,
            .candles_daily,
            data,
            .{ .failure_reason = .srf_parse_failed },
        ) catch |err| {
            log.debug(
                "{s}: failed to archive torn candles_daily body ({d} bytes): {s}",
                .{ symbol, data.len, @errorName(err) },
            );
        };
        log.debug(
            "{s}: self-heal wiping torn candles_daily+candles_meta pair ({d} bytes)",
            .{ symbol, data.len },
        );
        self.clearData(symbol, .candles_daily);
        self.clearData(symbol, .candles_meta);
    }

    /// Check if a cached data file is a negative entry (fetch_failed marker).
    /// Negative entries are always considered "fresh" -- they never expire.
    pub fn isNegative(self: *Store, symbol: []const u8, data_type: DataType) bool {
        const path = self.symbolPath(symbol, data_type.fileName()) catch return false;
        defer self.allocator.free(path);

        const file = std.Io.Dir.cwd().openFile(self.io, path, .{}) catch return false;
        defer file.close(self.io);

        var buf: [negative_cache_content.len]u8 = undefined;
        var file_reader = file.reader(self.io, &.{});
        const n = file_reader.interface.readSliceShort(&buf) catch return false;
        return n == negative_cache_content.len and
            std.mem.eql(u8, buf[0..n], negative_cache_content);
    }

    /// Clear a specific data type for a symbol.
    pub fn clearData(self: *Store, symbol: []const u8, data_type: DataType) void {
        const path = self.symbolPath(symbol, data_type.fileName()) catch return;
        defer self.allocator.free(path);
        std.Io.Dir.cwd().deleteFile(self.io, path) catch {};
    }

    /// Read the close price from the candle metadata file.
    /// Returns null if no metadata exists.
    pub fn readLastClose(self: *Store, symbol: []const u8) ?f64 {
        const raw = self.readRaw(symbol, .candles_meta) catch return null;
        const data = raw orelse return null;
        defer self.allocator.free(data);
        const meta = deserializeCandleMeta(self.allocator, data) catch return null;
        return meta.last_close;
    }

    /// Read the full candle metadata (last_close, last_date) plus the `#!created=` timestamp.
    /// Returns null if no metadata exists.
    pub fn readCandleMeta(self: *Store, symbol: []const u8) ?struct { meta: CandleMeta, created: i64 } {
        const raw = self.readRaw(symbol, .candles_meta) catch return null;
        const data = raw orelse 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();

        const created = it.created orelse std.Io.Timestamp.now(self.io, .real).toSeconds();
        const fields = (it.next() catch return null) orelse return null;
        const meta = fields.to(CandleMeta) catch return null;
        return .{ .meta = meta, .created = created };
    }

    /// Check if candle metadata is fresh using the embedded `#!expires=` directive.
    pub fn isCandleMetaFresh(self: *Store, symbol: []const u8) bool {
        const raw = self.readRaw(symbol, .candles_meta) catch return false;
        const data = raw orelse return false;
        defer self.allocator.free(data);

        if (std.mem.indexOf(u8, data, "# fetch_failed")) |_| return true;

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

        if (it.expires == null) return false;
        return it.isFresh(self.io);
    }

    /// Clear all cached data.
    pub fn clearAll(self: *Store) !void {
        std.Io.Dir.cwd().deleteTree(self.io, self.cache_dir) catch {};
    }

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

    /// Metadata stored in the separate candles_meta.srf file.
    /// Allows fast price lookups and freshness checks without parsing the full candle file.
    /// The `#!created=` directive tracks when this metadata was written (replaces fetched_at).
    pub const CandleMeta = struct {
        last_close: f64,
        last_date: Date,
        /// Which provider sourced the candle data. Used during incremental refresh
        /// to go directly to the right provider instead of trying Tiingo first.
        provider: CandleProvider = .tiingo,
        /// Consecutive transient failure count for the primary provider (Tiingo).
        /// Incremented on ServerError; reset to 0 on success. When >= 3, the
        /// symbol is degraded to a fallback provider until Tiingo recovers.
        fail_count: u8 = 0,
    };

    pub const CandleProvider = enum {
        twelvedata,
        yahoo,
        tiingo,

        pub fn fromString(s: []const u8) CandleProvider {
            if (std.mem.eql(u8, s, "yahoo")) return .yahoo;
            if (std.mem.eql(u8, s, "tiingo")) return .tiingo;
            return .twelvedata;
        }
    };

    // ── Private I/O ──────────────────────────────────────────────

    fn readRaw(self: *Store, symbol: []const u8, data_type: DataType) !?[]const u8 {
        const path = try self.symbolPath(symbol, data_type.fileName());
        defer self.allocator.free(path);

        return std.Io.Dir.cwd().readFileAlloc(self.io, path, self.allocator, .limited(50 * 1024 * 1024)) catch |err| switch (err) {
            error.FileNotFound => return null,
            else => return err,
        };
    }

    /// Write raw bytes to a cache file. Used by server sync to write
    /// pre-serialized SRF data directly to the cache.
    ///
    /// Atomic: writes to `<path>.tmp`, fsyncs, and renames. A concurrent
    /// reader sees either the old complete file or the new complete file —
    /// never a truncated-mid-write state. This matters because:
    ///
    ///   - `parallelServerSync` (service.zig) writes many cache files in
    ///     parallel; other code paths in the same process (TUI redraws,
    ///     progress callbacks that happen to peek, future concurrent
    ///     readers) must never observe a half-written file.
    ///   - The prior implementation used `createFile(truncate) + writeAll`,
    ///     which has a window between truncation and write-completion
    ///     where a reader gets 0..data.len bytes. The symptom was SRF
    ///     `custom parse of value 2026-04 failed : InvalidDateFormat`
    ///     — a date field truncated exactly 7 chars into its 10-char
    ///     value.
    pub fn writeRaw(self: *Store, symbol: []const u8, data_type: DataType, data: []const u8) !void {
        try self.ensureSymbolDir(symbol);
        const path = try self.symbolPath(symbol, data_type.fileName());
        defer self.allocator.free(path);

        try atomic.writeFileAtomic(self.io, self.allocator, path, data);
    }

    /// Append raw bytes to an existing cache file.
    ///
    /// Implemented as read-existing + concat + atomic write rather than
    /// a direct open-for-append, for the same reason as `writeRaw`:
    /// a true append (`seekFromEnd + writeAll`) leaves a reader that hits
    /// the file mid-append with a valid head + partial tail, which for
    /// SRF data means a truncated trailing record. With atomic rewrite,
    /// the rename primitive guarantees readers see either the pre-append
    /// state or the post-append state, never an in-between.
    ///
    /// Returns `error.FileNotFound` if the target doesn't exist yet —
    /// callers are expected to fall back to a full rewrite path in that
    /// case (see `appendCandles`).
    fn appendRaw(self: *Store, symbol: []const u8, data_type: DataType, data: []const u8) !void {
        const path = try self.symbolPath(symbol, data_type.fileName());
        defer self.allocator.free(path);

        const existing = std.Io.Dir.cwd().readFileAlloc(self.io, path, self.allocator, .limited(50 * 1024 * 1024)) catch |err| switch (err) {
            error.FileNotFound => return error.FileNotFound,
            else => return err,
        };
        defer self.allocator.free(existing);

        const combined = try self.allocator.alloc(u8, existing.len + data.len);
        defer self.allocator.free(combined);
        @memcpy(combined[0..existing.len], existing);
        @memcpy(combined[existing.len..], data);

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

    fn symbolPath(self: *Store, symbol: []const u8, file_name: []const u8) ![]const u8 {
        if (file_name.len == 0) {
            return std.fs.path.join(self.allocator, &.{ self.cache_dir, symbol });
        }
        return std.fs.path.join(self.allocator, &.{ self.cache_dir, symbol, file_name });
    }

    // ── Private serialization: generic ───────────────────────────

    /// Generic SRF deserializer with optional freshness check.
    /// Single-pass: creates one iterator, optionally checks freshness, extracts
    /// `#!created=` timestamp, and deserializes all records.
    fn readSlice(
        comptime T: type,
        io: std.Io,
        allocator: std.mem.Allocator,
        data: []const u8,
        comptime postProcess: ?*const fn (*T, std.mem.Allocator) anyerror!void,
        comptime freshness: Freshness,
    ) ?CacheResult(T) {
        var reader = std.Io.Reader.fixed(data);
        var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return null;
        defer it.deinit();

        if (freshness == .fresh_only) {
            // Negative cache entries are always "fresh" — they match exactly
            const is_negative = std.mem.eql(u8, data, negative_cache_content);
            if (!is_negative) {
                if (it.expires == null) return null;
                if (!it.isFresh(io)) return null;
            }
        }

        const timestamp: i64 = it.created orelse std.Io.Timestamp.now(io, .real).toSeconds();

        var items: std.ArrayList(T) = .empty;
        defer {
            if (items.items.len != 0) {
                if (comptime @hasDecl(T, "deinit")) {
                    for (items.items) |item| item.deinit(allocator);
                }
                items.deinit(allocator);
            }
        }

        while (it.next() catch return null) |fields| {
            var item = fields.to(T) catch continue;
            if (comptime postProcess) |pp| {
                pp(&item, allocator) catch {
                    if (comptime @hasDecl(T, "deinit")) item.deinit(allocator);
                    return null;
                };
            }
            items.append(allocator, item) catch {
                if (comptime @hasDecl(T, "deinit")) item.deinit(allocator);
                return null;
            };
        }

        const result = items.toOwnedSlice(allocator) catch return null;
        items = .empty; // prevent defer from freeing the returned slice
        return .{ .data = result, .timestamp = timestamp };
    }

    /// Generic SRF serializer: emit directives (including `#!created=`) then data records.
    fn serializeWithMeta(
        comptime T: type,
        io: std.Io,
        allocator: std.mem.Allocator,
        items: []const T,
        options: srf.FormatOptions,
    ) ![]const u8 {
        var aw: std.Io.Writer.Allocating = .init(allocator);
        errdefer aw.deinit();
        var opts = options;
        opts.created = std.Io.Timestamp.now(io, .real).toSeconds();
        try aw.writer.print("{f}", .{srf.fmtFrom(T, allocator, items, opts)});
        return aw.toOwnedSlice();
    }

    // ── Private serialization: candles ───────────────────────────

    fn serializeCandles(allocator: std.mem.Allocator, candles: []const Candle, options: srf.FormatOptions) ![]const u8 {
        var aw: std.Io.Writer.Allocating = .init(allocator);
        errdefer aw.deinit();
        try aw.writer.print("{f}", .{srf.fmtFrom(Candle, allocator, candles, options)});
        return aw.toOwnedSlice();
    }

    fn serializeCandleMeta(io: std.Io, allocator: std.mem.Allocator, meta: CandleMeta, options: srf.FormatOptions) ![]const u8 {
        var aw: std.Io.Writer.Allocating = .init(allocator);
        errdefer aw.deinit();
        const items = [_]CandleMeta{meta};
        var opts = options;
        opts.created = std.Io.Timestamp.now(io, .real).toSeconds();
        try aw.writer.print("{f}", .{srf.fmtFrom(CandleMeta, allocator, &items, opts)});
        return aw.toOwnedSlice();
    }

    fn deserializeCandleMeta(allocator: std.mem.Allocator, data: []const u8) !CandleMeta {
        var reader = std.Io.Reader.fixed(data);
        var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData;
        defer it.deinit();

        const fields = (try it.next()) orelse return error.InvalidData;
        return fields.to(CandleMeta) catch error.InvalidData;
    }

    // ── Private serialization: options (bespoke) ─────────────────

    const ChainHeader = struct {
        expiration: Date,
        symbol: []const u8,
        price: ?f64 = null,
    };

    const OptionsRecord = union(enum) {
        pub const srf_tag_field = "type";
        chain: ChainHeader,
        call: OptionContract,
        put: OptionContract,
    };

    fn serializeOptions(io: std.Io, allocator: std.mem.Allocator, chains: []const OptionsChain, options: srf.FormatOptions) ![]const u8 {
        var records: std.ArrayList(OptionsRecord) = .empty;
        defer records.deinit(allocator);

        for (chains) |chain| {
            try records.append(allocator, .{ .chain = .{
                .expiration = chain.expiration,
                .symbol = chain.underlying_symbol,
                .price = chain.underlying_price,
            } });
            for (chain.calls) |c|
                try records.append(allocator, .{ .call = c });
            for (chain.puts) |p|
                try records.append(allocator, .{ .put = p });
        }

        var aw: std.Io.Writer.Allocating = .init(allocator);
        errdefer aw.deinit();
        var opts = options;
        opts.created = std.Io.Timestamp.now(io, .real).toSeconds();
        try aw.writer.print("{f}", .{srf.fmtFrom(OptionsRecord, allocator, records.items, opts)});
        return aw.toOwnedSlice();
    }

    fn deserializeOptions(allocator: std.mem.Allocator, it: *srf.RecordIterator) ![]OptionsChain {
        var chains: std.ArrayList(OptionsChain) = .empty;
        errdefer {
            for (chains.items) |*ch| {
                allocator.free(ch.underlying_symbol);
                allocator.free(ch.calls);
                allocator.free(ch.puts);
            }
            chains.deinit(allocator);
        }

        var exp_map = std.AutoHashMap(i32, usize).init(allocator);
        defer exp_map.deinit();

        var calls_map = std.AutoHashMap(usize, std.ArrayList(OptionContract)).init(allocator);
        defer {
            var iter = calls_map.valueIterator();
            while (iter.next()) |v| v.deinit(allocator);
            calls_map.deinit();
        }
        var puts_map = std.AutoHashMap(usize, std.ArrayList(OptionContract)).init(allocator);
        defer {
            var iter = puts_map.valueIterator();
            while (iter.next()) |v| v.deinit(allocator);
            puts_map.deinit();
        }

        while (try it.next()) |fields| {
            const opt_rec = fields.to(OptionsRecord) catch continue;
            switch (opt_rec) {
                .chain => |ch| {
                    const idx = chains.items.len;
                    try chains.append(allocator, .{
                        .underlying_symbol = try allocator.dupe(u8, ch.symbol),
                        .underlying_price = ch.price,
                        .expiration = ch.expiration,
                        .calls = &.{},
                        .puts = &.{},
                    });
                    try exp_map.put(ch.expiration.days, idx);
                },
                .call => |c| {
                    if (exp_map.get(c.expiration.days)) |idx| {
                        const entry = try calls_map.getOrPut(idx);
                        if (!entry.found_existing) entry.value_ptr.* = .empty;
                        try entry.value_ptr.append(allocator, c);
                    }
                },
                .put => |c| {
                    if (exp_map.get(c.expiration.days)) |idx| {
                        const entry = try puts_map.getOrPut(idx);
                        if (!entry.found_existing) entry.value_ptr.* = .empty;
                        try entry.value_ptr.append(allocator, c);
                    }
                },
            }
        }

        for (chains.items, 0..) |*chain, idx| {
            if (calls_map.getPtr(idx)) |cl| {
                chain.calls = try cl.toOwnedSlice(allocator);
            }
            if (puts_map.getPtr(idx)) |pl| {
                chain.puts = try pl.toOwnedSlice(allocator);
            }
        }

        return chains.toOwnedSlice(allocator);
    }

    // ── Private serialization: ETF profile (bespoke) ─────────────

    const EtfRecord = union(enum) {
        pub const srf_tag_field = "type";
        meta: EtfProfile,
        sector: SectorWeight,
        holding: Holding,
    };

    fn serializeEtfProfile(io: std.Io, allocator: std.mem.Allocator, profile: EtfProfile, options: srf.FormatOptions) ![]const u8 {
        var records: std.ArrayList(EtfRecord) = .empty;
        defer records.deinit(allocator);

        try records.append(allocator, .{ .meta = profile });
        if (profile.sectors) |sectors| {
            for (sectors) |s| try records.append(allocator, .{ .sector = s });
        }
        if (profile.holdings) |holdings| {
            for (holdings) |h| try records.append(allocator, .{ .holding = h });
        }

        var aw: std.Io.Writer.Allocating = .init(allocator);
        errdefer aw.deinit();
        var opts = options;
        opts.created = std.Io.Timestamp.now(io, .real).toSeconds();
        try aw.writer.print("{f}", .{srf.fmtFrom(EtfRecord, allocator, records.items, opts)});
        return aw.toOwnedSlice();
    }

    fn deserializeEtfProfile(allocator: std.mem.Allocator, it: *srf.RecordIterator) !EtfProfile {
        var profile = EtfProfile{ .symbol = "" };
        var sectors: std.ArrayList(SectorWeight) = .empty;
        errdefer {
            for (sectors.items) |s| allocator.free(s.name);
            sectors.deinit(allocator);
        }
        var holdings: std.ArrayList(Holding) = .empty;
        errdefer {
            for (holdings.items) |h| {
                if (h.symbol) |s| allocator.free(s);
                allocator.free(h.name);
            }
            holdings.deinit(allocator);
        }

        while (try it.next()) |fields| {
            const etf_rec = fields.to(EtfRecord) catch continue;
            switch (etf_rec) {
                .meta => |m| {
                    profile = m;
                },
                .sector => |s| {
                    const duped = try allocator.dupe(u8, s.name);
                    try sectors.append(allocator, .{ .name = duped, .weight = s.weight });
                },
                .holding => |h| {
                    const duped_sym = if (h.symbol) |s| try allocator.dupe(u8, s) else null;
                    const duped_name = try allocator.dupe(u8, h.name);
                    try holdings.append(allocator, .{ .symbol = duped_sym, .name = duped_name, .weight = h.weight });
                },
            }
        }

        if (sectors.items.len > 0) {
            profile.sectors = try sectors.toOwnedSlice(allocator);
        } else {
            sectors.deinit(allocator);
        }
        if (holdings.items.len > 0) {
            profile.holdings = try holdings.toOwnedSlice(allocator);
        } else {
            holdings.deinit(allocator);
        }

        return profile;
    }
};

/// Serialize a portfolio (list of lots) to SRF format.
pub fn serializePortfolio(allocator: std.mem.Allocator, lots: []const Lot) ![]const u8 {
    var aw: std.Io.Writer.Allocating = .init(allocator);
    errdefer aw.deinit();
    try aw.writer.print("{f}", .{srf.fmtFrom(Lot, allocator, lots, .{})});
    return aw.toOwnedSlice();
}

/// Deserialize a portfolio from SRF data. Caller owns the returned Portfolio.
pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Portfolio {
    var lots: std.ArrayList(Lot) = .empty;
    errdefer {
        for (lots.items) |lot| {
            allocator.free(lot.symbol);
            if (lot.note) |n| allocator.free(n);
            if (lot.account) |a| allocator.free(a);
            if (lot.ticker) |t| allocator.free(t);
            if (lot.underlying) |u| allocator.free(u);
        }
        lots.deinit(allocator);
    }

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

    var skipped: usize = 0;
    while (try it.next()) |fields| {
        const line = it.state.line;
        var lot = fields.to(Lot) catch {
            std.log.warn("portfolio: could not parse record at line {d}", .{line});
            skipped += 1;
            continue;
        };

        // Dupe owned strings before iterator.deinit() frees the backing buffer
        lot.symbol = try allocator.dupe(u8, lot.symbol);
        if (lot.note) |n| lot.note = try allocator.dupe(u8, n);
        if (lot.account) |a| lot.account = try allocator.dupe(u8, a);
        if (lot.ticker) |t| lot.ticker = try allocator.dupe(u8, t);
        if (lot.underlying) |u| lot.underlying = try allocator.dupe(u8, u);

        // Cash lots without a symbol get a placeholder
        if (lot.symbol.len == 0) {
            allocator.free(lot.symbol);
            lot.symbol = switch (lot.security_type) {
                .cash => try allocator.dupe(u8, "CASH"),
                .illiquid => try allocator.dupe(u8, "ILLIQUID"),
                else => {
                    std.log.warn("portfolio: record at line {d} has no symbol, skipping", .{line});
                    if (lot.note) |n| allocator.free(n);
                    if (lot.account) |a| allocator.free(a);
                    if (lot.ticker) |t| allocator.free(t);
                    if (lot.underlying) |u| allocator.free(u);
                    skipped += 1;
                    continue;
                },
            };
        }

        try lots.append(allocator, lot);
    }

    if (skipped > 0) {
        std.log.warn("portfolio: {d} record(s) could not be parsed and were skipped", .{skipped});
    }

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

test "dividend serialize/deserialize round-trip" {
    const io = std.testing.io;
    const allocator = std.testing.allocator;
    const divs = [_]Dividend{
        .{ .ex_date = Date.fromYmd(2024, 3, 15), .amount = 0.8325, .pay_date = Date.fromYmd(2024, 3, 28), .frequency = 4, .type = .regular },
        .{ .ex_date = Date.fromYmd(2024, 6, 14), .amount = 0.9148, .type = .special },
    };

    const data = try Store.serializeWithMeta(Dividend, io, allocator, &divs, .{});
    defer allocator.free(data);

    // No postProcess needed — test data has no currency strings to dupe
    const result = Store.readSlice(Dividend, io, allocator, data, null, .any) orelse return error.TestUnexpectedResult;
    const parsed = result.data;
    defer allocator.free(parsed);

    try std.testing.expectEqual(@as(usize, 2), parsed.len);

    try std.testing.expect(parsed[0].ex_date.eql(Date.fromYmd(2024, 3, 15)));
    try std.testing.expectApproxEqAbs(@as(f64, 0.8325), parsed[0].amount, 0.0001);
    try std.testing.expect(parsed[0].pay_date != null);
    try std.testing.expect(parsed[0].pay_date.?.eql(Date.fromYmd(2024, 3, 28)));
    try std.testing.expectEqual(@as(?u8, 4), parsed[0].frequency);
    try std.testing.expectEqual(DividendType.regular, parsed[0].type);

    try std.testing.expect(parsed[1].ex_date.eql(Date.fromYmd(2024, 6, 14)));
    try std.testing.expectApproxEqAbs(@as(f64, 0.9148), parsed[1].amount, 0.0001);
    try std.testing.expect(parsed[1].pay_date == null);
    try std.testing.expectEqual(DividendType.special, parsed[1].type);
}

test "split serialize/deserialize round-trip" {
    const io = std.testing.io;
    const allocator = std.testing.allocator;
    const splits = [_]Split{
        .{ .date = Date.fromYmd(2020, 8, 31), .numerator = 4, .denominator = 1 },
        .{ .date = Date.fromYmd(2014, 6, 9), .numerator = 7, .denominator = 1 },
    };

    const data = try Store.serializeWithMeta(Split, io, allocator, &splits, .{});
    defer allocator.free(data);

    const result = Store.readSlice(Split, io, allocator, data, null, .any) orelse return error.TestUnexpectedResult;
    const parsed = result.data;
    defer allocator.free(parsed);

    try std.testing.expectEqual(@as(usize, 2), parsed.len);

    try std.testing.expect(parsed[0].date.eql(Date.fromYmd(2020, 8, 31)));
    try std.testing.expectApproxEqAbs(@as(f64, 4), parsed[0].numerator, 0.001);
    try std.testing.expectApproxEqAbs(@as(f64, 1), parsed[0].denominator, 0.001);

    try std.testing.expect(parsed[1].date.eql(Date.fromYmd(2014, 6, 9)));
    try std.testing.expectApproxEqAbs(@as(f64, 7), parsed[1].numerator, 0.001);
}

test "portfolio serialize/deserialize round-trip" {
    const allocator = std.testing.allocator;
    // Today is after the lots' open_dates and after the one close_date,
    // so "open" means "no close_date and not matured".
    const today = Date.fromYmd(2024, 6, 1);
    const lots = [_]Lot{
        .{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2022, 3, 15), .open_price = 150.25 },
        .{ .symbol = "AMZN", .shares = 5, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 125.00, .close_date = Date.fromYmd(2024, 1, 15), .close_price = 185.50 },
        .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2022, 1, 10), .open_price = 220.00 },
    };

    const data = try serializePortfolio(allocator, &lots);
    defer allocator.free(data);

    var portfolio = try deserializePortfolio(allocator, data);
    defer portfolio.deinit();

    try std.testing.expectEqual(@as(usize, 3), portfolio.lots.len);

    try std.testing.expectEqualStrings("AMZN", portfolio.lots[0].symbol);
    try std.testing.expectApproxEqAbs(@as(f64, 10), portfolio.lots[0].shares, 0.01);
    try std.testing.expect(portfolio.lots[0].isOpen(today));

    try std.testing.expectEqualStrings("AMZN", portfolio.lots[1].symbol);
    try std.testing.expectApproxEqAbs(@as(f64, 5), portfolio.lots[1].shares, 0.01);
    try std.testing.expect(!portfolio.lots[1].isOpen(today));
    try std.testing.expect(portfolio.lots[1].close_date.?.eql(Date.fromYmd(2024, 1, 15)));
    try std.testing.expectApproxEqAbs(@as(f64, 185.50), portfolio.lots[1].close_price.?, 0.01);

    try std.testing.expectEqualStrings("VTI", portfolio.lots[2].symbol);
}

test "portfolio: cash lots without symbol get CASH placeholder" {
    const allocator = std.testing.allocator;
    // Raw SRF with a cash lot that has no symbol field
    const data =
        \\#!srfv1
        \\security_type::cash,shares:num:598.66,open_date::2026-02-25,open_price:num:1.00,account::Savings
        \\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150.00
        \\
    ;

    var portfolio = try deserializePortfolio(allocator, data);
    defer portfolio.deinit();

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

    // Cash lot: no symbol in data -> gets "CASH" placeholder
    try std.testing.expectEqualStrings("CASH", portfolio.lots[0].symbol);
    try std.testing.expectEqual(LotType.cash, portfolio.lots[0].security_type);
    try std.testing.expectApproxEqAbs(@as(f64, 598.66), portfolio.lots[0].shares, 0.01);
    try std.testing.expectEqualStrings("Savings", portfolio.lots[0].account.?);

    // Stock lot: symbol present
    try std.testing.expectEqualStrings("AAPL", portfolio.lots[1].symbol);
}

test "portfolio: price_ratio round-trip" {
    const allocator = std.testing.allocator;
    const data =
        \\#!srfv1
        \\symbol::02315N600,shares:num:100,open_date::2024-01-15,open_price:num:140.00,ticker::VTTHX,price_ratio:num:5.185,note::VANGUARD TARGET 2035
        \\symbol::AAPL,shares:num:10,open_date::2024-03-01,open_price:num:150.00
        \\
    ;

    var portfolio = try deserializePortfolio(allocator, data);
    defer portfolio.deinit();

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

    // CUSIP lot with price_ratio and ticker
    try std.testing.expectEqualStrings("02315N600", portfolio.lots[0].symbol);
    try std.testing.expectEqualStrings("VTTHX", portfolio.lots[0].ticker.?);
    try std.testing.expectEqualStrings("VTTHX", portfolio.lots[0].priceSymbol());
    try std.testing.expectApproxEqAbs(@as(f64, 5.185), portfolio.lots[0].price_ratio, 0.001);
    try std.testing.expectEqualStrings("VANGUARD TARGET 2035", portfolio.lots[0].note.?);

    // Regular lot — no price_ratio (default 1.0)
    try std.testing.expectEqualStrings("AAPL", portfolio.lots[1].symbol);
    try std.testing.expectApproxEqAbs(@as(f64, 1.0), portfolio.lots[1].price_ratio, 0.001);
    try std.testing.expect(portfolio.lots[1].ticker == null);

    // Round-trip: serialize and deserialize again
    const reserialized = try serializePortfolio(allocator, portfolio.lots);
    defer allocator.free(reserialized);

    var portfolio2 = try deserializePortfolio(allocator, reserialized);
    defer portfolio2.deinit();

    try std.testing.expectEqual(@as(usize, 2), portfolio2.lots.len);
    try std.testing.expectApproxEqAbs(@as(f64, 5.185), portfolio2.lots[0].price_ratio, 0.001);
    try std.testing.expectEqualStrings("VTTHX", portfolio2.lots[0].ticker.?);
    try std.testing.expectApproxEqAbs(@as(f64, 1.0), portfolio2.lots[1].price_ratio, 0.001);
}

// ── TTL and Negative Cache Tests ─────────────────────────────────

test "TTL constants are reasonable" {
    // Historical candles never expire
    try std.testing.expectEqual(@as(i64, -1), Ttl.candles_historical);

    // Latest candles expire just under 24 hours (allowing for cron jitter)
    try std.testing.expect(Ttl.candles_latest > 23 * std.time.s_per_hour);
    try std.testing.expect(Ttl.candles_latest < 24 * std.time.s_per_hour);

    // Dividends and splits refresh biweekly
    try std.testing.expectEqual(@as(i64, 14 * std.time.s_per_day), Ttl.dividends);
    try std.testing.expectEqual(@as(i64, 14 * std.time.s_per_day), Ttl.splits);

    // Options refresh hourly
    try std.testing.expectEqual(@as(i64, std.time.s_per_hour), Ttl.options);

    // Earnings and ETF profiles refresh monthly
    try std.testing.expectEqual(@as(i64, 30 * std.time.s_per_day), Ttl.earnings);
    try std.testing.expectEqual(@as(i64, 30 * std.time.s_per_day), Ttl.etf_profile);
}

test "DataType.ttl returns correct values" {
    try std.testing.expectEqual(Ttl.dividends, DataType.dividends.ttl());
    try std.testing.expectEqual(Ttl.splits, DataType.splits.ttl());
    try std.testing.expectEqual(Ttl.options, DataType.options.ttl());
    try std.testing.expectEqual(Ttl.earnings, DataType.earnings.ttl());
    try std.testing.expectEqual(Ttl.etf_profile, DataType.etf_profile.ttl());

    // These types have no TTL (0 = managed elsewhere)
    try std.testing.expectEqual(@as(i64, 0), DataType.candles_daily.ttl());
    try std.testing.expectEqual(@as(i64, 0), DataType.candles_meta.ttl());
    try std.testing.expectEqual(@as(i64, 0), DataType.meta.ttl());
}

test "DataType.fileName returns correct file names" {
    try std.testing.expectEqualStrings("candles_daily.srf", DataType.candles_daily.fileName());
    try std.testing.expectEqualStrings("candles_meta.srf", DataType.candles_meta.fileName());
    try std.testing.expectEqualStrings("dividends.srf", DataType.dividends.fileName());
    try std.testing.expectEqualStrings("splits.srf", DataType.splits.fileName());
    try std.testing.expectEqualStrings("options.srf", DataType.options.fileName());
    try std.testing.expectEqualStrings("earnings.srf", DataType.earnings.fileName());
    try std.testing.expectEqualStrings("etf_profile.srf", DataType.etf_profile.fileName());
    try std.testing.expectEqualStrings("meta.srf", DataType.meta.fileName());
}

test "negative_cache_content format" {
    // Negative cache marker should be valid SRF with a comment
    try std.testing.expect(std.mem.startsWith(u8, Store.negative_cache_content, "#!srfv1"));
    try std.testing.expect(std.mem.indexOf(u8, Store.negative_cache_content, "fetch_failed") != null);
}

test "looksCompleteSrf: empty is invalid" {
    try std.testing.expect(!Store.looksCompleteSrf(""));
}

test "looksCompleteSrf: missing srfv1 header rejected" {
    try std.testing.expect(!Store.looksCompleteSrf("garbage\n"));
    try std.testing.expect(!Store.looksCompleteSrf("some json body\n"));
    try std.testing.expect(!Store.looksCompleteSrf("{\"error\":\"not found\"}\n"));
}

test "looksCompleteSrf: missing trailing newline rejected" {
    // Classic torn-write symptom: body ends mid-record with no newline.
    // FRDM 2026-05-02 looked like this: trailing `date::2026-04` with
    // no comma, no remaining fields, no `\n`.
    try std.testing.expect(!Store.looksCompleteSrf(
        "#!srfv1\ndate::2026-04-22,open:num:62.82\ndate::2026-04",
    ));
    try std.testing.expect(!Store.looksCompleteSrf("#!srfv1"));
}

test "looksCompleteSrf: well-formed body accepted" {
    try std.testing.expect(Store.looksCompleteSrf(
        "#!srfv1\ndate::2026-04-22,open:num:62.82,close:num:63.23\n",
    ));
    // Negative cache content is also valid
    try std.testing.expect(Store.looksCompleteSrf(Store.negative_cache_content));
    // Minimal: just the header with its own terminator
    try std.testing.expect(Store.looksCompleteSrf("#!srfv1\n"));
}

test "archiveTornBody writes .bin + .meta pair with expected SRF content" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

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

    // Shape mirrors the canonical FRDM torn body: a header plus a
    // complete record, then a record that was cut mid-date-field with
    // no terminating newline.
    const torn_bytes = "#!srfv1\ndate::2026-04-22,open:num:62.82,close:num:63.23\ndate::2026-04";

    try Store.archiveTornBody(
        std.testing.io,
        testing.allocator,
        dir_path,
        "FRDM",
        .candles_daily,
        torn_bytes,
        .{
            .failure_reason = .looks_complete_srf_failed,
            .http_status = 200,
            .http_content_length = 214637,
            .server_url = "https://example.test/FRDM/candles",
            .server_etag = "\"sha256:deadbeefcafe\"",
        },
    );

    // Find the produced files. The timestamp in the name is produced
    // at write time so we can't predict it — list the _torn dir and
    // assert exactly one `.bin` + one `.meta`, and that they share a
    // prefix (so they're unambiguously paired).
    const torn_dir_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "_torn" });
    defer testing.allocator.free(torn_dir_path);

    var torn_dir = try std.Io.Dir.cwd().openDir(std.testing.io, torn_dir_path, .{ .iterate = true });
    defer torn_dir.close(io);

    var bin_name_buf: [128]u8 = undefined;
    var meta_name_buf: [128]u8 = undefined;
    var bin_len: usize = 0;
    var meta_len: usize = 0;

    var it = torn_dir.iterate();
    while (try it.next(io)) |entry| {
        if (std.mem.endsWith(u8, entry.name, ".bin")) {
            @memcpy(bin_name_buf[0..entry.name.len], entry.name);
            bin_len = entry.name.len;
        } else if (std.mem.endsWith(u8, entry.name, ".meta")) {
            @memcpy(meta_name_buf[0..entry.name.len], entry.name);
            meta_len = entry.name.len;
        }
    }
    try std.testing.expect(bin_len > 0);
    try std.testing.expect(meta_len > 0);

    // Prefix without extension should match (same symbol + data_type
    // + timestamp).
    const bin_prefix = bin_name_buf[0 .. bin_len - ".bin".len];
    const meta_prefix = meta_name_buf[0 .. meta_len - ".meta".len];
    try std.testing.expectEqualStrings(bin_prefix, meta_prefix);
    try std.testing.expect(std.mem.startsWith(u8, bin_prefix, "FRDM_candles_daily_"));

    // .bin round-trips verbatim.
    const bin_path = try std.fs.path.join(testing.allocator, &.{ torn_dir_path, bin_name_buf[0..bin_len] });
    defer testing.allocator.free(bin_path);
    const bin_contents = try std.Io.Dir.cwd().readFileAlloc(std.testing.io, bin_path, testing.allocator, .limited(1024 * 1024));
    defer testing.allocator.free(bin_contents);
    try std.testing.expectEqualStrings(torn_bytes, bin_contents);

    // .meta is valid SRF and carries the fields we care about.
    const meta_path = try std.fs.path.join(testing.allocator, &.{ torn_dir_path, meta_name_buf[0..meta_len] });
    defer testing.allocator.free(meta_path);
    const meta_contents = try std.Io.Dir.cwd().readFileAlloc(std.testing.io, meta_path, testing.allocator, .limited(1024 * 1024));
    defer testing.allocator.free(meta_contents);

    try std.testing.expect(std.mem.startsWith(u8, meta_contents, "#!srfv1\n"));
    try std.testing.expect(std.mem.indexOf(u8, meta_contents, "symbol::FRDM") != null);
    try std.testing.expect(std.mem.indexOf(u8, meta_contents, "data_type::candles_daily") != null);
    try std.testing.expect(std.mem.indexOf(u8, meta_contents, "failure_reason::looks_complete_srf_failed") != null);
    try std.testing.expect(std.mem.indexOf(u8, meta_contents, "http_status:num:200") != null);
    try std.testing.expect(std.mem.indexOf(u8, meta_contents, "http_content_length:num:214637") != null);
    try std.testing.expect(std.mem.indexOf(u8, meta_contents, "server_url::https://example.test/FRDM/candles") != null);
    try std.testing.expect(std.mem.indexOf(u8, meta_contents, "server_etag::\"sha256:deadbeefcafe\"") != null);
    // Body length matches the torn slice exactly.
    var len_buf: [32]u8 = undefined;
    const len_str = try std.fmt.bufPrint(&len_buf, "body_length:num:{d}", .{torn_bytes.len});
    try std.testing.expect(std.mem.indexOf(u8, meta_contents, len_str) != null);
    // Hex-encoded sha256 — length check (64 hex chars).
    const sha_idx = std.mem.indexOf(u8, meta_contents, "body_sha256::").?;
    const sha_start = sha_idx + "body_sha256::".len;
    try std.testing.expect(meta_contents.len - sha_start >= 64);
    for (meta_contents[sha_start .. sha_start + 64]) |c| {
        try std.testing.expect(std.ascii.isHex(c));
    }
    // last_200_bytes_hex is exactly 2 * min(body_len, 200) chars.
    const tail_idx = std.mem.indexOf(u8, meta_contents, "last_200_bytes_hex::").?;
    const tail_start = tail_idx + "last_200_bytes_hex::".len;
    const tail_end = std.mem.indexOfScalarPos(u8, meta_contents, tail_start, '\n').?;
    try std.testing.expectEqual(@as(usize, torn_bytes.len * 2), tail_end - tail_start);
}

test "Store.read self-heals torn candles_daily and wipes the pair" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

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

    var store = Store.init(std.testing.io, testing.allocator, dir_path);
    try store.ensureSymbolDir("FRDM");

    // Seed a torn daily and an intact meta — the exact state we saw
    // on disk for FRDM in the 2026-05-08 incident.
    const torn_daily = "#!srfv1\ndate::2026-04-22,open:num:62.82,close:num:63.23\ndate::2026-04";
    const intact_meta = "#!srfv1\n#!expires=9999999999\n#!created=1777000000\nlast_close:num:67.11,last_date::2026-05-07\n";
    try store.writeRaw("FRDM", .candles_daily, torn_daily);
    try store.writeRaw("FRDM", .candles_meta, intact_meta);

    // Reading candles MUST signal cache miss.
    const result = store.read(Candle, "FRDM", null, .any);
    try std.testing.expect(result == null);

    // Both files are wiped.
    const daily_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "FRDM", "candles_daily.srf" });
    defer testing.allocator.free(daily_path);
    const meta_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "FRDM", "candles_meta.srf" });
    defer testing.allocator.free(meta_path);
    try std.testing.expectError(error.FileNotFound, std.Io.Dir.cwd().access(std.testing.io, daily_path, .{}));
    try std.testing.expectError(error.FileNotFound, std.Io.Dir.cwd().access(std.testing.io, meta_path, .{}));

    // And the torn body was archived under _torn/.
    const torn_dir_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "_torn" });
    defer testing.allocator.free(torn_dir_path);
    var torn_dir = try std.Io.Dir.cwd().openDir(std.testing.io, torn_dir_path, .{ .iterate = true });
    defer torn_dir.close(io);
    var found_bin = false;
    var found_meta = false;
    var it = torn_dir.iterate();
    while (try it.next(io)) |entry| {
        if (std.mem.startsWith(u8, entry.name, "FRDM_candles_daily_")) {
            if (std.mem.endsWith(u8, entry.name, ".bin")) found_bin = true;
            if (std.mem.endsWith(u8, entry.name, ".meta")) found_meta = true;
        }
    }
    try std.testing.expect(found_bin);
    try std.testing.expect(found_meta);
}

test "Store.read does not self-heal an intact candles_daily" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

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

    var store = Store.init(std.testing.io, testing.allocator, dir_path);
    try store.ensureSymbolDir("OK");

    // Seed a complete (well-formed) candles_daily.srf — a single
    // record with the full OHLCV field set the Candle type expects.
    const good_daily =
        "#!srfv1\n" ++
        "date::2026-04-22,open:num:62.82,high:num:63.24,low:num:62.6,close:num:63.23,adj_close:num:63.23,volume:num:189473\n";
    const good_meta = "#!srfv1\n#!expires=9999999999\n#!created=1777000000\nlast_close:num:63.23,last_date::2026-04-22\n";
    try store.writeRaw("OK", .candles_daily, good_daily);
    try store.writeRaw("OK", .candles_meta, good_meta);

    // Reading should succeed, and the cache files must still be there.
    const result = store.read(Candle, "OK", null, .any);
    try std.testing.expect(result != null);
    if (result) |r| {
        defer testing.allocator.free(r.data);
        try std.testing.expectEqual(@as(usize, 1), r.data.len);
    }

    const daily_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "OK", "candles_daily.srf" });
    defer testing.allocator.free(daily_path);
    const daily_stat = try std.Io.Dir.cwd().statFile(std.testing.io, daily_path, .{});
    try std.testing.expect(daily_stat.size > 0);

    // And no _torn/ directory was created.
    const torn_dir_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "_torn" });
    defer testing.allocator.free(torn_dir_path);
    try std.testing.expectError(error.FileNotFound, std.Io.Dir.cwd().access(std.testing.io, torn_dir_path, .{}));
}

test "Store.dataTypeFor maps model types correctly" {
    try std.testing.expectEqual(DataType.candles_daily, Store.dataTypeFor(Candle));
    try std.testing.expectEqual(DataType.dividends, Store.dataTypeFor(Dividend));
    try std.testing.expectEqual(DataType.splits, Store.dataTypeFor(Split));
    try std.testing.expectEqual(DataType.earnings, Store.dataTypeFor(EarningsEvent));
    try std.testing.expectEqual(DataType.options, Store.dataTypeFor(OptionsChain));
    try std.testing.expectEqual(DataType.etf_profile, Store.dataTypeFor(EtfProfile));
}

test "Store.DataFor returns correct types" {
    // EtfProfile returns single struct, others return slices
    try std.testing.expect(@TypeOf(Store.DataFor(EtfProfile)) == type);
    try std.testing.expect(Store.DataFor(EtfProfile) == EtfProfile);
    try std.testing.expect(Store.DataFor(Candle) == []Candle);
    try std.testing.expect(Store.DataFor(Dividend) == []Dividend);
    try std.testing.expect(Store.DataFor(Split) == []Split);
}

test "Store.Freshness enum values" {
    // Ensure enum has expected values
    try std.testing.expect(Store.Freshness.fresh_only != Store.Freshness.any);
}

test "CandleProvider.fromString parses provider names" {
    try std.testing.expectEqual(Store.CandleProvider.yahoo, Store.CandleProvider.fromString("yahoo"));
    try std.testing.expectEqual(Store.CandleProvider.tiingo, Store.CandleProvider.fromString("tiingo"));
    try std.testing.expectEqual(Store.CandleProvider.twelvedata, Store.CandleProvider.fromString("twelvedata"));
    // Unknown defaults to twelvedata
    try std.testing.expectEqual(Store.CandleProvider.twelvedata, Store.CandleProvider.fromString("unknown"));
    try std.testing.expectEqual(Store.CandleProvider.twelvedata, Store.CandleProvider.fromString(""));
}

test "Store init creates valid store" {
    const allocator = std.testing.allocator;
    const store = Store.init(std.testing.io, allocator, "/tmp/zfin-test");
    try std.testing.expectEqualStrings("/tmp/zfin-test", store.cache_dir);
}

test "CandleMeta default provider is tiingo" {
    const meta = Store.CandleMeta{
        .last_close = 100.0,
        .last_date = Date.fromYmd(2024, 1, 1),
    };
    try std.testing.expectEqual(Store.CandleProvider.tiingo, meta.provider);
}

// ── writeRaw / appendRaw atomicity ───────────────────────────
//
// A concurrent reader hitting a cache file mid-write must never see a
// truncated or partial-field state — the symptom was srf `custom parse
// of value 2026-04 failed : InvalidDateFormat`, a date field chopped
// exactly 7 chars into a 10-char value.
//
// These tests exercise the atomicity primitive underneath writeRaw/
// appendRaw: while one thread hammers writes of two alternating,
// differently-sized SRF blobs, reader threads hammer reads and assert
// that every read they succeed at parses cleanly as one of those two
// blobs — no partial content, no partial dates. A pre-atomic-fix
// version of writeRaw would fail this test within a handful of
// iterations.

const testing = std.testing;

test "writeRaw atomicity: concurrent readers never observe a truncated file" {
    const io = std.testing.io;
    // Two SRF blobs with dates at different byte offsets. A non-atomic
    // writer that truncates + writes would leak partial bytes of
    // whichever blob is mid-write; that would show up as either a
    // partial date or a split field — both caught by strict equality
    // against these two complete blobs.
    const blob_a =
        \\#!srfv1
        \\#!expires=1780000000
        \\#!created=1777000000
        \\symbol::AAPL,date::2026-04-30,actual:num:2.78
        \\symbol::MSFT,date::2026-01-29,actual:num:4.27
        \\
    ;
    const blob_b =
        \\#!srfv1
        \\#!expires=1780500000
        \\#!created=1777500000
        \\symbol::AMZN,date::2026-07-29,estimate:num:1.83
        \\
    ;
    try std.testing.expect(blob_a.len != blob_b.len); // ensure size changes

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

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

    // 0.16's DebugAllocator (which testing.allocator uses) is
    // thread-safe by default, so no ThreadSafeAllocator wrapper needed.
    var store = Store.init(std.testing.io, testing.allocator, dir_path);

    // Make sure the symbol subdir exists once up-front (writeRaw will
    // also call ensureSymbolDir, but doing it here keeps the writer
    // hot loop lean).
    try store.ensureSymbolDir("SYM");

    const Ctx = struct {
        store: *Store,
        blob_a: []const u8,
        blob_b: []const u8,
        stop: std.atomic.Value(bool),
        bad_reads: std.atomic.Value(u32),
        completed_reads: std.atomic.Value(u32),

        fn writer(self: *@This()) void {
            var i: usize = 0;
            while (!self.stop.load(.acquire)) : (i += 1) {
                const bytes = if (i & 1 == 0) self.blob_a else self.blob_b;
                // We don't care about errors here — worst case the
                // writer just skips this iteration.
                self.store.writeRaw("SYM", .candles_daily, bytes) catch {};
            }
        }

        fn reader(self: *@This()) void {
            const path = self.store.symbolPath("SYM", DataType.candles_daily.fileName()) catch return;
            defer self.store.allocator.free(path);

            while (!self.stop.load(.acquire)) {
                const bytes = std.Io.Dir.cwd().readFileAlloc(self.store.io, path, self.store.allocator, .limited(1 * 1024 * 1024)) catch |err| switch (err) {
                    error.FileNotFound => continue, // pre-first-write race
                    else => continue,
                };
                defer self.store.allocator.free(bytes);

                // Every successful read must match exactly one of the
                // two source blobs. A non-atomic writer would eventually
                // produce reads that match neither (truncated, zero
                // bytes, or mid-blob switchover garbage).
                const matches = std.mem.eql(u8, bytes, self.blob_a) or std.mem.eql(u8, bytes, self.blob_b);
                if (!matches) _ = self.bad_reads.fetchAdd(1, .monotonic);
                _ = self.completed_reads.fetchAdd(1, .monotonic);
            }
        }
    };

    var ctx = Ctx{
        .store = &store,
        .blob_a = blob_a,
        .blob_b = blob_b,
        .stop = std.atomic.Value(bool).init(false),
        .bad_reads = std.atomic.Value(u32).init(0),
        .completed_reads = std.atomic.Value(u32).init(0),
    };

    var writer_thread = try std.Thread.spawn(.{}, Ctx.writer, .{&ctx});
    var reader_threads: [3]std.Thread = undefined;
    for (&reader_threads) |*t| t.* = try std.Thread.spawn(.{}, Ctx.reader, .{&ctx});

    // Run the stress for a fixed duration. 200ms is plenty for thousands
    // of iterations on any reasonable machine — enough to reliably catch
    // a non-atomic write window at the scheduler granularity.
    try io.sleep(.fromMilliseconds(200), .boot);
    ctx.stop.store(true, .release);

    writer_thread.join();
    for (&reader_threads) |*t| t.join();

    // The critical assertion: zero reads landed in a partial state.
    const bad = ctx.bad_reads.load(.monotonic);
    const total = ctx.completed_reads.load(.monotonic);

    // Sanity: we actually did work (otherwise the test is a no-op).
    try testing.expect(total > 0);
    try testing.expectEqual(@as(u32, 0), bad);
}

test "appendRaw atomicity: concurrent readers see either pre- or post-append, never mid" {
    const io = std.testing.io;
    // Seed an initial file with a complete SRF doc, then have one thread
    // append more records repeatedly while readers race to read it. Every
    // successful read must parse cleanly and have a valid termination
    // — no trailing partial record from an in-flight append.
    const seed =
        \\#!srfv1
        \\#!created=1777000000
        \\symbol::AAPL,date::2024-04-30,actual:num:1.53
        \\
    ;
    const chunk =
        \\symbol::AAPL,date::2024-07-30,actual:num:1.65
        \\
    ;

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

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

    // 0.16's DebugAllocator (which testing.allocator uses) is
    // thread-safe by default, so no ThreadSafeAllocator wrapper needed.
    var store = Store.init(std.testing.io, testing.allocator, dir_path);
    try store.ensureSymbolDir("SYM");

    // Write seed atomically up front.
    try store.writeRaw("SYM", .candles_daily, seed);

    const Ctx = struct {
        store: *Store,
        chunk: []const u8,
        stop: std.atomic.Value(bool),
        bad_reads: std.atomic.Value(u32),
        completed_reads: std.atomic.Value(u32),

        fn appender(self: *@This()) void {
            while (!self.stop.load(.acquire)) {
                self.store.appendRaw("SYM", .candles_daily, self.chunk) catch {};
            }
        }

        fn reader(self: *@This()) void {
            const path = self.store.symbolPath("SYM", DataType.candles_daily.fileName()) catch return;
            defer self.store.allocator.free(path);

            while (!self.stop.load(.acquire)) {
                const bytes = std.Io.Dir.cwd().readFileAlloc(self.store.io, path, self.store.allocator, .limited(4 * 1024 * 1024)) catch continue;
                defer self.store.allocator.free(bytes);

                // Invariants for a valid appended file:
                //  1. Starts with `#!srfv1\n` (the header is never torn).
                //  2. Ends with `\n` (no partial trailing record).
                //  3. Total length is `seed.len + k * chunk.len` for some
                //     integer k ≥ 0 — which, with atomic appends, is the
                //     only observable shape.
                const ok_header = std.mem.startsWith(u8, bytes, "#!srfv1\n");
                const ok_tail = bytes.len > 0 and bytes[bytes.len - 1] == '\n';
                if (!ok_header or !ok_tail) {
                    _ = self.bad_reads.fetchAdd(1, .monotonic);
                }
                _ = self.completed_reads.fetchAdd(1, .monotonic);
            }
        }
    };

    var ctx = Ctx{
        .store = &store,
        .chunk = chunk,
        .stop = std.atomic.Value(bool).init(false),
        .bad_reads = std.atomic.Value(u32).init(0),
        .completed_reads = std.atomic.Value(u32).init(0),
    };

    var appender_thread = try std.Thread.spawn(.{}, Ctx.appender, .{&ctx});
    var reader_threads: [3]std.Thread = undefined;
    for (&reader_threads) |*t| t.* = try std.Thread.spawn(.{}, Ctx.reader, .{&ctx});

    try io.sleep(.fromMilliseconds(200), .boot);
    ctx.stop.store(true, .release);

    appender_thread.join();
    for (&reader_threads) |*t| t.join();

    const bad = ctx.bad_reads.load(.monotonic);
    const total = ctx.completed_reads.load(.monotonic);
    try testing.expect(total > 0);
    try testing.expectEqual(@as(u32, 0), bad);
}
