//! Per-minute upstream outcome history (milestone-26 rulings 1, 3, 4).
//!
//! Outcomes are aggregated into their wall-clock UTC minute at the moment the
//! pool records them, and the aggregates are flushed to `querylog.db` once a
//! minute. **Nothing samples a lifetime counter and subtracts.** That is what
//! makes the stored numbers additive facts: a restart inside a minute adds to
//! the same row, a crash loses at most the cells that had not flushed yet, and
//! no path anywhere can produce a negative delta.
//!
//! The query path may not touch SQLite, so recording is memory-only under this
//! module's own mutex and the writing happens on a task of its own.
//!
//! Two failure modes, deliberately kept apart:
//!
//!   * **Overflow.** More live `(url, minute)` pairs than `max_pending`. The
//!     oldest minute is dropped, `rows_dropped` counts it and
//!     `last_drop_minute` remembers how new the newest lost minute was, so a
//!     window that starts after it can still be reported as complete.
//!   * **A failed flush.** Nothing is dropped: the rows go back into the
//!     accumulator and the next pass writes them again.
//!
//! The wall clock, not `.awake`: history participates in wall-clock periods, so
//! a minute here is the same minute the dashboard's period picker means.
//! Routing state (`health.zig`) stays on `.awake` and is untouched by this
//! file.

const std = @import("std");

const db = @import("../storage/db.zig");
const events = @import("../storage/events.zig");
const health = @import("health.zig");
const upstream_history_repo = @import("../storage/repositories/upstream_history_repo.zig");

const log = std.log.scoped(.upstream_history);

/// Live `(url, minute)` cells. Not a knob (m26 anti-requirements). A household
/// pool is a handful of upstreams, so the bound is only reachable when flushing
/// has been failing for hours.
pub const max_pending = 4096;

/// One flush pass per minute, so an unflushed cell is at most about a minute
/// old. `.boot` rather than `.awake`, for `retention.zig`'s reason: a box that
/// suspends must still see its interval elapse.
pub const flush_interval_s = 60;

/// The UTC minute `wall_s` falls in, as its start in seconds. `@divFloor`, not
/// `@divTrunc`: a negative second belongs to the minute before it.
pub fn minuteOf(wall_s: i64) i64 {
    return @divFloor(wall_s, 60) * 60;
}

/// The write seam. Production passes `upstream_history_repo.flush`; a test
/// passes a stub that fails, or one that records what it was handed.
pub const WriteFn = *const fn (*db.Db, []const upstream_history_repo.FlushRow) db.Error!void;

