//! `zfin server refresh SYMBOL...` - ask the shared server to force-refresh
//! symbols in ITS cache, right now.
//!
//! The distinction from `zfin cache refresh` matters and is easy to get backwards:
//!
//!   `cache refresh`   acts on the LOCAL cache, bypassing the TTL *and* the
//!                     shared server, so it fetches straight from the provider.
//!   `server refresh`  asks the SERVER to refresh its own copy. Nothing local
//!                     changes until the next run syncs from it.
//!
//! Which one you want depends on where the stale bar lives. `zfin diagnose SYMBOL`
//! answers that: when it reports the server offering a bar behind your local copy,
//! or tracking the symbol at all, this is the lever. When the server is fine and
//! only your copy is behind, `cache refresh` is.
//!
//! Exists because a symbol the server serves but does not refresh will hand every
//! client the same stale bar indefinitely, and until now the only fix was an ssh
//! session and a cron run.

const std = @import("std");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const http = @import("../net/http.zig");

pub const ParsedArgs = struct {
    symbols: []const []const u8,
};

pub const meta: framework.Meta = .{
    .name = "server",
    .group = .infra,
    .synopsis = "Ask the shared server to refresh its own cache",
    .help =
    \\Usage: zfin server refresh SYMBOL [SYMBOL...]
    \\
    \\Force-refreshes candle data in the SHARED SERVER's cache (ZFIN_SERVER),
    \\bypassing its TTL. Reports, per symbol, whether its newest bar actually
    \\moved - a fetch can succeed and change nothing when the provider has no
    \\newer data, which is a finding rather than a success.
    \\
    \\Not the same as `zfin cache refresh`, which acts on your LOCAL cache and
    \\deliberately bypasses the server. Use `zfin diagnose SYMBOL` to see which
    \\side is behind before picking one.
    \\
    \\Nothing local changes: your next normal run picks up the server's new copy.
    \\
    \\Requires ZFIN_SERVER, and ZFIN_SERVER_API_KEY when the server enforces one.
    \\The server caps a single request (currently 25 symbols) and refuses a second
    \\concurrent refresh rather than queueing it.
    \\
    ,
    .uppercase_first_arg = false,
    .user_errors = error{ MissingSubcommand, UnknownSubcommand, MissingSymbol },
};

pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
    if (cmd_args.len < 1) {
        cli.stderrPrint(ctx.io, "Error: 'server' requires a subcommand (refresh)\n");
        return error.MissingSubcommand;
    }
    // Only one subcommand today. Matched explicitly rather than ignored so a typo
    // is an error instead of silently refreshing whatever came next - `zfin server
    // referesh AMZN` must not treat "referesh" as a symbol.
    if (!std.mem.eql(u8, cmd_args[0], "refresh")) {
        cli.stderrPrint(ctx.io, "Error: unknown 'server' subcommand (expected: refresh)\n");
        return error.UnknownSubcommand;
    }
    if (cmd_args.len < 2) {
        cli.stderrPrint(ctx.io, "Error: 'server refresh' requires at least one symbol\n");
        return error.MissingSymbol;
    }
    return .{ .symbols = cmd_args[1..] };
}

/// One symbol's outcome, as the server reported it.
pub const Outcome = struct {
    symbol: []const u8,
    ok: bool,
    /// Did the newest bar actually advance? The question the caller has: a
    /// successful fetch that moves nothing means the provider had nothing newer,
    /// which is a different situation from a fix.
    moved: bool = false,
    last_date: ?[]const u8 = null,
    /// Server-side error NAME when `ok` is false, passed through rather than
    /// reworded - an auth failure, a rate limit and a delisted ticker each send
    /// you somewhere different.
    err: ?[]const u8 = null,
};

/// Parse the server's `{"results":[...]}` body.
///
/// Returns null only when the body is not the expected shape at all. Individual
/// fields are read defensively: a newer server adding fields must not cost an
/// older client the ones it understands. Separated from the request so the shape
/// is testable without a server.
pub fn parseResults(arena: std.mem.Allocator, body: []const u8) ?[]const Outcome {
    const parsed = std.json.parseFromSlice(std.json.Value, arena, body, .{}) catch return null;
    const obj = switch (parsed.value) {
        .object => |o| o,
        else => return null,
    };
    const results = switch (obj.get("results") orelse return null) {
        .array => |a| a,
        else => return null,
    };

    var out = std.ArrayList(Outcome).empty;
    for (results.items) |item| {
        const r = switch (item) {
            .object => |o| o,
            else => continue,
        };
        const sym = switch (r.get("symbol") orelse continue) {
            .string => |s| s,
            else => continue,
        };
        var o = Outcome{ .symbol = sym, .ok = false };
        if (r.get("ok")) |v| if (v == .bool) {
            o.ok = v.bool;
        };
        if (r.get("moved")) |v| if (v == .bool) {
            o.moved = v.bool;
        };
        if (r.get("last_date")) |v| if (v == .string) {
            o.last_date = v.string;
        };
        if (r.get("error")) |v| if (v == .string) {
            o.err = v.string;
        };
        out.append(arena, o) catch return null;
    }
    return out.items;
}

