const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const srf = @import("srf");
const freshness = @import("../cache/freshness.zig");

const Store = zfin.cache.Store;
const DataType = zfin.cache.DataType;
const Date = zfin.Date;

pub const Subcommand = enum { stats, stale, refresh, clear };

pub const ParsedArgs = struct {
    sub: Subcommand,
    /// Symbols for `refresh`. Empty means "whatever the sweep found", which is
    /// the point of the command - the operator should not have to know which
    /// symbols are wrong, since not knowing is the original problem.
    symbols: []const []const u8 = &.{},
};

pub const meta: framework.Meta = .{
    .name = "cache",
    .group = .infra,
    .synopsis = "Inspect or clear the local provider-data cache",
    .help =
    \\Usage: zfin cache <stats|stale|clear> | zfin cache refresh [SYMBOL...]
    \\
    \\Subcommands:
    \\  stats    List every cached symbol with per-data-type size,
    \\           age, and freshness state. Stale entries (past TTL)
    \\           are flagged. Includes the cusip_tickers.srf file
    \\           if present.
    \\  refresh  Force-refresh candle data, bypassing the TTL and the
    \\           shared server. With no arguments, refreshes exactly what
    \\           `stale` reports. With symbols, refreshes those.
    \\  stale    Find symbols whose newest candle is behind their
    \\           peers'. Compares each symbol against others of the
    \\           same kind (equity vs mutual fund) rather than against
    \\           the clock, so an un-modeled market closure - which
    \\           moves every symbol together - is not mistaken for a
    \\           frozen cache. Read-only; reports, never fetches.
    \\  clear    Delete every file under the cache directory.
    \\           No confirmation; the next provider call will
    \\           re-fetch everything.
    \\
    \\Cache directory is `$ZFIN_CACHE_DIR` if set, else `~/.cache/zfin`.
    \\
    ,
    .uppercase_first_arg = false,
    .user_errors = error{ MissingSubcommand, UnexpectedArg, UnknownSubcommand, RefreshDisabled, NoDataService },
};

/// Data types to show in the stats table (skip candles_meta and meta - internal bookkeeping).
const display_types = [_]DataType{
    .candles_daily,
    .dividends,
    .splits,
    .options,
    .earnings,
};

const display_labels = [_][]const u8{
    "candles",
    "dividends",
    "splits",
    "options",
    "earnings",
};

pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
    if (cmd_args.len < 1) {
        cli.stderrPrint(ctx.io, "Error: 'cache' requires a subcommand (stats, stale, refresh, clear)\n");
        return error.MissingSubcommand;
    }
    const sub_str = cmd_args[0];
    // `refresh` is the only subcommand that takes operands.
    if (std.mem.eql(u8, sub_str, "refresh")) {
        return .{ .sub = .refresh, .symbols = cmd_args[1..] };
    }
    if (cmd_args.len > 1) {
        cli.stderrPrint(ctx.io, "Error: 'cache' takes a single subcommand\n");
        return error.UnexpectedArg;
    }
    if (std.mem.eql(u8, sub_str, "stats")) {
        return .{ .sub = .stats };
    }
    if (std.mem.eql(u8, sub_str, "stale")) {
        return .{ .sub = .stale };
    }
    if (std.mem.eql(u8, sub_str, "clear")) {
        return .{ .sub = .clear };
    }
    cli.stderrPrint(ctx.io, "Error: unknown cache subcommand '");
    cli.stderrPrint(ctx.io, sub_str);
    cli.stderrPrint(ctx.io, "'. Use 'stats', 'stale', 'refresh' or 'clear'.\n");
    return error.UnknownSubcommand;
}

pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
    switch (parsed.sub) {
        .stats => try runStats(ctx),
        .stale => try runStale(ctx),
        .refresh => try runRefresh(ctx, parsed.symbols),
        .clear => try runClear(ctx),
    }
}