pub const Accumulator = struct {
    pub const Cell = struct {
        /// Borrowed from the pool entry's endpoint, which lives as long as the
        /// process. Nothing here copies it, and nothing here may outlive it.
        url: []const u8,
        minute_ts: i64,
        /// Both counters saturate instead of wrapping: every write site uses
        /// `+|=` — `recordSuccess`, `recordFailure`, and `mergeBack`, which
        /// sums a failed flush's copy back into the live cell. The saturation
        /// is deliberate and unreachable: a cell counts one upstream's
        /// outcomes inside a single wall-clock minute, so filling a `u32`
        /// would take about 72 million exchanges per second with that one
        /// upstream. Nothing reports it, by design — `last_drop_minute` and
        /// the `complete` flag it feeds describe capacity drops, and a
        /// saturated counter is not a drop.
        successes: u32,
        failures: u32,
        last_failure_ts: ?i64,
        last_error_buf: [health.error_name_capacity]u8,
        last_error_len: u8,

        fn lastError(self: *const Cell) []const u8 {
            return self.last_error_buf[0..self.last_error_len];
        }
    };

    /// A consistent copy for `/metrics`, `/api/health` and the API layer.
    pub const Stats = struct {
        flushes: u64 = 0,
        flush_failures: u64 = 0,
        rows_dropped: u64 = 0,
        pending: u32 = 0,
        /// The newest minute capacity has ever cost this process, or null when
        /// nothing was ever dropped. A window that starts after it is complete
        /// again, so one historical overflow does not mark every later answer.
        last_drop_minute: ?i64 = null,
        /// Current state, not a count: set by a failed flush and cleared by the
        /// next successful one. Feeds the `/api/health` rollup.
        last_flush_failed: bool = false,
    };

    /// Atomic for the reason `retention.zig`'s are: the flush task writes them
    /// and the web task reads them, on different threads. They are bumped
    /// outside the mutex, so the lock is not what orders them.
    const Counters = struct {
        flushes: std.atomic.Value(u64) = .init(0),
        flush_failures: std.atomic.Value(u64) = .init(0),
        rows_dropped: std.atomic.Value(u64) = .init(0),
    };

    mutex: std.Io.Mutex = .init,
    cells: [max_pending]Cell,
    count: u32,
    last_drop_minute: ?i64,
    last_flush_failed: bool,
    counters: Counters,
    /// Owned by whichever task runs `flushOnce`, which is one task. It is a
    /// field rather than a local so that the megabyte it costs lives wherever
    /// the accumulator was placed instead of on a task's stack.
    flush_cells: [max_pending]Cell,
    flush_rows: [max_pending]upstream_history_repo.FlushRow,
    flush_count: u32,
    /// Set once by the composition root after `init`, following the
    /// `gate: ?*disk_monitor.Monitor` idiom. Null everywhere else, and every
    /// emit site below is inert when it is.
    diagnostics: ?*events.Store = null,

    /// `cells` and `flush_cells` are `undefined`: a cell is always written
    /// before it is read, and `count` is what says which ones exist.
    pub const init: Accumulator = .{
        .mutex = .init,
        .cells = undefined,
        .count = 0,
        .last_drop_minute = null,
        .last_flush_failed = false,
        .counters = .{},
        .flush_cells = undefined,
        .flush_rows = undefined,
        .flush_count = 0,
    };

    pub fn recordSuccess(self: *Accumulator, io: std.Io, url: []const u8, wall_s: i64) void {
        // Uncancelable for the reason the pool's health sections are: this
        // takes no Io and never blocks on a peer, and losing the record of a
        // completed exchange to a cancellation would undercount for good.
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);
        const cell = self.cellFor(url, minuteOf(wall_s));
        cell.successes +|= 1;
    }

    pub fn recordFailure(
        self: *Accumulator,
        io: std.Io,
        url: []const u8,
        wall_s: i64,
        error_name: []const u8,
    ) void {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);
        const cell = self.cellFor(url, minuteOf(wall_s));
        cell.failures +|= 1;
        noteFailure(cell, wall_s, error_name);
    }

    /// The only read surface. Every field above is private to this module, so
    /// no consumer can read one of them without the mutex.
    pub fn snapshotStats(self: *Accumulator, io: std.Io) Stats {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);
        return .{
            .flushes = self.counters.flushes.load(.monotonic),
            .flush_failures = self.counters.flush_failures.load(.monotonic),
            .rows_dropped = self.counters.rows_dropped.load(.monotonic),
            .pending = self.count,
            .last_drop_minute = self.last_drop_minute,
            .last_flush_failed = self.last_flush_failed,
        };
    }

    /// The cell for `(url, minute_ts)`, created if it is not there yet. The
    /// caller holds the mutex.
    ///
    /// Linear: the live set is one cell per upstream per unflushed minute,
    /// which at household scale is a handful. A miss at capacity evicts the
    /// oldest minute — see `evictOldest`.
    fn cellFor(self: *Accumulator, url: []const u8, minute_ts: i64) *Cell {
        for (self.cells[0..self.count]) |*cell| {
            if (cell.minute_ts != minute_ts) continue;
            // Pointer equality first: every call from the pool passes the same
            // `Entry.endpoint.url`, so the byte compare is the cold path.
            if (cell.url.ptr == url.ptr and cell.url.len == url.len) return cell;
            if (std.mem.eql(u8, cell.url, url)) return cell;
        }

        const slot = if (self.count < max_pending) fresh: {
            const index = self.count;
            self.count += 1;
            break :fresh &self.cells[index];
        } else self.evictOldest();

        slot.* = .{
            .url = url,
            .minute_ts = minute_ts,
            .successes = 0,
            .failures = 0,
            .last_failure_ts = null,
            .last_error_buf = @splat(0),
            .last_error_len = 0,
        };
        return slot;
    }

    /// Frees the cell holding the oldest minute and accounts for what it cost.
    ///
    /// `last_drop_minute` moves through `@max` and never through assignment: a
    /// merge-back after a failed flush can evict a cell older than one already
    /// dropped, and a watermark that moved backwards would report a window as
    /// complete when outcomes inside it are gone.
    fn evictOldest(self: *Accumulator) *Cell {
        var oldest: usize = 0;
        for (self.cells[1..self.count], 1..) |*cell, i| {
            if (cell.minute_ts < self.cells[oldest].minute_ts) oldest = i;
        }
        const evicted = self.cells[oldest].minute_ts;
        self.last_drop_minute = @max(self.last_drop_minute orelse evicted, evicted);
        _ = self.counters.rows_dropped.fetchAdd(1, .monotonic);
        return &self.cells[oldest];
    }

    /// One flush pass: swap the dirty cells out, write them, and on failure put
    /// them back.
    ///
    /// **A swap, never a subtraction.** SQLite runs outside the mutex, so while
    /// it does, a full accumulator can evict a cell that was copied out and
    /// then recreate the same `(url, minute_ts)`. A post-flush subtract would
    /// then destroy outcomes recorded during the write. Moving the cells out
    /// makes the flush own them: what is recorded beside it is new data, and a
    /// failure merges the two additively.
    ///
    /// Every failure is counted and warned about once; nothing here returns an
    /// error, because there is no caller that could do anything the next pass
    /// will not do anyway.
    pub fn flushOnce(self: *Accumulator, io: std.Io, database: *db.Db, write: WriteFn) void {
        {
            self.mutex.lockUncancelable(io);
            defer self.mutex.unlock(io);
            if (self.count == 0) return;
            @memcpy(self.flush_cells[0..self.count], self.cells[0..self.count]);
            self.flush_count = self.count;
            self.count = 0;
        }

        const rows = self.flush_rows[0..self.flush_count];
        for (self.flush_cells[0..self.flush_count], rows) |*cell, *row| {
            row.* = .{
                .url = cell.url,
                .minute_ts = cell.minute_ts,
                .successes = cell.successes,
                .failures = cell.failures,
                .last_failure_ts = cell.last_failure_ts,
                .last_error = cell.lastError(),
            };
        }

        if (write(database, rows)) {
            _ = self.counters.flushes.fetchAdd(1, .monotonic);
            if (self.diagnostics) |store| {
                store.resolve(io, std.Io.Clock.real.now(io).toSeconds(), .upstream_history_write, flush_key);
            }
            self.mutex.lockUncancelable(io);
            defer self.mutex.unlock(io);
            self.last_flush_failed = false;
            return;
        } else |err| {
            _ = self.counters.flush_failures.fetchAdd(1, .monotonic);
            log.warn("flushing {d} upstream history rows failed: {s}", .{ rows.len, @errorName(err) });
            if (self.diagnostics) |store| {
                var buf: [events.Store.max_detail_len]u8 = undefined;
                const detail = std.fmt.bufPrint(&buf, "flushing {d} upstream history rows failed: {s}", .{
                    rows.len,
                    @errorName(err),
                }) catch buf[0..];
                store.report(
                    io,
                    std.Io.Clock.real.now(io).toSeconds(),
                    .upstream_history_write,
                    flush_key,
                    "upstream history flush",
                    .warning,
                    detail,
                );
            }
            self.mergeBack(io);
        }
    }

    /// Puts a failed pass's cells back through the rules recording uses: a cell
    /// recorded during the write keeps its outcomes and the merge sums into it,
    /// and a merge that overflows follows the ordinary drop policy.
    fn mergeBack(self: *Accumulator, io: std.Io) void {
        self.mutex.lockUncancelable(io);
        defer self.mutex.unlock(io);
        self.last_flush_failed = true;

        for (self.flush_cells[0..self.flush_count]) |*saved| {
            const cell = self.cellFor(saved.url, saved.minute_ts);
            cell.successes +|= saved.successes;
            cell.failures +|= saved.failures;
            if (saved.last_failure_ts) |at| noteFailure(cell, at, saved.lastError());
        }
    }

    /// Daily-loop shape (`retention.zig`): flush first, then sleep, so a
    /// process that is about to be canceled has already written once.
    pub fn run(self: *Accumulator, io: std.Io, database: *db.Db) std.Io.Cancelable!void {
        const interval: std.Io.Clock.Duration = .{
            .raw = .fromSeconds(flush_interval_s),
            .clock = .boot,
        };
        while (true) {
            self.flushOnce(io, database, upstream_history_repo.flush);
            try interval.sleep(io);
        }
    }
};

