//! Corpus-wide candle-cache staleness detection.
//!
//! `market.candleFreshness` answers "is THIS symbol's newest bar overdue?" from
//! the clock alone. That is the right question for a fetch gate and the wrong
//! one for finding a symbol the cache has quietly frozen, because the clock
//! cannot distinguish three cases that look identical per-symbol: the bar isn't
//! published yet, the market was closed, or this symbol alone got left behind.
//!
//! Peers settle it. Symbols of the same `InstrumentKind` share a publication
//! schedule, so if twenty equities hold Friday's bar and three hold Thursday's,
//! those three are behind - no calendar reasoning required, and an un-modeled
//! closure cannot fool it because a closure moves every symbol together, taking
//! the peer maximum with them and leaving nothing behind it.
//!
//! That self-correction is why there is no calendar clamp on top. A first cut
//! suppressed findings whenever the group's own newest bar was itself overdue,
//! reasoning that a wholly-behind group meant a closure. It meant nothing of
//! the kind: it meant the corpus had not been refreshed yet that day, which is
//! the normal state, and the gate hid three genuinely-lagging symbols in live
//! data. "Is the corpus caught up?" and "is any symbol behind the rest?" are
//! independent questions, and only the second one names a symbol.
//!
//! The observed failure this exists for: three symbols served Thursday's close
//! all weekend while ~19 peers had Friday's. Each had been fetched, missed,
//! stamped `.lagging`, retried 30 minutes later, and by then sat just past the
//! 90-minute provider-lag grace - so `candleFreshness` said `.overdue`,
//! `staleCandleExpiry` routed them to the next session boundary, and the stale
//! bars became authoritative until Monday. The retry machinery worked as
//! designed; the premise was wrong.
//!
//! Pure: dates and `now_s` in, findings out. No I/O, no cache reads, no
//! fetches. The caller supplies the corpus.
//!
//! ## Adjustment-basis staleness
//!
//! A second, unrelated staleness question lives here too:
//! `adjustmentBasisStale` asks whether a symbol's cached `adj_close`
//! values predate a corporate action they should already reflect. It is
//! the same shape of problem - "this cache entry is quietly frozen" -
//! and it follows the same two-part split as the peer analysis above:
//! `newestCorporateAction` gathers from disk (like `collect`), and
//! `adjustmentBasisStale` decides (like `scan`, pure dates in, bool
//! out).
//!
//! It deliberately does not live in `store.zig`. The cache layer
//! serializes and reads; deciding that a distribution behind the basis
//! means the series must be refetched is domain policy, and
//! `store.zig`'s own `updateCandleMeta` doc records the same boundary
//! for market-clock knowledge ("owned by the caller").
//!
//! Note which half owns the "has it actually gone ex yet?" filter:
//! `newestCorporateAction` does, because that is a question about which
//! actions are *relevant*, and relevance is selection. Putting it on the
//! verdict instead is what let one forward-announced dividend mask every
//! older unapplied one.

const std = @import("std");
const Date = @import("../Date.zig");
const market = @import("../market.zig");
const cache = @import("store.zig");
const Dividend = @import("../models/dividend.zig").Dividend;
const Split = @import("../models/split.zig").Split;

/// One cached symbol, as the caller found it.
pub const Entry = struct {
    symbol: []const u8,
    kind: market.InstrumentKind,
    /// Newest bar in the cache, or null when there is no candle meta at all -
    /// never fetched, negative-cached, or a key that carries no candles (an
    /// EDGAR CIK, say). Null entries are never reported stale: absence of
    /// evidence is not a stale bar.
    last_date: ?Date,
    /// Is this symbol in the set zfin intends to keep fresh
    /// (`Portfolio.fetchedSymbols`)? An untracked symbol can only ever fall
    /// further behind, so reporting it as stale every run would be noise.
    tracked: bool,
};

/// Days behind peers that ordinary lag can still explain. Beyond this, a finding
/// is reported as `far_behind` rather than `stale`.
///
/// Four days, and it is deliberately tight: the worst legitimate gap is a Friday
/// bar missed ahead of a Monday holiday, which leaves a symbol four calendar days
/// behind peers holding Tuesday's. More than that is not lag.
///
/// The split exists because the two have different answers. Inside the window a
/// refresh is the routine remedy. Outside it, the gap is past anything lag
/// explains, so the cause is elsewhere and worth naming as a separate finding -
/// but NOT because a refresh is futile. Observed counter-example: a watchlist
/// symbol sat 42 days behind purely because no refresh path had ever included
/// it, and a single forced fetch brought it fully current.
///
/// This module does NOT infer a cause, and callers must not either. The
/// candidates are many and none of them are visible from here: the ticker
/// changed, the provider dropped coverage, that one symbol is failing auth or
/// being rate-limited at the tail of a fetch, the symbol form is wrong for the
/// provider, whatever refreshes this cache never included it in its set, or it
/// stopped trading. Report the gap and let the operator look.
pub const max_normal_lag_days: i64 = 4;