fn runStats(ctx: *framework.RunCtx) !void {
    const io = ctx.io;
    const allocator = ctx.allocator;
    const config = ctx.config;
    const out = ctx.out;
    // Capture wall-clock once per invocation so every "X ago" display
    // line in the table is computed against the same reference point.
    //
    // wall-clock required: human-readable "age" rendering needs the
    // current time rather than the per-invocation `today`.
    const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
    try out.print("Cache directory: {s}\n\n", .{config.cache_dir});

    // Enumerate via the store rather than walking the directory here. The
    // staleness sweep needs the same list, and two hand-rolled walks would
    // drift the way the watch-symbol unions did.
    var store = Store.init(io, allocator, config.cache_dir);
    const symbols = store.cacheKeys(allocator) catch {
        try out.print("  (empty -- no cached data)\n", .{});
        return;
    };
    defer Store.freeCacheKeys(allocator, symbols);

    if (symbols.len == 0) {
        try out.print("  (empty -- no cached data)\n", .{});
        return;
    }

    // Track totals
    var total_size: u64 = 0;
    var total_files: usize = 0;

    for (symbols) |symbol| {
        try out.print("{s}\n", .{symbol});

        // Print header
        try out.print("  {s:<14} {s:>10}  {s}\n", .{ "Type", "Size", "Updated" });
        try out.print("  {s:->14} {s:->10}  {s:->24}\n", .{ "", "", "" });

        var symbol_size: u64 = 0;
        var symbol_files: usize = 0;

        for (display_types, display_labels) |dt, label| {
            const info = getFileInfo(io, allocator, config.cache_dir, symbol, dt);
            if (info.exists) {
                symbol_files += 1;
                symbol_size += info.size;

                var size_buf: [10]u8 = undefined;
                const size_str = formatSize(&size_buf, info.size);

                if (info.is_negative) {
                    try out.print("  {s:<14} {s:>10}  (negative cache)\n", .{ label, size_str });
                } else if (info.created) |ts| {
                    var age_buf: [24]u8 = undefined;
                    const age_str = formatAge(&age_buf, ts, now_s);
                    const thru = info.lastDate() orelse "";
                    if (info.expired) {
                        if (thru.len > 0) {
                            try out.print("  {s:<14} {s:>10}  {s} (stale, thru {s})\n", .{ label, size_str, age_str, thru });
                        } else {
                            try out.print("  {s:<14} {s:>10}  {s} (stale)\n", .{ label, size_str, age_str });
                        }
                    } else {
                        if (thru.len > 0) {
                            try out.print("  {s:<14} {s:>10}  {s} (thru {s})\n", .{ label, size_str, age_str, thru });
                        } else {
                            try out.print("  {s:<14} {s:>10}  {s}\n", .{ label, size_str, age_str });
                        }
                    }
                } else {
                    try out.print("  {s:<14} {s:>10}  (no timestamp)\n", .{ label, size_str });
                }
            }
        }

        // Also count candles_meta size (not displayed as its own row but is part of total)
        const meta_info = getFileInfo(io, allocator, config.cache_dir, symbol, .candles_meta);
        if (meta_info.exists) {
            symbol_size += meta_info.size;
            symbol_files += 1;
        }

        if (symbol_files == 0) {
            try out.print("  (empty)\n", .{});
        }
        try out.print("\n", .{});
        total_size += symbol_size;
        total_files += symbol_files;
    }

    // Also count the top-level cusip_tickers.srf if present
    const cusip_path = std.fs.path.join(allocator, &.{ config.cache_dir, "cusip_tickers.srf" }) catch null;
    if (cusip_path) |path| {
        defer allocator.free(path);
        if (std.Io.Dir.cwd().statFile(io, path, .{})) |stat| {
            total_size += stat.size;
            total_files += 1;
        } else |_| {}
    }

    var total_buf: [10]u8 = undefined;
    try out.print("{d} symbol(s), {d} file(s), {s} total\n", .{
        symbols.len,
        total_files,
        formatSize(&total_buf, total_size),
    });
}

/// Peer-comparison staleness sweep over the cached corpus.
///
/// Read-only by design: it reports and never fetches, so it is safe to run
/// against a shared cache and safe to put in front of an operator who has not
/// decided what to do yet. `zfin cache refresh` is the acting half.
/// Everything the sweep needs, allocated in one arena the caller owns.
///
/// Shared by `stale` (report it) and `refresh` (act on it), because the whole
/// point of `refresh` with no arguments is that it acts on exactly what `stale`
/// reports - two builds of this would be two answers to the same question.
const Sweep = struct {
    arena_state: std.heap.ArenaAllocator,
    store: Store,
    report: freshness.Report,
    /// True when there was nothing to look at.
    empty: bool,

    fn deinit(self: *Sweep) void {
        self.arena_state.deinit();
    }
};