/// One accumulator, one flush task, one table: the subject of every
/// `upstream_history.write` episode is that single writer.
const flush_key = "flush";

/// Max-wins, matching the SQL upsert exactly: the newest failure in the minute
/// is the one whose name the cell keeps.
fn noteFailure(cell: *Accumulator.Cell, at: i64, error_name: []const u8) void {
    if (cell.last_failure_ts) |existing| {
        if (at < existing) return;
    }
    cell.last_failure_ts = at;
    const copied = @min(error_name.len, cell.last_error_buf.len);
    @memcpy(cell.last_error_buf[0..copied], error_name[0..copied]);
    cell.last_error_len = @intCast(copied);
}

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

const events_fixture = @import("../storage/events_fixture.zig");
const querylog_schema = @import("../storage/querylog_schema.zig");

const testing = std.testing;

fn openLog() !db.Db {
    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    errdefer database.close();
    try db.applyPragmas(&database, .{});
    try database.exec(querylog_schema.ddl);
    return database;
}

/// The accumulator is about a megabyte, which is more than a test frame should
/// carry.
fn newAccumulator() !*Accumulator {
    const acc = try testing.allocator.create(Accumulator);
    acc.* = .init;
    return acc;
}

fn failingWrite(_: *db.Db, _: []const upstream_history_repo.FlushRow) db.Error!void {
    return error.Busy;
}