/// Human-readable guidance for a transport-level failure.
///
/// Every branch names the actual condition rather than "request failed": the
/// whole point of `HttpError.Conflict` existing is that "a refresh is already
/// running" and "your key is wrong" are different problems, and collapsing them
/// sends you to the wrong place.
pub fn failureAdvice(err: anyerror) []const u8 {
    return switch (err) {
        error.Conflict => "a refresh is already running there - retry once it finishes",
        error.Unauthorized => "rejected: check ZFIN_SERVER_API_KEY",
        error.NotFound => "this server has no /refresh endpoint - it predates the feature",
        error.RateLimited => "the server is rate-limiting; retry shortly",
        error.ServerError => "the server errored; check its logs",
        // 400 lands here, and the server's 400s are specific ("Too many symbols:
        // 26 (limit 25)", "Invalid symbol: NK E"). That text is already emitted by
        // the transport as an `http rejection body` warning, so point at it rather
        // than reprinting "request failed" directly beneath the real reason.
        //
        // Deliberately NOT solved by mapping 400 to its own HttpError variant:
        // `InvalidResponse` is load-bearing elsewhere - `service.zig` routes it to
        // the Yahoo fallback and `isPermanentProviderFailure` counts it transient -
        // so renaming it would alter the provider chain to improve one CLI message.
        error.InvalidResponse => "the server rejected the request - its reason is on the `http rejection body` line above",
        else => "request failed",
    };
}

pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
    const io = ctx.io;
    const out = ctx.out;

    const base = ctx.config.server_url orelse {
        cli.stderrPrint(io, "Error: ZFIN_SERVER is not set - there is no server to ask\n");
        return;
    };

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

    // Uppercased here so the request matches what the server stores and what the
    // response echoes; the framework's `uppercase_first_arg` only covers the first
    // operand, which for this command is the subcommand.
    var joined = std.ArrayList(u8).empty;
    for (parsed.symbols, 0..) |sym, i| {
        if (i > 0) try joined.append(arena, ',');
        const upper = try arena.alloc(u8, sym.len);
        for (sym, 0..) |c, j| upper[j] = std.ascii.toUpper(c);
        try joined.appendSlice(arena, upper);
    }

    // Percent-encoded rather than interpolated. A hand-built query string put a
    // malformed symbol's space straight into the request line, so httpz rejected
    // the request at the HTTP layer and answered with its own generic "Invalid
    // Request" - the operator saw a protocol complaint instead of the server's
    // "Invalid symbol: NK E". An `&` would have been worse: it would silently
    // truncate the symbol list. `isQueryValueChar` leaves `,` unencoded, which is
    // what the server splits on.
    const endpoint = try std.fmt.allocPrint(arena, "{s}/refresh", .{base});
    const url = try http.buildUrl(arena, endpoint, &.{.{ "symbols", joined.items }});
    var client = http.Client.init(io, arena);
    defer client.deinit();

    var hdr: [1]std.http.Header = .{.{ .name = "", .value = "" }};
    const extra: []const std.http.Header = if (ctx.config.server_api_key) |k| blk: {
        hdr[0] = .{ .name = "X-API-Key", .value = k };
        break :blk hdr[0..1];
    } else &.{};

    try out.print("Asking {s} to refresh {d} symbol(s)...\n", .{ base, parsed.symbols.len });
    try out.flush();

    // POST, with an empty body: the symbols travel as query parameters, matching
    // the server's other operator endpoints and keeping the call curl-able.
    var resp = client.request(.POST, url, "", extra) catch |err| {
        // Both the advice and the underlying error name: the advice is what to do,
        // the name is what actually happened, and dropping either leaves the
        // operator guessing at one of them.
        const msg = std.fmt.allocPrint(arena, "Error: {s} ({s})\n", .{
            failureAdvice(err),
            @errorName(err),
        }) catch "Error: request failed\n";
        cli.stderrPrint(io, msg);
        return;
    };
    defer resp.deinit();

    const results = parseResults(arena, resp.body) orelse {
        cli.stderrPrint(io, "Error: could not parse the server's reply\n");
        return;
    };

    var moved: usize = 0;
    var failed: usize = 0;
    for (results) |r| {
        if (!r.ok) {
            failed += 1;
            try out.print("  {s:<10} FAILED  {s}\n", .{ r.symbol, r.err orelse "unknown error" });
            continue;
        }
        if (r.moved) moved += 1;
        // "unchanged" rather than "ok": a successful fetch that moved nothing is
        // the shape of a provider with no newer data, and calling it ok would hide
        // exactly what the operator came to find out.
        try out.print("  {s:<10} {s:<10} {s}\n", .{
            r.symbol,
            if (r.moved) "moved" else "unchanged",
            r.last_date orelse "no bar",
        });
    }

    try out.print("\n{d} moved, {d} unchanged, {d} failed\n", .{
        moved,
        results.len - moved - failed,
        failed,
    });
    if (moved > 0) {
        try out.print("Your local cache is untouched - a normal run will sync the new copy.\n", .{});
    }
    try out.flush();
}

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