fn sweep(ctx: *framework.RunCtx) !Sweep {
    const io = ctx.io;
    const allocator = ctx.allocator;

    // wall-clock required: the sweep compares each symbol's newest bar against
    // the market calendar's notion of what should be published by now.
    // Captured once so every symbol is judged against the same instant.
    const now_s = std.Io.Timestamp.now(io, .real).toSeconds();

    // Arena for the transient string work. Not a nicety: the tracked-set keys
    // are borrowed by a hashmap that outlives the block that builds them, and a
    // scoped `defer free` there dangles every key before the sweep reads it.
    var arena_state = std.heap.ArenaAllocator.init(allocator);
    const arena = arena_state.allocator();
    var store = Store.init(io, allocator, ctx.config.cache_dir);

    const keys = store.cacheKeys(arena) catch &.{};
    if (keys.len == 0) {
        return .{
            .arena_state = arena_state,
            .store = store,
            .report = .{ .stale = &.{}, .far_behind = &.{}, .orphans = &.{}, .missing = &.{}, .groups = &.{} },
            .empty = true,
        };
    }

    // The set zfin intends to keep fresh. Absent a portfolio every cached
    // symbol counts as untracked, which is honest - without one there is
    // nothing to be tracked BY.
    var tracked = std.StringHashMap(void).init(arena);
    if (cli.loadPortfolio(ctx, ctx.today)) |loaded_pf| {
        var l = loaded_pf;
        defer l.deinit(allocator);
        tracked = try cli.trackedSymbols(ctx, arena, l.portfolio, l.anchor());
    }

    const entries = try freshness.collect(arena, &store, keys, &tracked);
    return .{
        .arena_state = arena_state,
        .store = store,
        .report = try freshness.scan(arena, entries, now_s),
        .empty = false,
    };
}

fn runStale(ctx: *framework.RunCtx) !void {
    const out = ctx.out;
    var sw = try sweep(ctx);
    defer sw.deinit();
    if (sw.empty) {
        try out.print("Cache is empty; nothing to check.\n", .{});
        return;
    }
    const report = sw.report;

    for (report.groups) |g| {
        const label = switch (g.kind) {
            .equity => "equity/ETF",
            .mutual_fund => "mutual fund",
        };
        if (g.peer_date) |pd| {
            try out.print("{s:<12} {d} cached, newest bar {f}", .{ label, g.dated, pd });
            if (!g.conclusive()) {
                try out.print("  - only one cached, no peer to compare against", .{});
            } else if (g.freshness) |f| switch (f) {
                // Advice about the corpus, NOT a reason to withhold findings.
                // The per-symbol comparison below stands on its own.
                .lagging => try out.print("  - the latest session's bar is not published yet", .{}),
                .overdue => try out.print("  - the whole group is a session or more behind; a refresh is due", .{}),
                .current => {},
            };
            try out.print("\n", .{});
        } else {
            try out.print("{s:<12} none cached\n", .{label});
        }
    }

    if (report.stale.len > 0) {
        try out.print("\nBehind their peers ({d}):\n", .{report.stale.len});
        for (report.stale) |f| {
            try out.print("  {s:<10} {f}  {d}d behind {f}\n", .{ f.symbol, f.last_date, f.days_behind, f.peer_date });
        }
        try out.print("\n  zfin --refresh-data=force portfolio   re-fetches the tracked set\n", .{});
    } else if (report.far_behind.len == 0) {
        try out.print("\nNothing behind its peers.\n", .{});
    }

    if (report.far_behind.len > 0) {
        // Separated because a refresh is probably not the remedy. Naming no
        // cause on purpose: none of the candidates is visible from here, and the
        // first draft of this asserted "delisted", which is one of the least
        // likely of them.
        try out.print("\nFurther behind than lag explains ({d}):\n", .{report.far_behind.len});
        for (report.far_behind) |f| {
            try out.print("  {s:<10} {f}  {d}d behind {f}\n", .{ f.symbol, f.last_date, f.days_behind, f.peer_date });
        }
        try out.print("  Try `zfin cache refresh` first - a gap this size often means nothing has\n", .{});
        try out.print("  ever refreshed the symbol, not that a refresh cannot. If it does not move:\n", .{});
        try out.print("    - check whatever refreshes this cache actually includes it\n", .{});
        try out.print("      (a shared server refreshes ITS symbol set, not yours)\n", .{});
        try out.print("    - check whether the ticker changed, or the provider wants another form\n", .{});
        try out.print("    - check whether that one symbol is failing auth or being rate-limited\n", .{});
        try out.print("    - check whether the provider still covers it, or it stopped trading\n", .{});
    }

    if (report.missing.len > 0) {
        try out.print("\nTracked but never cached ({d}): ", .{report.missing.len});
        for (report.missing, 0..) |sym, i| try out.print("{s}{s}", .{ if (i == 0) "" else ", ", sym });
        try out.print("\n", .{});
    }

    if (report.orphans.len > 0) {
        try out.print("\nCached but tracked by nothing ({d}): ", .{report.orphans.len});
        for (report.orphans, 0..) |sym, i| try out.print("{s}{s}", .{ if (i == 0) "" else ", ", sym });
        try out.print("\n  Nothing will refresh these. `zfin cache clear` is the blunt option.\n", .{});
    }
}