/// What the last stubbed flush was handed, copied out so an assertion can read
/// it after the pass returned.
var recorded: [8]upstream_history_repo.FlushRow = undefined;
var recorded_len: usize = 0;

fn recordingWrite(_: *db.Db, rows: []const upstream_history_repo.FlushRow) db.Error!void {
    recorded_len = @min(rows.len, recorded.len);
    @memcpy(recorded[0..recorded_len], rows[0..recorded_len]);
}

/// The interleaving of ruling 4: a flush is in flight, and the recording side
/// recreates a swapped-out cell and then fills the accumulator to overflow.
var interleaved: ?*Accumulator = null;
var interleave_io: ?std.Io = null;
var interleave_urls: [max_pending][8]u8 = undefined;

fn interleavingWrite(_: *db.Db, _: []const upstream_history_repo.FlushRow) db.Error!void {
    const acc = interleaved.?;
    const io = interleave_io.?;
    // The very `(url, minute_ts)` the flush is holding, recorded again while
    // the write runs.
    acc.recordSuccess(io, "https://a.example", 60);
    // And then enough distinct minutes to fill the accumulator and evict.
    for (&interleave_urls, 0..) |*name, i| {
        const url = std.fmt.bufPrint(name, "u{d:0>6}", .{i}) catch unreachable;
        acc.recordSuccess(io, url, @as(i64, @intCast(i)) * 600 + 6000);
    }
    return error.Busy;
}

test "minuteOf floors to the minute, including before the epoch" {
    try testing.expectEqual(@as(i64, 0), minuteOf(0));
    try testing.expectEqual(@as(i64, 0), minuteOf(59));
    try testing.expectEqual(@as(i64, 60), minuteOf(60));
    try testing.expectEqual(@as(i64, 120), minuteOf(179));
    try testing.expectEqual(@as(i64, -60), minuteOf(-1));
}

