//! What every by-id mutation in this directory shares.
//!
//! `UPDATE ... WHERE id = ?1` and `DELETE ... WHERE id = ?1` are silent about a
//! row that is not there: SQLite reports success and touches nothing. The REST
//! layer must answer 404 instead, so every mutation runs its statement through
//! `execStrict`, which turns "touched no row" into `error.NotFound`.
//!
//! `error.Constraint` needs no helper — `Stmt.exec` already reports it, and the
//! handler layer maps it to 409. Each mutation documents which constraint of
//! `config_schema.ddl_v1` can fire.
//!
//! `listRows` and `freeRows` are the read half. Every `list*` function in this
//! directory reads rows into a `std.ArrayList` under the same unwind rules, and
//! the errdefer ordering those rules need is easy to write backwards. It is
//! written once here.

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

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

/// Runs a statement that must touch exactly one row.
///
/// `Db.changes` counts the rows the *last completed* statement wrote, so it
/// must be read immediately after `exec`. SQLite counts a row an `UPDATE`
/// rewrote with identical values, so a no-op edit is not mistaken for a missing
/// row.
pub fn execStrict(database: *db.Db, stmt: *db.Stmt) db.Error!void {
    try stmt.exec();
    if (database.changes() == 0) return error.NotFound;
}

/// Reads every row `sql` produces into a list, with the memory-safety
/// choreography every `list*` function in this directory shares.
///
/// `readRow` allocates the row's owning fields from `gpa` and carries its own
/// per-column `errdefer`s, so a row that fails halfway releases the columns it
/// already read. This function owns everything around that: a failure after the
/// first append releases the rows already in the list and then the list itself.
///
/// The result is the caller's: free the rows with `freeRows` (or the repository
/// shim over it) and then `deinit` the list.
pub fn listRows(
    comptime Row: type,
    database: *db.Db,
    gpa: Allocator,
    comptime sql: []const u8,
    comptime readRow: fn (*db.Stmt, Allocator) db.Error!Row,
) db.Error!std.ArrayList(Row) {
    return listRowsBound(Row, database, gpa, sql, readRow, .{});
}

/// `listRows` for a statement with parameters. `args` is a tuple bound to
/// positions 1..n in order.
pub fn listRowsBound(
    comptime Row: type,
    database: *db.Db,
    gpa: Allocator,
    comptime sql: []const u8,
    comptime readRow: fn (*db.Stmt, Allocator) db.Error!Row,
    args: anytype,
) db.Error!std.ArrayList(Row) {
    var stmt = try database.prepare(sql);
    defer stmt.deinit();
    inline for (args, 0..) |arg, i| try bindArg(&stmt, i + 1, arg);

    var out: std.ArrayList(Row) = .empty;
    // Order matters: `errdefer`s run in reverse, so the free pass is declared
    // *after* `deinit` to run *before* it. The other order reads `out.items`
    // after the backing array is gone.
    errdefer out.deinit(gpa);
    errdefer freeRows(Row, gpa, out.items);

    while (try stmt.step()) {
        const row = try readRow(&stmt, gpa);
        errdefer freeRow(Row, gpa, row);
        try out.append(gpa, row);
    }
    return out;
}

fn bindArg(stmt: *db.Stmt, idx: c_int, arg: anytype) db.Error!void {
    const Arg = @TypeOf(arg);
    if (Arg == []const u8 or Arg == []u8) return stmt.bindText(idx, arg);
    return switch (@typeInfo(Arg)) {
        .bool => stmt.bindInt(idx, @intFromBool(arg)),
        .int, .comptime_int => stmt.bindInt(idx, arg),
        else => @compileError("crud.listRowsBound: cannot bind a " ++ @typeName(Arg)),
    };
}

/// Releases every owning field of every row `listRows` produced.
pub fn freeRows(comptime Row: type, gpa: Allocator, items: []const Row) void {
    for (items) |item| freeRow(Row, gpa, item);
}

/// Releases the owning fields of one row.
///
/// A repository row owns its heap memory in exactly two shapes: `[]const u8`
/// and `?[]const u8` (`SourceRow.checksum` is the optional one — a shallow
/// slice-only reflection would leak its payload). Every other field must be a
/// plain value the row does not own. A field of any other shape is a
/// `@compileError`, so a row that grows a nested allocation cannot start
/// leaking silently: whoever adds it has to teach this function first.
pub fn freeRow(comptime Row: type, gpa: Allocator, row: Row) void {
    switch (@typeInfo(Row)) {
        .@"struct" => |info| inline for (info.fields) |field| {
            freeField(field.type, @typeName(Row) ++ "." ++ field.name, gpa, @field(row, field.name));
        },
        else => freeField(Row, @typeName(Row), gpa, row),
    }
}

fn freeField(comptime Field: type, comptime where: []const u8, gpa: Allocator, value: Field) void {
    if (Field == []const u8 or Field == []u8) return gpa.free(value);
    if (Field == ?[]const u8 or Field == ?[]u8) return if (value) |owned| gpa.free(owned);
    comptime assertUnowning(Field, where);
}

fn assertUnowning(comptime Field: type, comptime where: []const u8) void {
    switch (@typeInfo(Field)) {
        .bool, .int, .float, .@"enum", .void => {},
        .optional => |info| assertUnowning(info.child, where),
        else => @compileError("crud.freeRow: " ++ where ++ " is a " ++ @typeName(Field) ++
            ", which is neither a plain value nor an owning slice; teach freeRow how to release it"),
    }
}

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