/// Symbols a bare `cache refresh` should act on, worst first within each bucket.
///
/// BOTH buckets, deliberately. `far_behind` is not "a refresh is futile" - a
/// watchlist symbol was observed 42 days behind purely because no refresh path
/// had ever included it, and one forced fetch brought it fully current.
/// Attempting the refresh is how the operator learns which case they are in, and
/// it costs a single fetch per symbol.
///
/// Strings are duped into `arena` because the report borrows from a `Sweep` whose
/// arena the caller may outlive.
fn refreshTargets(arena: std.mem.Allocator, report: freshness.Report) ![][]const u8 {
    var list: std.ArrayList([]const u8) = .empty;
    errdefer list.deinit(arena);
    for (report.stale) |f| try list.append(arena, try arena.dupe(u8, f.symbol));
    for (report.far_behind) |f| try list.append(arena, try arena.dupe(u8, f.symbol));
    return list.toOwnedSlice(arena);
}

/// Force-refresh candle data for specific symbols, or for whatever the sweep
/// found.
///
/// `force_refresh` is the point: it bypasses both the TTL and the shared server,
/// so it recovers a symbol whose local copy is fresh-stamped but stale-dated -
/// which is precisely the state a lagging server leaves clients in.
///
/// With no arguments it acts on exactly what `zfin cache stale` reports, via the
/// same `sweep`. That matters because not knowing which symbols are wrong is the
/// original problem; requiring the operator to name them would hand the problem
/// back.
fn runRefresh(ctx: *framework.RunCtx, symbols: []const []const u8) !void {
    const io = ctx.io;
    const out = ctx.out;
    const svc = ctx.svc orelse {
        cli.stderrPrint(io, "Error: 'cache refresh' needs data-service access\n");
        return error.NoDataService;
    };

    // `skip_network` beats `force_refresh` inside the service, so a refresh
    // under `--refresh-data=never` would silently do nothing. Say so instead.
    if (ctx.globals.refresh_policy == .never) {
        cli.stderrPrint(io, "Error: 'cache refresh' fetches, which --refresh-data=never forbids\n");
        return error.RefreshDisabled;
    }

    var arena_state = std.heap.ArenaAllocator.init(ctx.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    // Resolve the target list.
    var targets: []const []const u8 = symbols;
    var sw: ?Sweep = null;
    defer if (sw) |*w| w.deinit();
    if (symbols.len == 0) {
        sw = try sweep(ctx);
        targets = try refreshTargets(arena, sw.?.report);
        if (targets.len == 0) {
            try out.print("Nothing behind its peers; no refresh needed.\n", .{});
            return;
        }
        try out.print("Refreshing {d} symbol(s) the sweep flagged.\n\n", .{targets.len});
    } else {
        try out.print("Refreshing {d} named symbol(s).\n\n", .{targets.len});
    }

    var store = Store.init(io, ctx.allocator, ctx.config.cache_dir);
    var moved: usize = 0;
    var failed: usize = 0;

    for (targets) |sym| {
        const before: ?Date = if (store.readCandleMeta(sym)) |m| m.meta.last_date else null;
        if (svc.getCandles(sym, .{ .force_refresh = true })) |r| {
            r.deinit();
        } else |err| {
            // Name the error. "refresh failed" alone leaves the operator unable
            // to tell a rate limit from a bad symbol from a dead key.
            try out.print("  {s:<10} FAILED: {s}\n", .{ sym, @errorName(err) });
            failed += 1;
            continue;
        }
        const after: ?Date = if (store.readCandleMeta(sym)) |m| m.meta.last_date else null;
        if (before != null and after != null and before.?.lessThan(after.?)) {
            try out.print("  {s:<10} {f} -> {f}\n", .{ sym, before.?, after.? });
            moved += 1;
        } else if (after) |a| {
            // The provider had nothing newer. Worth stating plainly rather than
            // printing an unchanged line and letting it read as success.
            try out.print("  {s:<10} still {f} - the provider had nothing newer\n", .{ sym, a });
        } else {
            try out.print("  {s:<10} no candle data after refresh\n", .{sym});
        }
    }

    try out.print("\n{d} of {d} moved forward", .{ moved, targets.len });
    if (failed > 0) try out.print(", {d} failed", .{failed});
    try out.print(".\n", .{});
    if (moved < targets.len - failed) {
        // No claim about why. A symbol can fail to move because the provider
        // genuinely has nothing newer, or because the request never reaches the
        // right data at all - and this command cannot tell those apart.
        try out.print("Symbols that did not move need a look at the provider side; `zfin cache stale` lists what to check.\n", .{});
    }
}

fn runClear(ctx: *framework.RunCtx) !void {
    var store = Store.init(ctx.io, ctx.allocator, ctx.config.cache_dir);
    try store.clearAll();
    try ctx.out.writeAll("Cache cleared.\n");
}

const FileInfo = struct {
    exists: bool = false,
    size: u64 = 0,
    is_negative: bool = false,
    created: ?i64 = null,
    expired: bool = false,
    // SAFETY: paired with `last_date_len`; only the prefix
    // `last_date_buf[0..last_date_len]` is ever read (see `lastDate`).
    last_date_buf: [10]u8 = undefined,
    last_date_len: u4 = 0,

    fn lastDate(self: *const FileInfo) ?[]const u8 {
        if (self.last_date_len == 0) return null;
        return self.last_date_buf[0..self.last_date_len];
    }
};

fn getFileInfo(io: std.Io, allocator: std.mem.Allocator, cache_dir: []const u8, symbol: []const u8, dt: DataType) FileInfo {
    var store = Store.init(io, allocator, cache_dir);

    // Get file size via stat
    const path = std.fs.path.join(allocator, &.{ cache_dir, symbol, dt.fileName() }) catch return .{};
    defer allocator.free(path);
    const stat = std.Io.Dir.cwd().statFile(io, path, .{}) catch return .{};

    // Check for negative cache
    if (store.isNegative(symbol, dt)) {
        return .{ .exists = true, .size = stat.size, .is_negative = true };
    }

    // For candles_daily, use candles_meta for freshness and last_date
    if (dt == .candles_daily) {
        const meta_result = store.readCandleMeta(symbol) orelse
            return .{ .exists = true, .size = stat.size };

        var last_date_buf: [10]u8 = undefined;
        // SAFETY: Date renders as 10-byte "YYYY-MM-DD"; buffer
        // is exactly that size so bufPrint cannot run out of room.
        const date_str = std.fmt.bufPrint(
            &last_date_buf,
            "{f}",
            .{meta_result.meta.last_date},
        ) catch last_date_buf[0..];

        return .{
            .exists = true,
            .size = stat.size,
            .created = meta_result.created,
            .expired = !store.isCandleMetaFresh(symbol),
            .last_date_buf = last_date_buf,
            .last_date_len = @intCast(date_str.len),
        };
    }

    // For all other types, read the file and use the srf iterator for directives
    const data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(50 * 1024 * 1024)) catch
        return .{ .exists = true, .size = stat.size };
    defer allocator.free(data);

    var reader = std.Io.Reader.fixed(data);
    const it = srf.iterator(&reader, allocator, .{ .parse_allocator = .none }) catch
        return .{ .exists = true, .size = stat.size };
    defer it.deinit();

    return .{
        .exists = true,
        .size = stat.size,
        .created = it.created,
        .expired = if (it.expires != null) !it.isFresh(io) else false,
    };
}

