//! `upstream_minute` and its `upstream_targets` dimension table in
//! `querylog.db` (milestone-26 rulings 2, 4, 5).
//!
//! One row per upstream per wall-clock UTC minute that had at least one
//! attempt. Rows are additive facts: a flush adds to whatever is already there,
//! so a restart inside a minute continues that minute's row rather than
//! replacing it, and nothing here can lower a stored count.
//!
//! Identity is the url, not the `config.db` upstream id: ids cannot be foreign
//! keys across database files and may be deleted or reused. Editing an
//! upstream's url deliberately starts a new history.
//!
//! Nothing here retries. The accumulator owns what a failed flush means
//! (`upstream/history.zig`).

const std = @import("std");

const db = @import("../db.zig");
const health = @import("../../upstream/health.zig");

/// How far back `Retention` keeps minute rows. A fixed window, not a knob
/// (m26 anti-requirements), and deliberately wider than the widest dashboard
/// period: `stats.window` derives `since = until - width * count` with
/// `until > now`, so a 30-day window never asks for anything older than
/// `now - 30d`. The extra day is slack for a retention pass that runs late.
///
/// `logging.retention_days` does not apply here. It bounds the query log, whose
/// rows are per-query; these are per-minute aggregates whose whole purpose is
/// to outlive them.
pub const retention_window_s: i64 = 31 * 86_400;

/// One minute of one upstream's outcomes, as the accumulator hands it over.
/// Every string is borrowed for the duration of the call: `Stmt.bindText` binds
/// with `SQLITE_TRANSIENT`, so SQLite copies before `flush` returns.
pub const FlushRow = struct {
    url: []const u8,
    minute_ts: i64,
    successes: u32,
    failures: u32,
    last_failure_ts: ?i64,
    /// Empty when the minute held no failure.
    last_error: []const u8,
};

const insert_target_sql = "INSERT OR IGNORE INTO upstream_targets (url) VALUES (?1)";

const select_target_sql = "SELECT id FROM upstream_targets WHERE url = ?1";

/// Additive, and the timestamp columns are max-wins, which is what makes a
/// flush safe to repeat against a row another process already wrote.
///
/// `max()` over a NULL is NULL in SQLite, so the coalesce is what keeps an
/// existing `last_failure_ts` when the incoming row carries none. The `CASE`
/// moves `last_error` with the timestamp it belongs to: a success-only upsert
/// leaves the stored failure and its name exactly as they were.
const upsert_minute_sql =
    \\INSERT INTO upstream_minute (upstream_id, minute_ts, successes, failures, last_failure_ts, last_error)
    \\VALUES (?1, ?2, ?3, ?4, ?5, ?6)
    \\ON CONFLICT(upstream_id, minute_ts) DO UPDATE SET
    \\    successes = successes + excluded.successes,
    \\    failures = failures + excluded.failures,
    \\    last_failure_ts = coalesce(max(last_failure_ts, excluded.last_failure_ts), last_failure_ts, excluded.last_failure_ts),
    \\    last_error = CASE
    \\        WHEN excluded.last_failure_ts IS NOT NULL
    \\         AND (last_failure_ts IS NULL OR excluded.last_failure_ts >= last_failure_ts)
    \\        THEN excluded.last_error
    \\        ELSE last_error
    \\    END
;

/// One transaction for the whole batch: either every minute of the pass lands
/// or none of it does, so a failed flush leaves nothing half-written for the
/// caller's merge-back to double-count.
pub fn flush(database: *db.Db, rows: []const FlushRow) db.Error!void {
    if (rows.len == 0) return;

    var insert_target = try database.prepare(insert_target_sql);
    defer insert_target.deinit();
    var select_target = try database.prepare(select_target_sql);
    defer select_target.deinit();
    var upsert = try database.prepare(upsert_minute_sql);
    defer upsert.deinit();

    var tx = try db.Tx.begin(database);
    errdefer tx.rollback();

    for (rows) |row| {
        const upstream_id = try internTarget(&insert_target, &select_target, row.url);

        try upsert.reset();
        try upsert.bindInt(1, upstream_id);
        try upsert.bindInt(2, row.minute_ts);
        try upsert.bindInt(3, row.successes);
        try upsert.bindInt(4, row.failures);
        if (row.last_failure_ts) |at| try upsert.bindInt(5, at) else try upsert.bindNull(5);
        try upsert.bindText(6, row.last_error);
        try upsert.exec();
    }

    try tx.commit();
}

