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

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

pub const Subcommand = enum { stats, clear };

pub const ParsedArgs = struct {
    sub: Subcommand,
};

pub const meta: framework.Meta = .{
    .name = "cache",
    .group = .infra,
    .synopsis = "Inspect or clear the local provider-data cache",
    .help =
    \\Usage: zfin cache <stats|clear>
    \\
    \\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.
    \\  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 },
};

/// 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, clear)\n");
        return error.MissingSubcommand;
    }
    if (cmd_args.len > 1) {
        cli.stderrPrint(ctx.io, "Error: 'cache' takes a single subcommand\n");
        return error.UnexpectedArg;
    }
    const sub_str = cmd_args[0];
    if (std.mem.eql(u8, sub_str, "stats")) {
        return .{ .sub = .stats };
    }
    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' or 'clear'.\n");
    return error.UnknownSubcommand;
}

pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
    switch (parsed.sub) {
        .stats => try runStats(ctx),
        .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});

    var dir = std.Io.Dir.cwd().openDir(io, config.cache_dir, .{ .iterate = true }) catch {
        try out.print("  (empty -- no cached data)\n", .{});
        return;
    };
    defer dir.close(io);

    // Collect and sort symbol names
    var symbols: std.ArrayList([]const u8) = .empty;
    defer {
        for (symbols.items) |s| allocator.free(s);
        symbols.deinit(allocator);
    }

    var iter = dir.iterate();
    while (iter.next(io) catch null) |entry| {
        if (entry.kind == .directory) {
            const name = allocator.dupe(u8, entry.name) catch continue;
            symbols.append(allocator, name) catch {
                allocator.free(name);
                continue;
            };
        }
    }

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

    std.mem.sort([]const u8, symbols.items, {}, struct {
        fn cmp(_: void, a: []const u8, b: []const u8) bool {
            return std.mem.order(u8, a, b) == .lt;
        }
    }.cmp);

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

    for (symbols.items) |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.items.len,
        total_files,
        formatSize(&total_buf, total_size),
    });
}

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