//! `GET /api/health` — the rollup a monitor scrapes (ruling 22).
//!
//! Always 200. "degraded" is a fact about the box, not a failure of the
//! request, and answering 503 would make an uptime check flap on a full disk
//! while nxdns is still resolving perfectly well.
//!
//! Unauthenticated and rate-limit exempt, like `/metrics`.
//!
//! `rollup` is pure so the whole degraded matrix is testable without a running
//! server; `handle` only gathers the inputs.

const std = @import("std");

const disk_monitor = @import("../../storage/disk_monitor.zig");
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 Disk = struct {
    state: []const u8,
    free_bytes: u64,
    db_bytes: u64,
    log_bytes: u64,
    sample_failures: u64,
};

/// The diagnostics store's own state, not a summary of what it holds: `state`
/// answers "is the operational log recording", and the two counts answer "what
/// is open right now".
pub const Diagnostics = struct {
    state: []const u8,
    active_warnings: u32,
    active_errors: u32,
};

pub const Upstreams = struct {
    available: u32,
    total: u32,
};

pub const Body = struct {
    status: []const u8,
    disk: Disk,
    upstreams: Upstreams,
    diagnostics: Diagnostics,
    queries_dropped: u64,
    writer_failed: bool,
    refreshes_gated: u64,
    /// Null before the first filter snapshot is published.
    snapshot_generation: ?u64,
};

/// What the rollup is computed from. Every field has a defined value even when
/// its collaborator is missing, and the defaults are the ones a half-wired
/// server should report: no disk reading, no upstreams, nothing published.
pub const Input = struct {
    disk_state: disk_monitor.State = .ok,
    disk: disk_monitor.Gauges = .{ .free_bytes = 0, .db_bytes = 0, .log_bytes = 0 },
    disk_sample_failures: u64 = 0,
    upstreams_available: u32 = 0,
    upstreams_total: u32 = 0,
    queries_dropped: u64 = 0,
    writer_failed: bool = false,
    /// Current state, not a count: the upstream-history flush is failing right
    /// now. Cleared by the next flush that succeeds (m26 ruling 7).
    ///
    /// `rows_dropped` deliberately does not appear here. It is cumulative, and
    /// a rollup that is computed statelessly cannot ask whether a counter grew
    /// — so feeding it in would latch `/api/health` to degraded forever after
    /// one overflow. Drops surface through the metric and through the API's
    /// per-window `complete` instead.
    history_flush_failing: bool = false,
    /// The diagnostics store exists. The benign default matches every other
    /// field here — a half-wired `Input` reports a box with nothing wrong — but
    /// `collect` must assign it explicitly, because in a serving process an
    /// absent store means `Store.init` failed.
    diagnostics_present: bool = true,
    /// The last diagnostics write failed. Current state, cleared by the next
    /// write that succeeds, like `history_flush_failing`.
    diagnostics_write_failed: bool = false,
    diagnostics_active_warnings: u32 = 0,
    diagnostics_active_errors: u32 = 0,
    refreshes_gated: u64 = 0,
    snapshot_generation: ?u64 = null,
};

pub const status_ok = "ok";
pub const status_degraded = "degraded";

pub const diagnostics_recording = "recording";
pub const diagnostics_unavailable = "unavailable";

/// The operational log is not recording — either the store never opened or its
/// writes are failing. Both mean the same thing to an operator: the record of
/// what went wrong is not being kept.
pub fn diagnosticsUnavailable(input: Input) bool {
    return !input.diagnostics_present or input.diagnostics_write_failed;
}

/// Conditions an operator must act on, and every one of them is a fact about
/// now rather than a count of the past: a disk that is filling stops the query
/// log, a pool with nothing available stops resolution, a failed writer means
/// rows are being lost right now, and a failing history flush means the
/// dashboard's upstream numbers are not being recorded. Each clears itself when
/// the underlying condition does.
pub fn degraded(input: Input) bool {
    return input.disk_state != .ok or input.upstreams_available == 0 or
        input.writer_failed or input.history_flush_failing or diagnosticsUnavailable(input);
}