pub fn formatSize(buf: *[10]u8, size: u64) []const u8 {
    if (size < 1024) {
        return std.fmt.bufPrint(buf, "{d} B", .{size}) catch "?";
    } else if (size < 1024 * 1024) {
        const kb: f64 = @as(f64, @floatFromInt(size)) / 1024.0;
        return std.fmt.bufPrint(buf, "{d:.1} KB", .{kb}) catch "?";
    } else {
        const mb: f64 = @as(f64, @floatFromInt(size)) / (1024.0 * 1024.0);
        return std.fmt.bufPrint(buf, "{d:.1} MB", .{mb}) catch "?";
    }
}

/// Pure age formatter: renders `after_s - before_s` as a human string
/// (`"5m ago"`, `"2h ago"`, `"3d ago"`, `"just now"`). Caller captures
/// `after_s` via `std.Io.Timestamp.now(io, .real).toSeconds()` once per
/// frame or command and passes it in.
fn formatAge(buf: *[24]u8, before_s: i64, after_s: i64) []const u8 {
    const age = after_s - before_s;

    if (age < 0) {
        return std.fmt.bufPrint(buf, "just now", .{}) catch "?";
    } else if (age < 60) {
        return std.fmt.bufPrint(buf, "{d}s ago", .{@as(u64, @intCast(age))}) catch "?";
    } else if (age < 3600) {
        return std.fmt.bufPrint(buf, "{d}m ago", .{@as(u64, @intCast(@divTrunc(age, 60)))}) catch "?";
    } else if (age < 86400) {
        return std.fmt.bufPrint(buf, "{d}h ago", .{@as(u64, @intCast(@divTrunc(age, 3600)))}) catch "?";
    } else {
        return std.fmt.bufPrint(buf, "{d}d ago", .{@as(u64, @intCast(@divTrunc(age, 86400)))}) catch "?";
    }
}

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