test "outcomes land in the cell of their own minute and upstream" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    const acc = try newAccumulator();
    defer testing.allocator.destroy(acc);

    acc.recordSuccess(io, "https://a.example", 65);
    acc.recordSuccess(io, "https://a.example", 119);
    acc.recordFailure(io, "https://a.example", 100, "Timeout");
    acc.recordSuccess(io, "https://a.example", 130);
    acc.recordSuccess(io, "https://b.example", 70);

    // Three cells: a/60, a/120 and b/60.
    try testing.expectEqual(@as(u32, 3), acc.snapshotStats(io).pending);

    const first = acc.cellFor("https://a.example", 60);
    try testing.expectEqual(@as(u32, 2), first.successes);
    try testing.expectEqual(@as(u32, 1), first.failures);
    try testing.expectEqual(@as(?i64, 100), first.last_failure_ts);
    try testing.expectEqualStrings("Timeout", first.lastError());

    const second = acc.cellFor("https://a.example", 120);
    try testing.expectEqual(@as(u32, 1), second.successes);
    try testing.expectEqual(@as(u32, 0), second.failures);
    try testing.expectEqual(@as(?i64, null), second.last_failure_ts);
}

test "a cell keeps the newest failure's error and ignores an older one" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    const acc = try newAccumulator();
    defer testing.allocator.destroy(acc);

    acc.recordFailure(io, "https://a.example", 100, "Timeout");
    acc.recordFailure(io, "https://a.example", 80, "ConnectFailed");
    const cell = acc.cellFor("https://a.example", 60);
    try testing.expectEqual(@as(u32, 2), cell.failures);
    try testing.expectEqual(@as(?i64, 100), cell.last_failure_ts);
    try testing.expectEqualStrings("Timeout", cell.lastError());

    acc.recordFailure(io, "https://a.example", 110, "BadResponse");
    try testing.expectEqual(@as(?i64, 110), cell.last_failure_ts);
    try testing.expectEqualStrings("BadResponse", cell.lastError());

    const long = "A" ** 200;
    acc.recordFailure(io, "https://a.example", 115, long);
    try testing.expectEqual(@as(usize, health.error_name_capacity), cell.lastError().len);
}

test "at capacity the oldest minute is dropped, counted, and the watermark only moves forward" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    const acc = try newAccumulator();
    defer testing.allocator.destroy(acc);

    var names: [max_pending][8]u8 = undefined;
    for (&names, 0..) |*name, i| {
        const url = std.fmt.bufPrint(name, "u{d:0>6}", .{i}) catch unreachable;
        // Minute 600 is the oldest; every later cell is newer.
        acc.recordSuccess(io, url, 600 + @as(i64, @intCast(i)) * 60);
    }
    try testing.expectEqual(@as(u32, max_pending), acc.snapshotStats(io).pending);
    try testing.expectEqual(@as(u64, 0), acc.snapshotStats(io).rows_dropped);
    try testing.expectEqual(@as(?i64, null), acc.snapshotStats(io).last_drop_minute);

    // One more cell evicts the oldest minute and nothing else.
    acc.recordSuccess(io, "https://new.example", 10_000_000);
    const after = acc.snapshotStats(io);
    try testing.expectEqual(@as(u32, max_pending), after.pending);
    try testing.expectEqual(@as(u64, 1), after.rows_dropped);
    try testing.expectEqual(@as(?i64, 600), after.last_drop_minute);

    // A later eviction of an *older* minute must not move the watermark back.
    acc.recordSuccess(io, "https://older.example", 120);
    const back = acc.snapshotStats(io);
    try testing.expectEqual(@as(u64, 2), back.rows_dropped);
    try testing.expectEqual(@as(?i64, 660), back.last_drop_minute);

    acc.recordSuccess(io, "https://newer.example", 20_000_000);
    const forward = acc.snapshotStats(io);
    try testing.expectEqual(@as(u64, 3), forward.rows_dropped);
    // The minute just evicted is 120, older than the 660 already recorded.
    try testing.expectEqual(@as(?i64, 660), forward.last_drop_minute);
}