pub fn rollup(input: Input) Body {
    return .{
        .status = if (degraded(input)) status_degraded else status_ok,
        .disk = .{
            .state = @tagName(input.disk_state),
            .free_bytes = input.disk.free_bytes,
            .db_bytes = input.disk.db_bytes,
            .log_bytes = input.disk.log_bytes,
            .sample_failures = input.disk_sample_failures,
        },
        .upstreams = .{ .available = input.upstreams_available, .total = input.upstreams_total },
        .diagnostics = .{
            .state = if (diagnosticsUnavailable(input)) diagnostics_unavailable else diagnostics_recording,
            .active_warnings = input.diagnostics_active_warnings,
            .active_errors = input.diagnostics_active_errors,
        },
        .queries_dropped = input.queries_dropped,
        .writer_failed = input.writer_failed,
        .refreshes_gated = input.refreshes_gated,
        .snapshot_generation = input.snapshot_generation,
    };
}

pub fn handle(
    state: *server.WebState,
    io: std.Io,
    request: *http_util.Request,
) http_util.HandlerError!void {
    return http_util.respondJson(request, .ok, rollup(collect(state, io)), &.{});
}

pub fn collect(state: *server.WebState, io: std.Io) Input {
    var input: Input = .{};

    if (state.monitor) |monitor| {
        input.disk_state = monitor.state();
        input.disk = monitor.gauges();
        input.disk_sample_failures = monitor.sample_failures.load(.monotonic);
    }

    if (state.pool) |pool| {
        var raw: [metrics.max_upstreams]pool_mod.Snapshot = undefined;
        const count = metrics.poolSnapshot(pool, io, &raw);
        input.upstreams_total = @intCast(count);
        for (raw[0..count]) |entry| {
            if (entry.available) input.upstreams_available += 1;
        }
    }

    if (state.logger) |logger| {
        input.queries_dropped = logger.queries_dropped.load(.monotonic);
        input.writer_failed = logger.writer_failed.load(.monotonic);
    }

    // Assigned before the `if`, not inside it: the field's benign default is
    // `true`, so the natural `if (state.events) |store|` shape would report an
    // absent store as recording — the one case that must degrade.
    input.diagnostics_present = state.events != null;
    if (state.events) |store| {
        input.diagnostics_write_failed = store.writeFailed();
        const counts = store.activeCounts(io);
        input.diagnostics_active_warnings = counts.warnings;
        input.diagnostics_active_errors = counts.errors;
    }

    if (state.history) |history| {
        input.history_flush_failing = history.snapshotStats(io).last_flush_failed;
    }

    if (state.manager) |manager| {
        input.refreshes_gated = manager.refreshesGated();
        if (manager.acquire(io)) |acquired| {
            defer acquired.release(io);
            input.snapshot_generation = acquired.snapshot.generation;
        }
    }

    return input;
}

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

const db = @import("../../storage/db.zig");
const events_mod = @import("../../storage/events.zig");
const migrations = @import("../../storage/migrations.zig");
const history_mod = @import("../../upstream/history.zig");
const logger_mod = @import("../../storage/logger.zig");
const upstream_history_repo = @import("../../storage/repositories/upstream_history_repo.zig");
const testing = std.testing;

/// A box with nothing wrong with it: one upstream up, disk ok, writer alive.
const healthy: Input = .{
    .disk_state = .ok,
    .upstreams_available = 1,
    .upstreams_total = 1,
    .writer_failed = false,
};