/// A tracked symbol whose newest bar is behind its peers'.
pub const Finding = struct {
    symbol: []const u8,
    kind: market.InstrumentKind,
    last_date: Date,
    /// Newest bar held by any same-kind peer.
    peer_date: Date,
    /// Calendar days between the two. Calendar rather than trading days
    /// because this is a human-facing magnitude, and the distinction between
    /// "one session" and "six weeks" is what it has to convey.
    days_behind: i64,

    fn worstFirst(_: void, a: Finding, b: Finding) bool {
        if (a.days_behind != b.days_behind) return a.days_behind > b.days_behind;
        return std.mem.order(u8, a.symbol, b.symbol) == .lt;
    }
};

/// Per-`InstrumentKind` reference state, so a caller can explain itself rather
/// than just listing symbols.
pub const GroupState = struct {
    kind: market.InstrumentKind,
    /// Newest bar across every dated entry of this kind; null when the kind
    /// has no dated entries.
    peer_date: ?Date,
    /// Freshness of the group's OWN newest bar - context, NOT a gate.
    ///
    /// Reported so a caller can say "the whole corpus is a session behind,
    /// refresh everything" alongside any per-symbol findings. It deliberately
    /// does not suppress those findings: an earlier version gated on
    /// `.current` and thereby hid three genuinely-lagging symbols every time
    /// the corpus as a whole had not been refreshed that day, which is most of
    /// the time.
    ///
    /// No clamp is needed, because peer comparison is already immune to the
    /// case a clamp was meant to catch: a market closure moves every symbol
    /// together, so the peer maximum moves with them and nothing sits behind
    /// it. The two questions are independent - "is the corpus caught up?" and
    /// "is any symbol behind the rest?" - and only the second one identifies a
    /// symbol.
    freshness: ?market.CandleFreshness,
    /// Dated entries considered for this kind.
    dated: usize,

    /// Can this kind yield findings at all? Only that a peer exists to compare
    /// against; with a single cached symbol there is no comparison to make.
    pub fn conclusive(self: GroupState) bool {
        return self.dated > 1;
    }
};

pub const Report = struct {
    /// Tracked symbols behind their peers by no more than
    /// `max_normal_lag_days`, worst first. A refresh is the expected remedy.
    stale: []Finding,
    /// Tracked symbols further behind than lag explains, worst first. Reported
    /// separately because a refresh probably is not the answer - see
    /// `max_normal_lag_days`.
    far_behind: []Finding,
    /// Cached, dated, but tracked by nothing. Nothing will ever refresh these,
    /// so they are not staleness - they are disk you may want back. Reported
    /// separately precisely so they cannot dominate the stale list forever.
    orphans: []const []const u8,
    /// Tracked but with no cached bar at all. A different finding: not stale,
    /// never fetched.
    missing: []const []const u8,
    groups: []GroupState,

    pub fn deinit(self: Report, allocator: std.mem.Allocator) void {
        allocator.free(self.stale);
        allocator.free(self.far_behind);
        allocator.free(self.orphans);
        allocator.free(self.missing);
        allocator.free(self.groups);
    }
};

/// Read the cache and build the corpus `scan` consumes.
///
/// The I/O half, deliberately separate from `scan`: reading is a loop over the
/// store, deciding is the part worth testing exhaustively, and keeping them
/// apart is what lets every branch of the decision be exercised with fixed
/// dates and no filesystem.
///
/// `readCandleMeta` returning null IS the filter for non-candle keys. EDGAR
/// CIKs, negative-cached symbols and anything never fetched all arrive as
/// `last_date = null`, so none of them can be reported stale, and this function
/// needs to know nothing about what any of them are.
///
/// Symbol strings borrow from `keys`; only the slice is allocated.
///
/// `exclude` is a caller-supplied set of symbols to leave out of the corpus. The
/// meaning is the caller's - this module only needs to know that no verdict it
/// could reach would be true of them.
pub fn collect(
    allocator: std.mem.Allocator,
    store: *cache.Store,
    keys: []const []const u8,
    tracked: *const std.StringHashMap(void),
    exclude: []const []const u8,
) ![]Entry {
    var out = std.ArrayList(Entry).empty;
    errdefer out.deinit(allocator);
    outer: for (keys) |key| {
        // Excluded keys are dropped from the corpus entirely rather than
        // classified, because every classification would be wrong for them: a
        // symbol fetched on demand is not stale (whatever needs it fetches it)
        // and not an orphan (something does refresh it). Taking the exclusion
        // here rather than at each caller means both the sweep and `doctor` skip
        // them by construction instead of by remembering to.
        //
        // ...but ONLY if the symbol is not otherwise tracked. A symbol that is
        // both held and the configured benchmark is subject to the routine
        // refresh like any other holding, so its staleness IS a finding and
        // dropping it would hide a real position. This bit the moment someone
        // pointed `benchmark_stock` at a symbol they actually hold - which is
        // the recommended way to configure it, because a benchmark held nowhere
        // never gets its dividends warmed.
        const is_tracked = tracked.contains(key);
        if (!is_tracked) {
            for (exclude) |ex| {
                if (std.mem.eql(u8, key, ex)) continue :outer;
            }
        }
        const cm = store.readCandleMeta(key);
        try out.append(allocator, .{
            .symbol = key,
            .kind = market.classify(key),
            .last_date = if (cm) |m| m.meta.last_date else null,
            .tracked = is_tracked,
        });
    }
    return out.toOwnedSlice(allocator);
}