fn internTarget(insert: *db.Stmt, select: *db.Stmt, url: []const u8) db.Error!i64 {
    try insert.reset();
    try insert.bindText(1, url);
    try insert.exec();

    try select.reset();
    try select.bindText(1, url);
    // The insert above either created the row or found it already there, so a
    // miss means the table changed under this connection.
    if (!try select.step()) return error.NotFound;
    const id = select.columnInt(0);
    // A statement stopped on a row keeps its cursor open until it is reset;
    // the transaction must not carry that to the next row.
    try select.reset();
    return id;
}

/// What `GET /api/upstream/health` reports for one upstream over one window.
pub const WindowStats = struct {
    attempts: u64,
    successes: u64,
    failures: u64,
    last_failure_ts: ?i64,
    /// The error name of the row holding the newest `last_failure_ts` in the
    /// window; empty when the window holds no failure.
    ///
    /// By value rather than by slice: the caller loops over upstreams and
    /// reuses one `WindowStats`, so a borrowed slice would dangle into the
    /// storage the next iteration overwrites.
    last_failure_error_buf: [health.error_name_capacity]u8,
    last_failure_error_len: u8,

    pub fn lastFailureError(self: *const WindowStats) []const u8 {
        return self.last_failure_error_buf[0..self.last_failure_error_len];
    }
};

/// **One statement, deliberately.** Two statements would not share a SQLite
/// snapshot: the flush connection can commit between them, and the read would
/// then pair a `max(last_failure_ts)` taken from one state with an error text
/// taken from another.
///
/// The error lookup is a scalar subquery for the same reason it is not a bare
/// column: `SELECT max(last_failure_ts), last_error` lets SQLite return the
/// `last_error` of an arbitrary row of the group. `ORDER BY ... DESC, minute_ts
/// DESC` makes the choice deterministic when two minutes share a timestamp.
const window_stats_sql =
    \\SELECT coalesce(sum(m.successes), 0), coalesce(sum(m.failures), 0), max(m.last_failure_ts),
    \\       (SELECT e.last_error FROM upstream_minute e
    \\        WHERE e.upstream_id = m.upstream_id AND e.minute_ts >= ?2 AND e.minute_ts < ?3
    \\          AND e.last_failure_ts IS NOT NULL
    \\        ORDER BY e.last_failure_ts DESC, e.minute_ts DESC LIMIT 1)
    \\  FROM upstream_minute m JOIN upstream_targets t ON t.id = m.upstream_id
    \\ WHERE t.url = ?1 AND m.minute_ts >= ?2 AND m.minute_ts < ?3
;

/// Aggregates `[since, until)` by `minute_ts`. An unknown url or an empty
/// window is zeros, a null timestamp and the empty error — not an error.
pub fn windowStats(database: *db.Db, url: []const u8, since: i64, until: i64) db.Error!WindowStats {
    var stmt = try database.prepare(window_stats_sql);
    defer stmt.deinit();
    try stmt.bindText(1, url);
    try stmt.bindInt(2, since);
    try stmt.bindInt(3, until);

    // A bare aggregate always produces exactly one row; no row means the
    // statement is not the one this function prepared.
    if (!try stmt.step()) return error.Misuse;

    const successes = try countOf(stmt.columnInt(0));
    const failures = try countOf(stmt.columnInt(1));

    var out: WindowStats = .{
        .attempts = successes + failures,
        .successes = successes,
        .failures = failures,
        .last_failure_ts = if (stmt.isNull(2)) null else stmt.columnInt(2),
        .last_failure_error_buf = @splat(0),
        .last_failure_error_len = 0,
    };
    const name = stmt.columnText(3);
    const copied = @min(name.len, out.last_failure_error_buf.len);
    @memcpy(out.last_failure_error_buf[0..copied], name[0..copied]);
    out.last_failure_error_len = @intCast(copied);
    return out;
}

/// `sum` over `CHECK (… >= 0)` columns cannot go negative; a negative value
/// means the row came from something other than this schema.
fn countOf(value: i64) db.Error!u64 {
    if (value < 0) return error.Mismatch;
    return @intCast(value);
}