test "parseArgs: 'stats' resolves to .stats" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"stats"};
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expectEqual(Subcommand.stats, parsed.sub);
}

test "parseArgs: 'clear' resolves to .clear" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"clear"};
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expectEqual(Subcommand.clear, parsed.sub);
}

test "parseArgs: missing subcommand errors" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{};
    try std.testing.expectError(error.MissingSubcommand, parseArgs(&ctx, &args));
}

test "parseArgs: unknown subcommand errors" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"bogus"};
    try std.testing.expectError(error.UnknownSubcommand, parseArgs(&ctx, &args));
}

test "parseArgs: extra args error" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{ "stats", "extra" };
    try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}

test "formatAge: future timestamp renders 'just now'" {
    var buf: [24]u8 = undefined;
    try std.testing.expectEqualStrings("just now", formatAge(&buf, 1_700_000_100, 1_700_000_000));
}

test "formatAge: seconds" {
    var buf: [24]u8 = undefined;
    try std.testing.expectEqualStrings("0s ago", formatAge(&buf, 1_700_000_000, 1_700_000_000));
    try std.testing.expectEqualStrings("5s ago", formatAge(&buf, 1_700_000_000, 1_700_000_005));
    try std.testing.expectEqualStrings("59s ago", formatAge(&buf, 1_700_000_000, 1_700_000_059));
}

test "formatAge: minutes" {
    var buf: [24]u8 = undefined;
    // 1m at exactly 60s
    try std.testing.expectEqualStrings("1m ago", formatAge(&buf, 1_700_000_000, 1_700_000_060));
    // 59m at 59*60+59 seconds
    try std.testing.expectEqualStrings("59m ago", formatAge(&buf, 1_700_000_000, 1_700_000_000 + 59 * 60 + 59));
}

test "formatAge: hours" {
    var buf: [24]u8 = undefined;
    try std.testing.expectEqualStrings("1h ago", formatAge(&buf, 1_700_000_000, 1_700_000_000 + 3600));
    // Just under a day
    try std.testing.expectEqualStrings("23h ago", formatAge(&buf, 1_700_000_000, 1_700_000_000 + 23 * 3600 + 59 * 60));
}