/// Classify `entries` as of `now_s`.
///
/// Symbol strings are borrowed from `entries`; only the slices are allocated.
pub fn scan(
    allocator: std.mem.Allocator,
    entries: []const Entry,
    now_s: i64,
) !Report {
    const kinds = [_]market.InstrumentKind{ .equity, .mutual_fund };

    var groups = try allocator.alloc(GroupState, kinds.len);
    errdefer allocator.free(groups);
    for (kinds, 0..) |kind, i| {
        var newest: ?Date = null;
        var dated: usize = 0;
        for (entries) |e| {
            if (e.kind != kind) continue;
            const d = e.last_date orelse continue;
            dated += 1;
            if (newest == null or newest.?.lessThan(d)) newest = d;
        }
        groups[i] = .{
            .kind = kind,
            .peer_date = newest,
            // Context only (see GroupState.freshness). Reuses the fetch
            // gate's own verdict rather than re-deriving the market calendar.
            .freshness = if (newest) |n| market.candleFreshness(now_s, kind, n) else null,
            .dated = dated,
        };
    }

    var stale = std.ArrayList(Finding).empty;
    errdefer stale.deinit(allocator);
    var far_behind = std.ArrayList(Finding).empty;
    errdefer far_behind.deinit(allocator);
    var orphans = std.ArrayList([]const u8).empty;
    errdefer orphans.deinit(allocator);
    var missing = std.ArrayList([]const u8).empty;
    errdefer missing.deinit(allocator);

    for (entries) |e| {
        const last = e.last_date orelse {
            // No bar at all. Only worth saying when something tracks it.
            if (e.tracked) try missing.append(allocator, e.symbol);
            continue;
        };
        if (!e.tracked) {
            try orphans.append(allocator, e.symbol);
            continue;
        }

        const g = groups[if (e.kind == .equity) 0 else 1];
        if (!g.conclusive()) continue;
        const peer = g.peer_date.?;
        if (!last.lessThan(peer)) continue;

        const days = @divTrunc(peer.toEpoch() - last.toEpoch(), std.time.s_per_day);
        const finding = Finding{
            .symbol = e.symbol,
            .kind = e.kind,
            .last_date = last,
            .peer_date = peer,
            .days_behind = days,
        };
        if (days > max_normal_lag_days) {
            try far_behind.append(allocator, finding);
        } else {
            try stale.append(allocator, finding);
        }
    }

    // Ownership moves out of the ArrayLists one at a time, so each completed
    // handoff needs its own errdefer: the list's errdefer no longer covers a
    // slice it has released, and the Report does not exist yet to cover it
    // either. An allocation-failure test caught `stale_out` leaking in exactly
    // that window.
    const stale_out = try stale.toOwnedSlice(allocator);
    errdefer allocator.free(stale_out);
    std.mem.sort(Finding, stale_out, {}, Finding.worstFirst);

    const far_out = try far_behind.toOwnedSlice(allocator);
    errdefer allocator.free(far_out);
    std.mem.sort(Finding, far_out, {}, Finding.worstFirst);

    const orphans_out = try orphans.toOwnedSlice(allocator);
    errdefer allocator.free(orphans_out);

    const missing_out = try missing.toOwnedSlice(allocator);

    return .{
        .stale = stale_out,
        .far_behind = far_out,
        .orphans = orphans_out,
        .missing = missing_out,
        .groups = groups,
    };
}

// ── tests ────────────────────────────────────────────────────

const testing = std.testing;

/// Friday 2025-06-13 at 18:00 ET, past the 16:55 equity boundary. Equities
/// should hold Friday's bar; mutual-fund NAVs for Friday are not published
/// until the next morning, so funds should hold Thursday's.
fn fridayEvening() i64 {
    return Date.fromYmd(2025, 6, 13).toEpoch() + 22 * std.time.s_per_hour;
}

fn eq(sym: []const u8, d: ?Date, tracked: bool) Entry {
    return .{ .symbol = sym, .kind = .equity, .last_date = d, .tracked = tracked };
}

test "scan: a symbol behind its peers is reported" {
    const a = testing.allocator;
    // The shape of the observed failure: most of the corpus has the latest
    // bar, a few do not.
    const fri = Date.fromYmd(2025, 6, 13);
    const thu = Date.fromYmd(2025, 6, 12);
    const entries = [_]Entry{
        eq("AAPL", fri, true),
        eq("MSFT", fri, true),
        eq("NVDA", fri, true),
        eq("TSLA", thu, true),
    };
    var r = try scan(a, &entries, fridayEvening());
    defer r.deinit(a);

    try testing.expectEqual(@as(usize, 1), r.stale.len);
    try testing.expectEqualStrings("TSLA", r.stale[0].symbol);
    try testing.expectEqual(@as(i64, 1), r.stale[0].days_behind);
    try testing.expect(r.stale[0].peer_date.eql(fri));
}