/// Deletes every `upstream_minute` row strictly older than `cutoff_ts`, then
/// the targets no surviving row references, and returns how many **minute**
/// rows went.
///
/// One transaction: a target dropped without its rows, or rows dropped while
/// the target delete failed, would leave the foreign key pointing at nothing.
/// The count is minute rows only, so the metric an operator watches counts
/// aggregates rather than dimension-table housekeeping.
pub fn pruneOlderThan(database: *db.Db, cutoff_ts: i64) db.Error!i64 {
    var tx = try db.Tx.begin(database);
    errdefer tx.rollback();

    var minutes = try database.prepare("DELETE FROM upstream_minute WHERE minute_ts < ?1");
    defer minutes.deinit();
    try minutes.bindInt(1, cutoff_ts);
    try minutes.exec();
    const deleted = database.changes();

    try database.exec(
        \\DELETE FROM upstream_targets
        \\ WHERE id NOT IN (SELECT upstream_id FROM upstream_minute);
    );

    try tx.commit();
    return deleted;
}

pub fn countMinutes(database: *db.Db) db.Error!i64 {
    return database.queryInt("SELECT count(*) FROM upstream_minute");
}

pub fn countTargets(database: *db.Db) db.Error!i64 {
    return database.queryInt("SELECT count(*) FROM upstream_targets");
}

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

const querylog_schema = @import("../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;
}

/// `columnText` is borrowed until the statement is finalized, so the stored
/// error name is copied out rather than returned as a slice.
const StoredMinute = struct {
    successes: u32,
    failures: u32,
    last_failure_ts: ?i64,
    error_buf: [health.error_name_capacity]u8,
    error_len: u8,

    fn lastError(self: *const StoredMinute) []const u8 {
        return self.error_buf[0..self.error_len];
    }
};

fn readMinute(database: *db.Db, url: []const u8, minute_ts: i64) !StoredMinute {
    var stmt = try database.prepare(
        \\SELECT m.successes, m.failures, m.last_failure_ts, m.last_error
        \\  FROM upstream_minute m JOIN upstream_targets t ON t.id = m.upstream_id
        \\ WHERE t.url = ?1 AND m.minute_ts = ?2
    );
    defer stmt.deinit();
    try stmt.bindText(1, url);
    try stmt.bindInt(2, minute_ts);
    try testing.expect(try stmt.step());
    var out: StoredMinute = .{
        .successes = @intCast(stmt.columnInt(0)),
        .failures = @intCast(stmt.columnInt(1)),
        .last_failure_ts = if (stmt.isNull(2)) null else stmt.columnInt(2),
        .error_buf = @splat(0),
        .error_len = 0,
    };
    const name = stmt.columnText(3);
    @memcpy(out.error_buf[0..name.len], name);
    out.error_len = @intCast(name.len);
    return out;
}

test "the upsert adds to the row already there rather than replacing it" {
    var database = try openLog();
    defer database.close();

    try flush(&database, &.{
        .{ .url = "https://a.example", .minute_ts = 60, .successes = 3, .failures = 1, .last_failure_ts = 70, .last_error = "Timeout" },
    });
    // The shape a restart inside one minute takes: a second process writes the
    // same (url, minute) and the counts continue.
    try flush(&database, &.{
        .{ .url = "https://a.example", .minute_ts = 60, .successes = 2, .failures = 4, .last_failure_ts = 90, .last_error = "ConnectFailed" },
    });

    const row = try readMinute(&database, "https://a.example", 60);
    try testing.expectEqual(@as(u32, 5), row.successes);
    try testing.expectEqual(@as(u32, 5), row.failures);
    try testing.expectEqual(@as(?i64, 90), row.last_failure_ts);
    try testing.expectEqualStrings("ConnectFailed", row.lastError());
    // One row and one target, not two of either.
    try testing.expectEqual(@as(i64, 1), try countMinutes(&database));
    try testing.expectEqual(@as(i64, 1), try countTargets(&database));
}

test "a success-only upsert keeps the failure timestamp and its error" {
    var database = try openLog();
    defer database.close();

    try flush(&database, &.{
        .{ .url = "https://a.example", .minute_ts = 60, .successes = 0, .failures = 1, .last_failure_ts = 70, .last_error = "Timeout" },
    });
    // `max()` over a NULL is NULL in SQLite, so without the coalesce this
    // upsert would erase the timestamp it knows nothing about.
    try flush(&database, &.{
        .{ .url = "https://a.example", .minute_ts = 60, .successes = 5, .failures = 0, .last_failure_ts = null, .last_error = "" },
    });

    const row = try readMinute(&database, "https://a.example", 60);
    try testing.expectEqual(@as(u32, 5), row.successes);
    try testing.expectEqual(@as(u32, 1), row.failures);
    try testing.expectEqual(@as(?i64, 70), row.last_failure_ts);
    try testing.expectEqualStrings("Timeout", row.lastError());
}