test "formatAge: days" {
    var buf: [24]u8 = undefined;
    try std.testing.expectEqualStrings("1d ago", formatAge(&buf, 1_700_000_000, 1_700_000_000 + 86_400));
    try std.testing.expectEqualStrings("30d ago", formatAge(&buf, 1_700_000_000, 1_700_000_000 + 30 * 86_400));
}

test "formatSize: bytes" {
    var buf: [10]u8 = undefined;
    try std.testing.expectEqualStrings("0 B", formatSize(&buf, 0));
    try std.testing.expectEqualStrings("512 B", formatSize(&buf, 512));
    try std.testing.expectEqualStrings("1023 B", formatSize(&buf, 1023));
}

test "formatSize: kilobytes" {
    var buf: [10]u8 = undefined;
    try std.testing.expectEqualStrings("1.0 KB", formatSize(&buf, 1024));
    try std.testing.expectEqualStrings("1.5 KB", formatSize(&buf, 1536));
    try std.testing.expectEqualStrings("100.0 KB", formatSize(&buf, 100 * 1024));
}

test "formatSize: megabytes" {
    var buf: [10]u8 = undefined;
    try std.testing.expectEqualStrings("1.0 MB", formatSize(&buf, 1024 * 1024));
    try std.testing.expectEqualStrings("2.5 MB", formatSize(&buf, 2 * 1024 * 1024 + 512 * 1024));
}

test "parseArgs: 'stale' resolves to .stale" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"stale"};
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expectEqual(Subcommand.stale, parsed.sub);
}

test "parseArgs: 'refresh' accepts an optional symbol list" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;

    // No operands: acts on whatever the sweep found, which is the point - not
    // knowing which symbols are wrong is the original problem.
    const bare = try parseArgs(&ctx, &[_][]const u8{"refresh"});
    try std.testing.expectEqual(Subcommand.refresh, bare.sub);
    try std.testing.expectEqual(@as(usize, 0), bare.symbols.len);

    const named = try parseArgs(&ctx, &[_][]const u8{ "refresh", "AMZN", "NKE" });
    try std.testing.expectEqual(Subcommand.refresh, named.sub);
    try std.testing.expectEqual(@as(usize, 2), named.symbols.len);
    try std.testing.expectEqualStrings("AMZN", named.symbols[0]);
    try std.testing.expectEqualStrings("NKE", named.symbols[1]);
}

test "parseArgs: only 'refresh' takes operands" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    // `stats`/`stale`/`clear` are still single-word, so a stray operand is a
    // typo worth rejecting rather than silently ignoring.
    try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &[_][]const u8{ "stats", "AMZN" }));
    try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &[_][]const u8{ "clear", "AMZN" }));
}

test "refreshTargets: includes both buckets, stale first" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    // `far_behind` is included on purpose. SPCX sat 42 days behind because
    // neither the server's set nor the client's watchlist path fetched it, and a
    // single forced refresh brought it current - so excluding that bucket would
    // have withheld the fix from the symbol that needed it most.
    const stale = [_]freshness.Finding{
        .{ .symbol = "AMZN", .kind = .equity, .last_date = Date.fromYmd(2026, 8, 7), .peer_date = Date.fromYmd(2026, 8, 10), .days_behind = 3 },
    };
    const far = [_]freshness.Finding{
        .{ .symbol = "SPCX", .kind = .equity, .last_date = Date.fromYmd(2026, 6, 29), .peer_date = Date.fromYmd(2026, 8, 10), .days_behind = 42 },
    };
    const targets = try refreshTargets(arena, .{
        .stale = @constCast(&stale),
        .far_behind = @constCast(&far),
        .orphans = &.{},
        .missing = &.{},
        .groups = &.{},
    });
    try std.testing.expectEqual(@as(usize, 2), targets.len);
    try std.testing.expectEqualStrings("AMZN", targets[0]);
    try std.testing.expectEqualStrings("SPCX", targets[1]);
}

test "refreshTargets: a clean sweep yields nothing to do" {
    var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena_state.deinit();
    const targets = try refreshTargets(arena_state.allocator(), .{
        .stale = &.{},
        .far_behind = &.{},
        .orphans = &.{},
        .missing = &.{},
        .groups = &.{},
    });
    try std.testing.expectEqual(@as(usize, 0), targets.len);
}