test "scan: a uniformly-behind group reports nothing, and needs no clamp to do it" {
    const a = testing.allocator;
    // A closure (or simply a corpus nobody refreshed) moves every symbol
    // together, so the peer maximum moves too and no symbol sits behind it.
    // Peer comparison is self-correcting here - a clock-based check would flag
    // all four, and an added calendar clamp would suppress real findings
    // elsewhere for no gain. `freshness` is still reported as context so a
    // caller can say "refresh everything".
    const wed = Date.fromYmd(2025, 6, 11);
    const entries = [_]Entry{
        eq("AAPL", wed, true),
        eq("MSFT", wed, true),
        eq("NVDA", wed, true),
        eq("TSLA", wed, true),
    };
    var r = try scan(a, &entries, fridayEvening());
    defer r.deinit(a);

    try testing.expectEqual(@as(usize, 0), r.stale.len);
    // Context says the corpus is behind; it does not gate anything.
    try testing.expect(r.groups[0].freshness.? != .current);
    try testing.expect(r.groups[0].conclusive());
}

test "scan: a laggard is found even when the corpus as a whole is behind" {
    const a = testing.allocator;
    // THE REGRESSION THIS GUARDS, caught on live data. Monday evening, nothing
    // has refreshed since Friday: every symbol is behind the session the
    // calendar expects. An earlier version gated findings on the group's own
    // bar being `.current` and so reported nothing at all - while three
    // symbols sat a full session behind the other twenty-one.
    //
    // Monday 2025-06-16 22:00 UTC, past the equity boundary, so Monday's bar
    // is expected and even the peer maximum (Friday) is overdue.
    const monday_evening = Date.fromYmd(2025, 6, 16).toEpoch() + 22 * std.time.s_per_hour;
    const fri = Date.fromYmd(2025, 6, 13);
    const thu = Date.fromYmd(2025, 6, 12);
    const entries = [_]Entry{
        eq("AAPL", fri, true),
        eq("MSFT", fri, true),
        eq("NVDA", fri, true),
        eq("AGG", thu, true),
        eq("AMZN", thu, true),
        eq("NKE", thu, true),
    };
    var r = try scan(a, &entries, monday_evening);
    defer r.deinit(a);

    // The group is behind - and that must not silence the three outliers.
    try testing.expect(r.groups[0].freshness.? != .current);
    try testing.expectEqual(@as(usize, 3), r.stale.len);
    for (r.stale) |f| {
        try testing.expect(f.last_date.eql(thu));
        try testing.expect(f.peer_date.eql(fri));
        try testing.expectEqual(@as(i64, 1), f.days_behind);
    }
}

test "scan: kinds are judged separately, so fund NAV lag is not staleness" {
    const a = testing.allocator;
    // Friday evening: equities have Friday, funds still legitimately have
    // Thursday. Judged as one corpus, every fund would read a day stale every
    // single evening.
    const fri = Date.fromYmd(2025, 6, 13);
    const thu = Date.fromYmd(2025, 6, 12);
    const entries = [_]Entry{
        eq("AAPL", fri, true),
        eq("MSFT", fri, true),
        .{ .symbol = "VBTLX", .kind = .mutual_fund, .last_date = thu, .tracked = true },
        .{ .symbol = "VPMAX", .kind = .mutual_fund, .last_date = thu, .tracked = true },
    };
    var r = try scan(a, &entries, fridayEvening());
    defer r.deinit(a);

    try testing.expectEqual(@as(usize, 0), r.stale.len);
    // And not merely absent from `stale` - absent from BOTH lists. A fund that
    // slipped into `far_behind` would exit the server's refresh non-zero and mail
    // the operator every evening, which is a worse failure than a noisy list.
    try testing.expectEqual(@as(usize, 0), r.far_behind.len);
    // The peer maximum is what protects this: each kind's reference moves with
    // its own group, so a schedule shared by every member of a group can never
    // put any member behind it.
    try testing.expect(r.groups[0].peer_date.?.eql(fri));
    try testing.expect(r.groups[1].peer_date.?.eql(thu));
    // Both groups are caught up against their own schedules.
    try testing.expectEqual(market.CandleFreshness.current, r.groups[0].freshness.?);
    try testing.expectEqual(market.CandleFreshness.current, r.groups[1].freshness.?);
}

test "scan: a lone symbol in its kind yields no verdict" {
    const a = testing.allocator;
    // With one peer there is nothing to compare against, and the clock alone
    // cannot tell a frozen cache from a closed market. Silence is the honest
    // answer, and this is peer comparison's documented blind spot.
    const entries = [_]Entry{
        .{ .symbol = "VBTLX", .kind = .mutual_fund, .last_date = Date.fromYmd(2025, 5, 1), .tracked = true },
    };
    var r = try scan(a, &entries, fridayEvening());
    defer r.deinit(a);
    try testing.expectEqual(@as(usize, 0), r.stale.len);
    try testing.expect(!r.groups[1].conclusive());
    try testing.expectEqual(@as(usize, 1), r.groups[1].dated);
}