test "a successful flush hands over every cell, empties the accumulator and counts once" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var database = try openLog();
    defer database.close();

    const acc = try newAccumulator();
    defer testing.allocator.destroy(acc);

    acc.recordSuccess(io, "https://a.example", 65);
    acc.recordFailure(io, "https://a.example", 100, "Timeout");
    acc.flushOnce(io, &database, recordingWrite);

    try testing.expectEqual(@as(usize, 1), recorded_len);
    try testing.expectEqualStrings("https://a.example", recorded[0].url);
    try testing.expectEqual(@as(i64, 60), recorded[0].minute_ts);
    try testing.expectEqual(@as(u32, 1), recorded[0].successes);
    try testing.expectEqual(@as(u32, 1), recorded[0].failures);
    try testing.expectEqual(@as(?i64, 100), recorded[0].last_failure_ts);
    try testing.expectEqualStrings("Timeout", recorded[0].last_error);

    const stats = acc.snapshotStats(io);
    try testing.expectEqual(@as(u32, 0), stats.pending);
    try testing.expectEqual(@as(u64, 1), stats.flushes);
    try testing.expectEqual(@as(u64, 0), stats.flush_failures);
    try testing.expect(!stats.last_flush_failed);
}

test "a pass with nothing pending counts neither a flush nor a failure" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var database = try openLog();
    defer database.close();

    const acc = try newAccumulator();
    defer testing.allocator.destroy(acc);

    acc.flushOnce(io, &database, failingWrite);
    acc.flushOnce(io, &database, recordingWrite);

    const stats = acc.snapshotStats(io);
    try testing.expectEqual(@as(u64, 0), stats.flushes);
    try testing.expectEqual(@as(u64, 0), stats.flush_failures);
    try testing.expect(!stats.last_flush_failed);
}

test "a failed flush keeps the rows, sets the flag, and the next success clears it" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var database = try openLog();
    defer database.close();

    const acc = try newAccumulator();
    defer testing.allocator.destroy(acc);

    acc.recordSuccess(io, "https://a.example", 65);
    acc.recordFailure(io, "https://a.example", 100, "Timeout");
    acc.flushOnce(io, &database, failingWrite);

    const failed = acc.snapshotStats(io);
    try testing.expectEqual(@as(u64, 0), failed.flushes);
    try testing.expectEqual(@as(u64, 1), failed.flush_failures);
    try testing.expectEqual(@as(u64, 0), failed.rows_dropped);
    try testing.expect(failed.last_flush_failed);
    // Nothing was lost: the cell is back, whole.
    try testing.expectEqual(@as(u32, 1), failed.pending);
    const cell = acc.cellFor("https://a.example", 60);
    try testing.expectEqual(@as(u32, 1), cell.successes);
    try testing.expectEqual(@as(u32, 1), cell.failures);
    try testing.expectEqualStrings("Timeout", cell.lastError());

    // The retry writes what the failed pass could not.
    acc.recordSuccess(io, "https://a.example", 70);
    acc.flushOnce(io, &database, recordingWrite);
    const cleared = acc.snapshotStats(io);
    try testing.expectEqual(@as(u64, 1), cleared.flushes);
    try testing.expectEqual(@as(u64, 1), cleared.flush_failures);
    try testing.expect(!cleared.last_flush_failed);
    try testing.expectEqual(@as(usize, 1), recorded_len);
    try testing.expectEqual(@as(u32, 2), recorded[0].successes);
    try testing.expectEqual(@as(u32, 1), recorded[0].failures);
}