test "the degraded matrix covers disk state, availability and the writer" {
    const cases = [_]struct { input: Input, degraded: bool }{
        .{ .input = healthy, .degraded = false },
        .{ .input = withDisk(healthy, .warn), .degraded = true },
        .{ .input = withDisk(healthy, .critical), .degraded = true },
        .{ .input = withAvailable(healthy, 0), .degraded = true },
        .{ .input = withWriterFailed(healthy), .degraded = true },
        // A failing upstream-history flush is losing the dashboard's numbers
        // right now, and it recovers on its own the moment a flush succeeds.
        .{ .input = withHistoryFailing(healthy, true), .degraded = true },
        .{ .input = withHistoryFailing(healthy, false), .degraded = false },
        // The operational log not recording is itself a fault an operator must
        // act on: whatever fails next will leave no record of having failed.
        .{ .input = withDiagnostics(healthy, false, false), .degraded = true },
        .{ .input = withDiagnostics(healthy, true, true), .degraded = true },
        .{ .input = withDiagnostics(healthy, true, false), .degraded = false },
        // Two faults at once still report one status.
        .{ .input = withWriterFailed(withDisk(healthy, .critical)), .degraded = true },
        // Some upstreams down is not degraded while one still answers.
        .{ .input = .{ .upstreams_available = 1, .upstreams_total = 3 }, .degraded = false },
    };

    for (cases, 0..) |case, i| {
        errdefer std.debug.print("case {d}\n", .{i});
        try testing.expectEqual(case.degraded, degraded(case.input));
        try testing.expectEqualStrings(
            if (case.degraded) status_degraded else status_ok,
            rollup(case.input).status,
        );
    }
}

fn withDisk(input: Input, state: disk_monitor.State) Input {
    var out = input;
    out.disk_state = state;
    return out;
}

fn withAvailable(input: Input, available: u32) Input {
    var out = input;
    out.upstreams_available = available;
    return out;
}

fn withWriterFailed(input: Input) Input {
    var out = input;
    out.writer_failed = true;
    return out;
}

fn withHistoryFailing(input: Input, failing: bool) Input {
    var out = input;
    out.history_flush_failing = failing;
    return out;
}

fn withDiagnostics(input: Input, present: bool, write_failed: bool) Input {
    var out = input;
    out.diagnostics_present = present;
    out.diagnostics_write_failed = write_failed;
    return out;
}

test "the diagnostics block reports the state and the open counts" {
    const recording = rollup(.{
        .upstreams_available = 1,
        .diagnostics_active_warnings = 3,
        .diagnostics_active_errors = 1,
    });
    try testing.expectEqualStrings(diagnostics_recording, recording.diagnostics.state);
    try testing.expectEqual(@as(u32, 3), recording.diagnostics.active_warnings);
    try testing.expectEqual(@as(u32, 1), recording.diagnostics.active_errors);
    // Open episodes are what the box is doing, not a fault of the log: they do
    // not degrade on their own.
    try testing.expectEqualStrings(status_ok, recording.status);

    // Failing writes: the counts are whatever was last read, and the state is
    // the honest one.
    const failing = rollup(.{ .upstreams_available = 1, .diagnostics_write_failed = true });
    try testing.expectEqualStrings(diagnostics_unavailable, failing.diagnostics.state);
    try testing.expectEqualStrings(status_degraded, failing.status);

    const absent = rollup(.{ .upstreams_available = 1, .diagnostics_present = false });
    try testing.expectEqualStrings(diagnostics_unavailable, absent.diagnostics.state);
    try testing.expectEqualStrings(status_degraded, absent.status);
}

test "collect reports an absent store as unavailable rather than as recording" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    // `diagnostics_present` defaults to true like every other benign default,
    // so an assignment `collect` forgot would read as a healthy log here.
    var state: server.WebState = .{ .gpa = testing.allocator };
    const absent = collect(&state, io);
    try testing.expect(!absent.diagnostics_present);
    try testing.expectEqualStrings(diagnostics_unavailable, rollup(absent).diagnostics.state);

    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    defer database.close();
    try db.applyPragmas(&database, .{});
    _ = try migrations.migrate(&database);
    var store = try events_mod.Store.init(io, &database, 1000);
    store.report(io, 1000, .disk_space, "data", "data", .warning, "low");
    store.report(io, 1000, .listener_start, "doh", "doh", .@"error", "AddressInUse");

    state.events = &store;
    const present = collect(&state, io);
    try testing.expect(present.diagnostics_present);
    try testing.expect(!present.diagnostics_write_failed);
    try testing.expectEqual(@as(u32, 1), present.diagnostics_active_warnings);
    try testing.expectEqual(@as(u32, 1), present.diagnostics_active_errors);
    try testing.expectEqualStrings(diagnostics_recording, rollup(present).diagnostics.state);
}