test "scan: an untracked symbol is an orphan, never stale" {
    const a = testing.allocator;
    // Nothing refreshes it, so it can only fall further behind. Reporting it
    // as stale would put a permanent entry at the top of the list and train
    // the operator to skip the section.
    const fri = Date.fromYmd(2025, 6, 13);
    const entries = [_]Entry{
        eq("AAPL", fri, true),
        eq("MSFT", fri, true),
        eq("OLDCO", Date.fromYmd(2025, 1, 2), false),
    };
    var r = try scan(a, &entries, fridayEvening());
    defer r.deinit(a);

    try testing.expectEqual(@as(usize, 0), r.stale.len);
    try testing.expectEqual(@as(usize, 1), r.orphans.len);
    try testing.expectEqualStrings("OLDCO", r.orphans[0]);
}

test "scan: no candle meta means missing when tracked, ignored when not" {
    const a = testing.allocator;
    // A negative-cached symbol or a non-candle key has no bar. That is not a
    // stale bar, and inventing an infinitely-old date for it would put it
    // permanently at the top of the worst-first list.
    const fri = Date.fromYmd(2025, 6, 13);
    const entries = [_]Entry{
        eq("AAPL", fri, true),
        eq("MSFT", fri, true),
        eq("DOGE-USD", null, true),
        eq("0000320193", null, false),
    };
    var r = try scan(a, &entries, fridayEvening());
    defer r.deinit(a);

    try testing.expectEqual(@as(usize, 0), r.stale.len);
    try testing.expectEqual(@as(usize, 0), r.orphans.len);
    try testing.expectEqual(@as(usize, 1), r.missing.len);
    try testing.expectEqualStrings("DOGE-USD", r.missing[0]);
    try testing.expectEqual(@as(usize, 2), r.groups[0].dated);
}

test "scan: findings are ordered worst first" {
    const a = testing.allocator;
    // One session behind and three days behind are different magnitudes; the
    // larger should not be buried under the smaller.
    const fri = Date.fromYmd(2025, 6, 13);
    const entries = [_]Entry{
        eq("AAPL", fri, true),
        eq("MSFT", fri, true),
        eq("NEAR", Date.fromYmd(2025, 6, 12), true),
        eq("MID", Date.fromYmd(2025, 6, 11), true),
        eq("FAR", Date.fromYmd(2025, 6, 9), true),
    };
    var r = try scan(a, &entries, fridayEvening());
    defer r.deinit(a);

    try testing.expectEqual(@as(usize, 3), r.stale.len);
    try testing.expectEqualStrings("FAR", r.stale[0].symbol);
    try testing.expectEqualStrings("MID", r.stale[1].symbol);
    try testing.expectEqualStrings("NEAR", r.stale[2].symbol);
    try testing.expectEqual(@as(i64, 4), r.stale[0].days_behind);
    try testing.expectEqual(@as(usize, 0), r.far_behind.len);
}

test "scan: a symbol far behind is separated from one merely lagging" {
    const a = testing.allocator;
    // Observed live: three symbols 3-4 days behind, which a refresh fixes,
    // alongside one 42 days behind that no refresh had moved. In a single
    // worst-first list the latter sits permanently on top and buries the
    // actionable ones.
    const fri = Date.fromYmd(2025, 6, 13);
    const entries = [_]Entry{
        eq("AAPL", fri, true),
        eq("MSFT", fri, true),
        eq("LAGGY", Date.fromYmd(2025, 6, 10), true),
        eq("STOPPED", Date.fromYmd(2025, 5, 2), true),
    };
    var r = try scan(a, &entries, fridayEvening());
    defer r.deinit(a);

    try testing.expectEqual(@as(usize, 1), r.stale.len);
    try testing.expectEqualStrings("LAGGY", r.stale[0].symbol);
    try testing.expectEqual(@as(usize, 1), r.far_behind.len);
    try testing.expectEqualStrings("STOPPED", r.far_behind[0].symbol);
    try testing.expectEqual(@as(i64, 42), r.far_behind[0].days_behind);
}

test "scan: the boundary is at max_normal_lag_days, exclusive" {
    const a = testing.allocator;
    const peer = Date.fromYmd(2025, 6, 13);
    // Exactly at the limit is still ordinary lag; one day past it is not. The
    // limit is a Friday bar missed ahead of a Monday holiday.
    const at = peer.addDays(@intCast(-max_normal_lag_days));
    const over = peer.addDays(@intCast(-(max_normal_lag_days + 1)));
    const entries = [_]Entry{
        eq("AAPL", peer, true),
        eq("MSFT", peer, true),
        eq("AT", at, true),
        eq("OVER", over, true),
    };
    var r = try scan(a, &entries, fridayEvening());
    defer r.deinit(a);

    try testing.expectEqual(@as(usize, 1), r.stale.len);
    try testing.expectEqualStrings("AT", r.stale[0].symbol);
    try testing.expectEqual(@as(i64, 4), r.stale[0].days_behind);
    try testing.expectEqual(@as(usize, 1), r.far_behind.len);
    try testing.expectEqualStrings("OVER", r.far_behind[0].symbol);
    try testing.expectEqual(@as(i64, 5), r.far_behind[0].days_behind);
}