test "a merge-back sums into what was recorded beside the flush" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var database = try openLog();
    defer database.close();

    const acc = try newAccumulator();
    defer testing.allocator.destroy(acc);

    // The write recreates the swapped-out cell before it fails, which is the
    // interleaving the swap makes possible.
    interleaved = acc;
    interleave_io = io;
    defer {
        interleaved = null;
        interleave_io = null;
    }

    acc.recordSuccess(io, "https://a.example", 65);
    acc.recordFailure(io, "https://a.example", 100, "Timeout");
    acc.flushOnce(io, &database, interleavingWrite);

    const stats = acc.snapshotStats(io);
    try testing.expect(stats.last_flush_failed);
    try testing.expectEqual(@as(u64, 1), stats.flush_failures);
    // Two drops, and they are different drops: filling to capacity during the
    // write evicted the recreated minute 60, and the merge-back then found no
    // room either and evicted the oldest of what the write had left, 6000.
    // Overflow loss is the specced policy; what matters is that it is counted.
    try testing.expectEqual(@as(u32, max_pending), stats.pending);
    try testing.expectEqual(@as(u64, 2), stats.rows_dropped);
    // Forward only: the second eviction was the newer minute of the two.
    try testing.expectEqual(@as(?i64, 6000), stats.last_drop_minute);
    // The merged cell is back, carrying what the failed flush was holding.
    const merged = acc.cellFor("https://a.example", 60);
    try testing.expectEqual(@as(u32, 1), merged.successes);
    try testing.expectEqual(@as(u32, 1), merged.failures);
    try testing.expectEqualStrings("Timeout", merged.lastError());

    // The flush-owned buffer was not touched by any of the recording that
    // happened beside it: it still holds exactly what was swapped out.
    try testing.expectEqual(@as(u32, 1), acc.flush_count);
    try testing.expectEqual(@as(u32, 1), acc.flush_cells[0].successes);
    try testing.expectEqual(@as(u32, 1), acc.flush_cells[0].failures);
    try testing.expectEqual(@as(i64, 60), acc.flush_cells[0].minute_ts);
}

test "a flush against the real repository writes the minute rows" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var database = try openLog();
    defer database.close();

    const acc = try newAccumulator();
    defer testing.allocator.destroy(acc);

    acc.recordSuccess(io, "https://a.example", 65);
    acc.recordFailure(io, "https://a.example", 100, "Timeout");
    acc.recordSuccess(io, "https://b.example", 130);
    acc.flushOnce(io, &database, upstream_history_repo.flush);

    // The second pass is the restart shape: the same minute, added to.
    acc.recordSuccess(io, "https://a.example", 90);
    acc.flushOnce(io, &database, upstream_history_repo.flush);

    const stats = try upstream_history_repo.windowStats(&database, "https://a.example", 0, 200);
    try testing.expectEqual(@as(u64, 2), stats.successes);
    try testing.expectEqual(@as(u64, 1), stats.failures);
    try testing.expectEqual(@as(u64, 3), stats.attempts);
    try testing.expectEqual(@as(?i64, 100), stats.last_failure_ts);
    try testing.expectEqualStrings("Timeout", stats.lastFailureError());
    try testing.expectEqual(@as(i64, 2), try upstream_history_repo.countMinutes(&database));
    try testing.expectEqual(@as(u64, 2), acc.snapshotStats(io).flushes);
}

test "a failed flush opens one episode and the next successful flush closes it" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var database = try openLog();
    defer database.close();

    var fx: events_fixture.Fixture = .{};
    try fx.init(io, 1000);
    defer fx.deinit();

    const acc = try newAccumulator();
    defer testing.allocator.destroy(acc);
    acc.diagnostics = &fx.store;

    acc.recordFailure(io, "https://a.example", 1_700_000_000, "Timeout");
    acc.flushOnce(io, &database, failingWrite);

    try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
    try testing.expectEqualStrings("upstream_history.write", try fx.text("SELECT code FROM operational_events"));
    try testing.expectEqualStrings("flush", try fx.text("SELECT subject_key FROM operational_events"));
    try testing.expectEqualStrings("warning", try fx.text("SELECT severity FROM operational_events"));
    try testing.expectEqual(
        @as(i64, 1),
        try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
    );

    acc.flushOnce(io, &database, upstream_history_repo.flush);
    try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
    try testing.expectEqual(
        @as(i64, 0),
        try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
    );
}

test "a flush with no store attached records nothing and still flushes" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var database = try openLog();
    defer database.close();

    const acc = try newAccumulator();
    defer testing.allocator.destroy(acc);

    acc.recordFailure(io, "https://a.example", 1_700_000_000, "Timeout");
    acc.flushOnce(io, &database, failingWrite);
    acc.flushOnce(io, &database, upstream_history_repo.flush);
    try testing.expectEqual(@as(u64, 1), acc.counters.flushes.load(.monotonic));
}
