//! `GET /api/upstream/health` — the pool's own view of its upstreams.
//!
//! The rows are `Pool.Snapshot` with the borrowed strings copied. `last_error`
//! points into the entry that produced it and is rewritten by that entry's next
//! failure, so it is duplicated into the request arena before the pool's mutex
//! is out of sight.
//!
//! No timestamps: the health fields are stamped on the `awake` clock, which
//! stops while the box is suspended and means nothing to a client reading wall
//! time. What an operator needs — is it up, how often does it fail, what did it
//! say last — is here without them.

const std = @import("std");
const Allocator = std.mem.Allocator;

const http_util = @import("../http_util.zig");
const metrics = @import("../metrics.zig");
const pool_mod = @import("../../upstream/pool.zig");
const server = @import("../server.zig");

pub const Upstream = struct {
    url: []const u8,
    enabled: bool,
    available: bool,
    consecutive_failures: u32,
    total_successes: u64,
    total_failures: u64,
    success_rate: f32,
    /// "" when the upstream has never failed.
    last_error: []const u8,
};

pub const Body = struct {
    upstreams: []const Upstream,
    available: u32,
    total: u32,
};

pub fn handle(
    state: *server.WebState,
    io: std.Io,
    request: *http_util.Request,
) http_util.HandlerError!void {
    const pool = state.pool orelse
        return http_util.respondError(request, .service_unavailable, "no upstream pool");
    return http_util.respondJson(request, .ok, try collect(pool, io, request.arena), &.{});
}

pub fn collect(pool: *pool_mod.Pool, io: std.Io, arena: Allocator) Allocator.Error!Body {
    var raw: [metrics.max_upstreams]pool_mod.Snapshot = undefined;
    const count = metrics.poolSnapshot(pool, io, &raw);

    const out = try arena.alloc(Upstream, count);
    var available: u32 = 0;
    for (raw[0..count], out) |entry, *slot| {
        if (entry.available) available += 1;
        slot.* = .{
            .url = try arena.dupe(u8, entry.url),
            .enabled = entry.enabled,
            .available = entry.available,
            .consecutive_failures = entry.consecutive_failures,
            .total_successes = entry.total_successes,
            .total_failures = entry.total_failures,
            .success_rate = entry.success_rate,
            .last_error = try arena.dupe(u8, entry.last_error),
        };
    }

    return .{ .upstreams = out, .available = available, .total = @intCast(count) };
}

// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------

const transport = @import("../../upstream/transport.zig");
const testing = std.testing;

/// The client is never called: every test here reads health, not answers.
fn testEntry(url: []const u8, enabled: bool) pool_mod.Entry {
    return .{
        .endpoint = transport.Endpoint.parse(url) catch unreachable,
        .client = .{ .ptr = undefined, .exchangeFn = undefined },
        .priority = 1,
        .enabled = enabled,
        .health = .init,
    };
}

fn testPool(entries: []pool_mod.Entry) pool_mod.Pool {
    return .init(entries, .{}, .{
        .attempt = .{ .raw = .fromMilliseconds(50), .clock = .awake },
        .total = .{ .raw = .fromMilliseconds(100), .clock = .awake },
    }, 1);
}

test "every upstream is copied, counted and owned by the arena" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var entries = [_]pool_mod.Entry{
        testEntry("https://a.test/dns-query", true),
        testEntry("https://b.test/dns-query", false),
    };
    var pool = testPool(&entries);

    var arena: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena.deinit();

    const body = try collect(&pool, io, arena.allocator());
    try testing.expectEqual(@as(u32, 2), body.total);
    try testing.expectEqual(@as(usize, 2), body.upstreams.len);
    try testing.expectEqualStrings("https://a.test/dns-query", body.upstreams[0].url);
    try testing.expect(body.upstreams[0].enabled);
    try testing.expect(body.upstreams[0].available);
    try testing.expect(!body.upstreams[1].enabled);
    try testing.expect(!body.upstreams[1].available);
    // A disabled upstream is not available, so it is not counted.
    try testing.expectEqual(@as(u32, 1), body.available);
    try testing.expectEqualStrings("", body.upstreams[0].last_error);
}

test "the copied strings survive the entry they came from" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var entries = [_]pool_mod.Entry{testEntry("https://a.test/dns-query", true)};
    var pool = testPool(&entries);

    const at = std.Io.Clock.awake.now(io);
    entries[0].health.recordFailure(at, "ConnectFailed", .{}, 0);

    var arena: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena.deinit();
    const body = try collect(&pool, io, arena.allocator());
    try testing.expectEqualStrings("ConnectFailed", body.upstreams[0].last_error);

    // The entry rewrites its buffer; the copy must not change with it.
    entries[0].health.recordFailure(at, "Timeout", .{}, 0);
    try testing.expectEqualStrings("ConnectFailed", body.upstreams[0].last_error);
}

test "the body serializes with snake_case field names" {
    const upstreams = [_]Upstream{.{
        .url = "https://a.test/dns-query",
        .enabled = true,
        .available = false,
        .consecutive_failures = 3,
        .total_successes = 10,
        .total_failures = 4,
        .success_rate = 0.5,
        .last_error = "ConnectFailed",
    }};

    var allocating: std.Io.Writer.Allocating = .init(testing.allocator);
    defer allocating.deinit();
    try std.json.Stringify.value(
        Body{ .upstreams = &upstreams, .available = 0, .total = 1 },
        .{},
        &allocating.writer,
    );
    const text = allocating.written();

    for ([_][]const u8{
        "\"consecutive_failures\":3",
        "\"total_successes\":10",
        "\"total_failures\":4",
        "\"last_error\":\"ConnectFailed\"",
        "\"available\":0",
        "\"total\":1",
    }) |fragment| {
        try testing.expect(std.mem.containsAtLeast(u8, text, 1, fragment));
    }
}