test "a history overflow that already happened does not degrade the rollup" {
    // `rows_dropped` is cumulative and the rollup is stateless, so the only
    // thing it could do with a drop count is latch on it. The accumulator's
    // drops reach an operator through `/metrics` and through the per-window
    // `complete` flag, and never through this.
    const dropped: Input = .{
        .upstreams_available = 1,
        .upstreams_total = 1,
        .history_flush_failing = false,
    };
    try testing.expect(!degraded(dropped));
    try testing.expectEqualStrings(status_ok, rollup(dropped).status);
}

test "collect reads the accumulator's current flush state" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    const acc = try testing.allocator.create(history_mod.Accumulator);
    defer testing.allocator.destroy(acc);
    acc.* = .init;

    var state: server.WebState = .{ .gpa = testing.allocator, .history = acc };
    try testing.expect(!collect(&state, io).history_flush_failing);

    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    defer database.close();
    acc.recordSuccess(io, "https://a.example", 60);
    // No schema in this database, so the real write fails and the flag is set
    // by the production path rather than by a test poking a field.
    acc.flushOnce(io, &database, upstream_history_repo.flush);
    try testing.expect(collect(&state, io).history_flush_failing);
    try testing.expectEqualStrings("degraded", rollup(collect(&state, io)).status);
}

test "the body reports every input verbatim" {
    const body = rollup(.{
        .disk_state = .warn,
        .disk = .{ .free_bytes = 100, .db_bytes = 20, .log_bytes = 3 },
        .disk_sample_failures = 2,
        .upstreams_available = 2,
        .upstreams_total = 4,
        .queries_dropped = 9,
        .writer_failed = false,
        .refreshes_gated = 1,
        .snapshot_generation = 12,
    });

    try testing.expectEqualStrings("degraded", body.status);
    try testing.expectEqualStrings("warn", body.disk.state);
    try testing.expectEqual(@as(u64, 100), body.disk.free_bytes);
    try testing.expectEqual(@as(u64, 20), body.disk.db_bytes);
    try testing.expectEqual(@as(u64, 3), body.disk.log_bytes);
    try testing.expectEqual(@as(u64, 2), body.disk.sample_failures);
    try testing.expectEqual(@as(u32, 2), body.upstreams.available);
    try testing.expectEqual(@as(u32, 4), body.upstreams.total);
    try testing.expectEqual(@as(u64, 9), body.queries_dropped);
    try testing.expectEqual(@as(u64, 1), body.refreshes_gated);
    try testing.expectEqual(@as(?u64, 12), body.snapshot_generation);
}

test "an unpublished snapshot serializes as null, not as zero" {
    var buffer: [512]u8 = undefined;
    var writer: std.Io.Writer = .fixed(&buffer);
    try std.json.Stringify.value(rollup(.{}), .{}, &writer);
    try testing.expect(std.mem.containsAtLeast(u8, writer.buffered(), 1, "\"snapshot_generation\":null"));
}

test "collect reads the logger's counters and reports a bare state as degraded" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var queue_buf: [2]logger_mod.Entry = undefined;
    var query_logger: logger_mod.Logger = .init(.{}, &queue_buf);
    query_logger.queries_dropped.store(4, .monotonic);
    query_logger.writer_failed.store(true, .monotonic);

    var state: server.WebState = .{ .gpa = testing.allocator, .logger = &query_logger };
    const input = collect(&state, io);

    try testing.expectEqual(@as(u64, 4), input.queries_dropped);
    try testing.expect(input.writer_failed);
    try testing.expectEqual(@as(u32, 0), input.upstreams_total);
    try testing.expectEqual(@as(?u64, null), input.snapshot_generation);
    try testing.expectEqualStrings("degraded", rollup(input).status);
}