const testing = std.testing;

fn openTable() !db.Db {
    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    errdefer database.close();
    try db.applyPragmas(&database, .{});
    _ = try migrations.migrate(&database);
    try database.exec("INSERT INTO forward_zones (id, zone, resolver) VALUES (1, 'home.arpa', 'udp://10.0.0.1:53');");
    return database;
}

test "execStrict passes an update that touches a row" {
    var database = try openTable();
    defer database.close();

    var stmt = try database.prepare("UPDATE forward_zones SET resolver = ?2 WHERE id = ?1");
    defer stmt.deinit();
    try stmt.bindInt(1, 1);
    try stmt.bindText(2, "udp://10.0.0.2:53");
    try execStrict(&database, &stmt);
}

test "execStrict passes an update that rewrites the same value" {
    var database = try openTable();
    defer database.close();

    var stmt = try database.prepare("UPDATE forward_zones SET resolver = ?2 WHERE id = ?1");
    defer stmt.deinit();
    try stmt.bindInt(1, 1);
    try stmt.bindText(2, "udp://10.0.0.1:53");
    try execStrict(&database, &stmt);
}

test "execStrict reports NotFound for an id no row holds" {
    var database = try openTable();
    defer database.close();

    var update = try database.prepare("UPDATE forward_zones SET resolver = 'x' WHERE id = ?1");
    defer update.deinit();
    try update.bindInt(1, 404);
    try testing.expectError(error.NotFound, execStrict(&database, &update));

    var delete = try database.prepare("DELETE FROM forward_zones WHERE id = ?1");
    defer delete.deinit();
    try delete.bindInt(1, 404);
    try testing.expectError(error.NotFound, execStrict(&database, &delete));

    try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM forward_zones"));
}

/// Carries both owning shapes a repository row may hold: a `[]const u8` that is
/// always there, and the `?[]const u8` of `SourceRow.checksum`.
const TestRow = struct {
    id: i64,
    url: []const u8,
    checksum: ?[]const u8,
};

const test_rows_sql = "SELECT id, url, checksum FROM blocklist_sources ORDER BY id";

fn readTestRow(stmt: *db.Stmt, gpa: Allocator) db.Error!TestRow {
    const url = try stmt.columnTextAlloc(gpa, 1);
    errdefer gpa.free(url);
    const checksum = try stmt.columnTextAllocOrNull(gpa, 2);
    errdefer if (checksum) |value| gpa.free(value);
    return .{ .id = stmt.columnInt(0), .url = url, .checksum = checksum };
}

/// Two rows: the first carries a checksum, the second leaves it NULL, so one
/// read exercises both arms of the optional.
fn seedTestRows(database: *db.Db) !void {
    try database.exec(
        \\INSERT INTO blocklist_sources (id, url, name, checksum) VALUES
        \\  (1, 'https://a.example/list.txt', 'A', 'aaaa'),
        \\  (2, 'https://b.example/list.txt', 'B', NULL);
    );
}

test "listRows reads every row and freeRows releases both owning shapes" {
    var database = try openTable();
    defer database.close();
    try seedTestRows(&database);

    var rows = try listRows(TestRow, &database, testing.allocator, test_rows_sql, readTestRow);
    defer rows.deinit(testing.allocator);
    defer freeRows(TestRow, testing.allocator, rows.items);

    try testing.expectEqual(@as(usize, 2), rows.items.len);
    try testing.expectEqualStrings("https://a.example/list.txt", rows.items[0].url);
    // The leak detector is what proves this payload is released.
    try testing.expectEqualStrings("aaaa", rows.items[0].checksum.?);
    try testing.expectEqual(@as(?[]const u8, null), rows.items[1].checksum);
}

fn listRowsUnderFailure(gpa: Allocator) !void {
    var database = try openTable();
    defer database.close();
    try seedTestRows(&database);

    var rows = try listRows(TestRow, &database, gpa, test_rows_sql, readTestRow);
    defer rows.deinit(gpa);
    defer freeRows(TestRow, gpa, rows.items);

    // A row with a non-null checksum must be in the result, or the failure
    // injection never reaches the optional's allocation.
    try testing.expect(rows.items[0].checksum != null);
}

test "listRows is leak-safe under allocation failure" {
    try testing.checkAllAllocationFailures(testing.allocator, listRowsUnderFailure, .{});
}

test "listRowsBound binds its arguments in tuple order" {
    var database = try openTable();
    defer database.close();
    try seedTestRows(&database);

    var rows = try listRowsBound(
        TestRow,
        &database,
        testing.allocator,
        "SELECT id, url, checksum FROM blocklist_sources WHERE id > ?1 AND name = ?2",
        readTestRow,
        .{ @as(i64, 1), @as([]const u8, "B") },
    );
    defer rows.deinit(testing.allocator);
    defer freeRows(TestRow, testing.allocator, rows.items);

    try testing.expectEqual(@as(usize, 1), rows.items.len);
    try testing.expectEqual(@as(i64, 2), rows.items[0].id);
}

test "freeRows over a row type with no owning field is a no-op" {
    // `listGroupSourceIds` reads a bare `i64`; the reflection must accept a Row
    // that is not a struct at all.
    freeRows(i64, testing.allocator, &.{ 1, 2, 3 });
}