test "an older failure does not overwrite the newer error already stored" {
    var database = try openLog();
    defer database.close();

    try flush(&database, &.{
        .{ .url = "https://a.example", .minute_ts = 60, .successes = 0, .failures = 1, .last_failure_ts = 90, .last_error = "Timeout" },
    });
    try flush(&database, &.{
        .{ .url = "https://a.example", .minute_ts = 60, .successes = 0, .failures = 1, .last_failure_ts = 70, .last_error = "ConnectFailed" },
    });

    const row = try readMinute(&database, "https://a.example", 60);
    try testing.expectEqual(@as(?i64, 90), row.last_failure_ts);
    try testing.expectEqualStrings("Timeout", row.lastError());
}

test "flush interns each url once and writes every minute of the batch" {
    var database = try openLog();
    defer database.close();

    try flush(&database, &.{
        .{ .url = "https://a.example", .minute_ts = 60, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
        .{ .url = "https://a.example", .minute_ts = 120, .successes = 2, .failures = 0, .last_failure_ts = null, .last_error = "" },
        .{ .url = "https://b.example", .minute_ts = 60, .successes = 3, .failures = 0, .last_failure_ts = null, .last_error = "" },
    });

    try testing.expectEqual(@as(i64, 3), try countMinutes(&database));
    try testing.expectEqual(@as(i64, 2), try countTargets(&database));

    // An empty batch opens no transaction: one is already open here, so a
    // `BEGIN IMMEDIATE` would fail.
    var tx = try db.Tx.begin(&database);
    try flush(&database, &.{});
    tx.rollback();
}

test "windowStats sums only the window and pairs the newest failure with its own error" {
    var database = try openLog();
    defer database.close();

    try flush(&database, &.{
        // Before the window.
        .{ .url = "https://a.example", .minute_ts = 0, .successes = 9, .failures = 9, .last_failure_ts = 30, .last_error = "Outside" },
        .{ .url = "https://a.example", .minute_ts = 60, .successes = 2, .failures = 1, .last_failure_ts = 100, .last_error = "ConnectFailed" },
        .{ .url = "https://a.example", .minute_ts = 120, .successes = 4, .failures = 2, .last_failure_ts = 170, .last_error = "Timeout" },
        // A later minute with no failure at all: the error must still come from
        // the minute holding the newest `last_failure_ts`, not from this one.
        .{ .url = "https://a.example", .minute_ts = 180, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
        // At the exclusive end of the window.
        .{ .url = "https://a.example", .minute_ts = 240, .successes = 7, .failures = 7, .last_failure_ts = 250, .last_error = "After" },
        // A different upstream in the same minutes.
        .{ .url = "https://b.example", .minute_ts = 120, .successes = 5, .failures = 5, .last_failure_ts = 175, .last_error = "Other" },
    });

    const stats = try windowStats(&database, "https://a.example", 60, 240);
    try testing.expectEqual(@as(u64, 7), stats.successes);
    try testing.expectEqual(@as(u64, 3), stats.failures);
    try testing.expectEqual(@as(u64, 10), stats.attempts);
    try testing.expectEqual(@as(?i64, 170), stats.last_failure_ts);
    try testing.expectEqualStrings("Timeout", stats.lastFailureError());
}

test "two minutes sharing the newest failure timestamp resolve to the later minute" {
    // The tiebreak the subquery's ORDER BY owns. `max(last_failure_ts)` alone
    // cannot choose between these two rows, so without a deterministic second
    // key the answer is whichever row SQLite happened to visit.
    var database = try openLog();
    defer database.close();
    try flush(&database, &.{
        .{ .url = "https://a.example", .minute_ts = 60, .successes = 0, .failures = 1, .last_failure_ts = 100, .last_error = "Earlier" },
        .{ .url = "https://a.example", .minute_ts = 120, .successes = 0, .failures = 1, .last_failure_ts = 100, .last_error = "Later" },
    });

    const stats = try windowStats(&database, "https://a.example", 0, 1000);
    try testing.expectEqual(@as(?i64, 100), stats.last_failure_ts);
    try testing.expectEqualStrings("Later", stats.lastFailureError());
}

test "windowStats over an unknown url or an empty window is zeros and no error" {
    var database = try openLog();
    defer database.close();
    try flush(&database, &.{
        .{ .url = "https://a.example", .minute_ts = 60, .successes = 1, .failures = 1, .last_failure_ts = 70, .last_error = "Timeout" },
    });

    for ([_][3]i64{ .{ 0, 60, 0 }, .{ 120, 180, 0 }, .{ 60, 60, 0 } }) |window| {
        const stats = try windowStats(&database, "https://a.example", window[0], window[1]);
        try testing.expectEqual(@as(u64, 0), stats.attempts);
        try testing.expectEqual(@as(u64, 0), stats.successes);
        try testing.expectEqual(@as(u64, 0), stats.failures);
        try testing.expectEqual(@as(?i64, null), stats.last_failure_ts);
        try testing.expectEqualStrings("", stats.lastFailureError());
    }

    const unknown = try windowStats(&database, "https://never.example", 0, 1000);
    try testing.expectEqual(@as(u64, 0), unknown.attempts);
    try testing.expectEqual(@as(?i64, null), unknown.last_failure_ts);
    try testing.expectEqualStrings("", unknown.lastFailureError());
}

test "a window whose only failures are outside it reports no failure at all" {
    var database = try openLog();
    defer database.close();
    try flush(&database, &.{
        .{ .url = "https://a.example", .minute_ts = 0, .successes = 0, .failures = 1, .last_failure_ts = 30, .last_error = "Timeout" },
        .{ .url = "https://a.example", .minute_ts = 60, .successes = 4, .failures = 0, .last_failure_ts = null, .last_error = "" },
    });

    const stats = try windowStats(&database, "https://a.example", 60, 120);
    try testing.expectEqual(@as(u64, 4), stats.attempts);
    try testing.expectEqual(@as(?i64, null), stats.last_failure_ts);
    try testing.expectEqualStrings("", stats.lastFailureError());
}

test "pruneOlderThan counts minute rows only and drops the orphaned target" {
    var database = try openLog();
    defer database.close();

    try flush(&database, &.{
        .{ .url = "https://old.example", .minute_ts = 60, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
        .{ .url = "https://old.example", .minute_ts = 120, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
        .{ .url = "https://kept.example", .minute_ts = 120, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
        .{ .url = "https://kept.example", .minute_ts = 300, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
    });

    // Three minute rows go; the two target deletes must not join the count.
    try testing.expectEqual(@as(i64, 3), try pruneOlderThan(&database, 300));
    try testing.expectEqual(@as(i64, 1), try countMinutes(&database));
    // "old.example" has nothing left, "kept.example" still does.
    try testing.expectEqual(@as(i64, 1), try countTargets(&database));

    // The row exactly at the cutoff stays, and a second pass finds nothing.
    try testing.expectEqual(@as(i64, 0), try pruneOlderThan(&database, 300));
    try testing.expectEqual(@as(i64, 1), try countMinutes(&database));
}

test "a failed prune leaves both tables as they were" {
    var database = try openLog();
    defer database.close();
    try flush(&database, &.{
        .{ .url = "https://a.example", .minute_ts = 60, .successes = 1, .failures = 0, .last_failure_ts = null, .last_error = "" },
    });
    try database.exec(
        \\CREATE TRIGGER refuse_target_delete BEFORE DELETE ON upstream_targets
        \\BEGIN SELECT RAISE(ABORT, 'refused'); END;
    );

    // The minute delete succeeds and the target delete does not; one
    // transaction means neither survives.
    try testing.expectError(error.Constraint, pruneOlderThan(&database, 300));
    try testing.expectEqual(@as(i64, 1), try countMinutes(&database));
    try testing.expectEqual(@as(i64, 1), try countTargets(&database));
}

test "an error name longer than the buffer is truncated, not overflowed" {
    var database = try openLog();
    defer database.close();
    const long = "A" ** 200;
    try flush(&database, &.{
        .{ .url = "https://a.example", .minute_ts = 60, .successes = 0, .failures = 1, .last_failure_ts = 70, .last_error = long },
    });

    const stats = try windowStats(&database, "https://a.example", 60, 120);
    try testing.expectEqual(@as(usize, health.error_name_capacity), stats.lastFailureError().len);
    try testing.expectEqualStrings(long[0..health.error_name_capacity], stats.lastFailureError());
}