test "scan: an empty corpus is not an error" {
    const a = testing.allocator;
    var r = try scan(a, &.{}, fridayEvening());
    defer r.deinit(a);
    try testing.expectEqual(@as(usize, 0), r.stale.len);
    for (r.groups) |g| {
        try testing.expectEqual(@as(?Date, null), g.peer_date);
        try testing.expect(!g.conclusive());
    }
}

/// OOM-path wrapper for `checkAllAllocationFailures`.
fn scanOom(a: std.mem.Allocator, entries: []const Entry, now_s: i64) !void {
    var r = try scan(a, entries, now_s);
    r.deinit(a);
}

test "scan: every allocation-failure path unwinds cleanly" {
    const fri = Date.fromYmd(2025, 6, 13);
    const entries = [_]Entry{
        eq("AAPL", fri, true),
        eq("MSFT", fri, true),
        eq("TSLA", Date.fromYmd(2025, 6, 12), true),
        eq("OLDCO", Date.fromYmd(2025, 1, 2), false),
        eq("DOGE-USD", null, true),
    };
    try testing.checkAllAllocationFailures(
        testing.allocator,
        scanOom,
        .{ @as([]const Entry, &entries), fridayEvening() },
    );
}

test "collect: a benchmark that is ALSO held stays in the corpus" {
    // The regression. `collect` used to drop every excluded key
    // unconditionally, so pointing `benchmark_stock` at a symbol you hold
    // made that holding invisible to `cache stale`, `cache refresh` and
    // `doctor` - the exact opposite of the intent, since the reason to
    // choose a held symbol as your benchmark is that held symbols are the
    // only ones whose dividends get warmed.
    const a = testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    const dir_path = try tmp.dir.realPathFileAlloc(io, ".", a);
    defer a.free(dir_path);
    var store = cache.Store.init(io, a, dir_path);

    var tracked = std.StringHashMap(void).init(a);
    defer tracked.deinit();
    try tracked.put("HELDBM", {}); // held AND the configured benchmark
    try tracked.put("PLAIN", {}); // an ordinary holding

    const keys = [_][]const u8{ "HELDBM", "PLAIN", "PUREBM" };
    const exclude = [_][]const u8{ "HELDBM", "PUREBM" }; // the benchmark pair

    const entries = try collect(a, &store, &keys, &tracked, &exclude);
    defer a.free(entries);

    // PUREBM is benchmark-only -> dropped. The other two survive.
    try testing.expectEqual(@as(usize, 2), entries.len);
    var saw_held = false;
    var saw_plain = false;
    for (entries) |e| {
        if (std.mem.eql(u8, e.symbol, "HELDBM")) {
            saw_held = true;
            // ...and it is reported as tracked, so a stale finding is a
            // real finding rather than an orphan verdict.
            try testing.expect(e.tracked);
        }
        if (std.mem.eql(u8, e.symbol, "PLAIN")) saw_plain = true;
        try testing.expect(!std.mem.eql(u8, e.symbol, "PUREBM"));
    }
    try testing.expect(saw_held);
    try testing.expect(saw_plain);
}

test "collect-style exclusion: an excluded symbol produces no finding at all" {
    const a = testing.allocator;
    // A demand-fetched symbol makes every classification wrong: not stale
    // (whatever needs it fetches it) and not orphaned (something does refresh
    // it). Dropping it from the corpus is the only honest option, and the caller
    // footnotes it instead.
    const fri = Date.fromYmd(2025, 6, 13);
    const entries = [_]Entry{
        eq("AAPL", fri, true),
        eq("MSFT", fri, true),
        // AGG-shaped: well behind, untracked, and not a finding.
        eq("AGG", Date.fromYmd(2025, 6, 2), false),
    };
    // With it present it would land in orphans, which reads as "delete this".
    var with = try scan(a, &entries, fridayEvening());
    defer with.deinit(a);
    try testing.expectEqual(@as(usize, 1), with.orphans.len);

    // Excluded at collect time, it is simply absent.
    var without = try scan(a, entries[0..2], fridayEvening());
    defer without.deinit(a);
    try testing.expectEqual(@as(usize, 0), without.orphans.len);
    try testing.expectEqual(@as(usize, 0), without.stale.len);
}

// ── Adjustment-basis staleness ───────────────────────────────
//
// The candle cache is append-only. `getCandles` tops up from
// `last_date + 1`, and the newly-appended bars arrive with
// `adj_close == close` because nothing has gone ex after them yet -
// while every previously-cached bar keeps the adjustment basis it was
// originally fetched with. When the next distribution goes ex, the bars
// behind it should be marked down by its factor and nothing does it, so
// every total return spanning that ex-date reads low by roughly the
// missed yield.
//
// `CandleMeta.adj_basis` records how current a series' basis is. These
// two functions turn that into a verdict, split the same way as
// `collect` / `scan`: one gathers from disk, one decides.