const testing = std.testing;

test "parseResults: the shape zfin-server actually returns" {
    // Verbatim from `POST /refresh?symbols=NKE,AMZN,ZZZZQQ` against a live server.
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const body =
        \\{"results":[{"symbol":"NKE","ok":true,"moved":true,"last_date":"2026-08-12"},{"symbol":"AMZN","ok":true,"moved":false,"last_date":"2026-08-12"},{"symbol":"ZZZZQQ","ok":false,"error":"FetchFailed"}]}
    ;
    const r = parseResults(arena.allocator(), body).?;
    try testing.expectEqual(@as(usize, 3), r.len);

    try testing.expectEqualStrings("NKE", r[0].symbol);
    try testing.expect(r[0].ok);
    try testing.expect(r[0].moved);
    try testing.expectEqualStrings("2026-08-12", r[0].last_date.?);

    // The distinction the command exists to surface: fetched fine, moved nothing.
    try testing.expect(r[1].ok);
    try testing.expect(!r[1].moved);

    // A failure carries the server's error name, not a reworded summary.
    try testing.expect(!r[2].ok);
    try testing.expectEqualStrings("FetchFailed", r[2].err.?);
}

test "parseResults: a null last_date survives, and junk is rejected" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const a = arena.allocator();

    // An uncached symbol reports success with no bar. `null` must not become the
    // string "null" or an empty date.
    const r = parseResults(a, "{\"results\":[{\"symbol\":\"X\",\"ok\":true,\"moved\":false,\"last_date\":null}]}").?;
    try testing.expectEqual(@as(?[]const u8, null), r[0].last_date);

    // Not JSON at all - e.g. an HTML error page from a proxy in front of it.
    try testing.expectEqual(@as(?[]const Outcome, null), parseResults(a, "<html>502</html>"));
    // JSON, wrong shape.
    try testing.expectEqual(@as(?[]const Outcome, null), parseResults(a, "[1,2,3]"));
    try testing.expectEqual(@as(?[]const Outcome, null), parseResults(a, "{\"other\":[]}"));
    // An empty result set is valid, not an error.
    try testing.expectEqual(@as(usize, 0), parseResults(a, "{\"results\":[]}").?.len);
}

test "parseResults: an unknown field does not blind an older client" {
    // Forward compatibility, the same reason `diagnose` reads fields individually
    // rather than by struct coercion.
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const body =
        \\{"results":[{"symbol":"NKE","ok":true,"moved":true,"last_date":"2026-08-12","queued_at":123,"extra":{"a":1}}],"summary":"whatever"}
    ;
    const r = parseResults(arena.allocator(), body).?;
    try testing.expectEqual(@as(usize, 1), r.len);
    try testing.expect(r[0].moved);
}

test "failureAdvice: each condition points somewhere different" {
    // The justification for adding `HttpError.Conflict` at all. Before it, 409
    // collapsed into InvalidResponse and this command would have told the operator
    // their request was malformed when it just needed a retry.
    try testing.expect(std.mem.indexOf(u8, failureAdvice(error.Conflict), "already running") != null);
    try testing.expect(std.mem.indexOf(u8, failureAdvice(error.Unauthorized), "ZFIN_SERVER_API_KEY") != null);
    try testing.expect(std.mem.indexOf(u8, failureAdvice(error.NotFound), "predates") != null);
    // 400 arrives as InvalidResponse. The advice must send the operator to the
    // logged rejection body, which carries the server's exact complaint - observed
    // returning "Too many symbols: 26 (limit 25)" while this line said only
    // "request failed".
    try testing.expect(std.mem.indexOf(u8, failureAdvice(error.InvalidResponse), "rejection body") != null);
    try testing.expect(!std.mem.eql(u8, failureAdvice(error.InvalidResponse), failureAdvice(error.ConnectionRefused)));

    // Every branch must say something concrete; none may be empty.
    for ([_]anyerror{
        error.Conflict,          error.Unauthorized, error.NotFound,
        error.RateLimited,       error.ServerError,  error.InvalidResponse,
        error.ConnectionRefused,
    }) |e| {
        try testing.expect(failureAdvice(e).len > 0);
    }

    // And the advice must never be the same for Conflict and Unauthorized, which
    // is the exact collapse this replaced.
    try testing.expect(!std.mem.eql(u8, failureAdvice(error.Conflict), failureAdvice(error.Unauthorized)));
}

test "the symbols parameter is percent-encoded" {
    // Guards the bug this shipped with: an unencoded space made the request line
    // malformed, so the server never saw the symbol and could not explain what was
    // wrong with it. An `&` would have truncated the list without any error at all.
    const a = testing.allocator;
    const url = try http.buildUrl(a, "https://h/refresh", &.{.{ "symbols", "NK E,A&B" }});
    defer a.free(url);
    try testing.expectEqualStrings("https://h/refresh?symbols=NK%20E,A%26B", url);
    // The comma must NOT be encoded - it is the separator the server splits on.
    try testing.expect(std.mem.indexOfScalar(u8, url, ',') != null);
}