/// Newest corporate-action ex-date cached for a symbol that has already
/// gone ex as of `through`, or null when there is no such action.
///
/// Splits count as much as dividends here, and arguably more: raw
/// `close` is not split-adjusted (hence `split.cumulativeSplitRatio`),
/// so an unapplied 2:1 split leaves older `adj_close` values wrong by
/// 50% rather than by a quarter's yield.
///
/// `through` is normally `CandleMeta.last_date`, the newest bar held.
/// Actions dated after it are skipped, and that bound is load-bearing
/// twice over:
///
///   - **Convergence.** A declared-but-not-yet-ex distribution is
///     reflected in no provider's adjustment series, so treating it as
///     something to catch up to would refetch on every pass forever.
///   - **Not masking real work.** The bound belongs here, on the
///     selection, rather than on the verdict. Bounding the verdict
///     instead - "is the newest action of all still in the future? then
///     nothing to do" - lets a single forward announcement hide every
///     older unapplied action behind it. That shipped, and it left NKE
///     permanently unable to restate: it announces roughly a quarter
///     ahead, so its newest cached ex-date is essentially always in the
///     future, which masked a distribution that had genuinely gone ex
///     two months earlier.
///
/// Allocates only transiently - the returned `Date` borrows nothing.
pub fn newestCorporateAction(
    allocator: std.mem.Allocator,
    store: *cache.Store,
    symbol: []const u8,
    through: Date,
) ?Date {
    var newest: ?Date = null;

    // `CacheResult` has no deinit - the caller owns `data`. Dividends
    // carry owned strings, so they need `freeSlice`; splits are
    // pure-numeric.
    if (store.read(allocator, Dividend, symbol, null, .any)) |r| {
        defer Dividend.freeSlice(allocator, r.data);
        for (r.data) |d| {
            if (through.lessThan(d.ex_date)) continue;
            if (newest == null or newest.?.lessThan(d.ex_date)) newest = d.ex_date;
        }
    }
    if (store.read(allocator, Split, symbol, null, .any)) |r| {
        defer allocator.free(r.data);
        for (r.data) |sp| {
            if (through.lessThan(sp.date)) continue;
            if (newest == null or newest.?.lessThan(sp.date)) newest = sp.date;
        }
    }

    return newest;
}

/// Does a series' `adj_close` predate a corporate action it should
/// already reflect?
///
/// - `adj_basis`: `CandleMeta.adj_basis` - the bar date through which
///   the cached `adj_close` values reflect corporate actions.
/// - `newest_ex`: from `newestCorporateAction`, which has already
///   discarded anything not yet ex. Null for a symbol with no
///   applicable cached dividends or splits.
///
/// Takes no `last_bar`: the not-yet-ex bound lives in
/// `newestCorporateAction` so that it cannot be applied to the wrong
/// end of the comparison. See that function for what happened when it
/// was applied here instead.
///
/// Pure: two dates in, bool out.
pub fn adjustmentBasisStale(adj_basis: Date, newest_ex: ?Date) bool {
    const newest = newest_ex orelse return false;
    return adj_basis.lessThan(newest);
}

test "adjustmentBasisStale: no applicable action means nothing to restate" {
    // A non-payer has no ex-date to exceed the basis, so it must never
    // escalate - not even with the epoch sentinel a legacy cache parses to.
    try testing.expect(!adjustmentBasisStale(Date.fromYmd(2026, 8, 14), null));
    try testing.expect(!adjustmentBasisStale(Date.epoch, null));
}

test "adjustmentBasisStale: action behind the basis is stale" {
    const ex = Date.fromYmd(2026, 6, 18);

    // Basis past the ex-date already covers it.
    try testing.expect(!adjustmentBasisStale(Date.fromYmd(2026, 8, 14), ex));
    // Basis exactly at the ex-date covers it too (not `lessThan`).
    try testing.expect(!adjustmentBasisStale(ex, ex));
    // Basis behind the ex-date: the bars in between were never marked down.
    try testing.expect(adjustmentBasisStale(Date.fromYmd(2026, 5, 1), ex));
    // The legacy-cache case: sentinel basis on a dividend payer. This is
    // the shape every pre-adj_basis cache lands in.
    try testing.expect(adjustmentBasisStale(Date.epoch, ex));
}

test "newestCorporateAction: takes the max across dividends and splits" {
    const io = testing.io;
    const a = testing.allocator;
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    const dir_path = try tmp.dir.realPathFileAlloc(io, ".", a);
    defer a.free(dir_path);

    var store = cache.Store.init(io, a, dir_path);
    const through = Date.fromYmd(2026, 8, 14);

    // Nothing cached at all.
    try testing.expect(newestCorporateAction(a, &store, "SMPL", through) == null);

    var divs = [_]Dividend{
        .{ .ex_date = Date.fromYmd(2026, 3, 20), .amount = 1.79 },
        .{ .ex_date = Date.fromYmd(2026, 6, 18), .amount = 1.90 },
    };
    store.write(Dividend, "SMPL", divs[0..], .{ .seconds = cache.Ttl.dividends });
    try testing.expect(newestCorporateAction(a, &store, "SMPL", through).?.eql(Date.fromYmd(2026, 6, 18)));

    // A later split must win over the later dividend.
    var splits = [_]Split{.{ .date = Date.fromYmd(2026, 7, 1), .numerator = 2, .denominator = 1 }};
    store.write(Split, "SMPL", splits[0..], .{ .seconds = cache.Ttl.splits });
    try testing.expect(newestCorporateAction(a, &store, "SMPL", through).?.eql(Date.fromYmd(2026, 7, 1)));

    // Splits alone (different symbol) still resolve.
    store.write(Split, "SMPLB", splits[0..], .{ .seconds = cache.Ttl.splits });
    try testing.expect(newestCorporateAction(a, &store, "SMPLB", through).?.eql(Date.fromYmd(2026, 7, 1)));
}

test "newestCorporateAction: a forward-announced action does not mask an older unapplied one" {
    // The regression. NKE announces roughly a quarter ahead, so its
    // newest cached ex-date is essentially always in the future. Bounding
    // the *verdict* on "is the newest action still in the future?" made
    // that one announcement hide a distribution that had gone ex two
    // months earlier, and NKE could never restate. The bound belongs on
    // the selection, so the announcement is skipped and the older
    // already-ex action is still found.
    const io = testing.io;
    const a = testing.allocator;
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    const dir_path = try tmp.dir.realPathFileAlloc(io, ".", a);
    defer a.free(dir_path);

    var store = cache.Store.init(io, a, dir_path);
    const last_bar = Date.fromYmd(2026, 8, 18);

    // NKE's exact shape: applied through 2026-03-02, 2026-06-01 went ex
    // and was never applied, 2026-09-01 declared but not yet ex.
    var divs = [_]Dividend{
        .{ .ex_date = Date.fromYmd(2026, 9, 1), .amount = 0.41 },
        .{ .ex_date = Date.fromYmd(2026, 6, 1), .amount = 0.41 },
        .{ .ex_date = Date.fromYmd(2026, 3, 2), .amount = 0.41 },
    };
    store.write(Dividend, "SMPL", divs[0..], .{ .seconds = cache.Ttl.dividends });

    // The future announcement is skipped; the newest already-ex wins.
    const newest = newestCorporateAction(a, &store, "SMPL", last_bar);
    try testing.expect(newest != null);
    try testing.expect(newest.?.eql(Date.fromYmd(2026, 6, 1)));

    // And the verdict is therefore "stale" for a legacy sentinel basis.
    try testing.expect(adjustmentBasisStale(Date.epoch, newest));
    // ...and for a basis covering 2026-03-02 but not 2026-06-01.
    try testing.expect(adjustmentBasisStale(Date.fromYmd(2026, 4, 1), newest));
    // ...but not once the basis has caught up.
    try testing.expect(!adjustmentBasisStale(Date.fromYmd(2026, 8, 17), newest));
}

test "newestCorporateAction: a declared-but-not-effective split does not mask either" {
    const io = testing.io;
    const a = testing.allocator;
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    const dir_path = try tmp.dir.realPathFileAlloc(io, ".", a);
    defer a.free(dir_path);

    var store = cache.Store.init(io, a, dir_path);
    const last_bar = Date.fromYmd(2026, 8, 18);

    var splits = [_]Split{
        .{ .date = Date.fromYmd(2026, 10, 1), .numerator = 2, .denominator = 1 },
        .{ .date = Date.fromYmd(2026, 5, 1), .numerator = 3, .denominator = 1 },
    };
    store.write(Split, "SMPL", splits[0..], .{ .seconds = cache.Ttl.splits });

    const newest = newestCorporateAction(a, &store, "SMPL", last_bar);
    try testing.expect(newest.?.eql(Date.fromYmd(2026, 5, 1)));
    try testing.expect(adjustmentBasisStale(Date.epoch, newest));
}

test "newestCorporateAction: convergence - an only-future action is not actionable" {
    // The property the bad bound was reaching for, preserved. A
    // declared-but-not-yet-ex action is in no provider's adjustment
    // series, so treating it as something to catch up to would refetch
    // the full history on every pass, forever.
    const io = testing.io;
    const a = testing.allocator;
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    const dir_path = try tmp.dir.realPathFileAlloc(io, ".", a);
    defer a.free(dir_path);

    var store = cache.Store.init(io, a, dir_path);

    var divs = [_]Dividend{.{ .ex_date = Date.fromYmd(2026, 9, 17), .amount = 1.9 }};
    store.write(Dividend, "SMPL", divs[0..], .{ .seconds = cache.Ttl.dividends });

    const last_bar = Date.fromYmd(2026, 8, 18);
    try testing.expect(newestCorporateAction(a, &store, "SMPL", last_bar) == null);
    try testing.expect(!adjustmentBasisStale(Date.epoch, newestCorporateAction(a, &store, "SMPL", last_bar)));

    // Boundary: once it goes ex - `through` reaching the ex-date - it
    // becomes actionable on that very bar.
    const on_ex = Date.fromYmd(2026, 9, 17);
    try testing.expect(newestCorporateAction(a, &store, "SMPL", on_ex).?.eql(on_ex));
    try testing.expect(adjustmentBasisStale(Date.epoch, newestCorporateAction(a, &store, "SMPL", on_ex)));
}
