//! The whole SQLite surface nxdns owns (PLAN Decision G). Nothing above this
//! file calls SQLite directly.
//!
//! **SQLite's own file I/O takes no `std.Io`.** It is the one deliberate
//! exception to Decision E. SQLite performs its own file I/O through its VFS;
//! routing it through `std.Io` would mean writing a custom SQLite VFS — a large,
//! security-sensitive component bought for nothing at household scale. Every
//! other storage file that touches the filesystem takes `io: std.Io`.
//!
//! The exception covers SQLite, not nxdns. The one place this file does its own
//! filesystem calls — the probes guarding `OpenMode.immutable`, at the open and
//! again at `Db.verifyImmutable` — follows the ordinary rule and takes an
//! `io: std.Io`, which is why that mode carries one.
//!
//! The C API is declared by hand below. No `@cImport` — the handles stay
//! opaque, matching `src/platform/tls_server.zig`'s Mbed TLS approach.

const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;

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

pub const c = struct {
    pub const Sqlite3 = opaque {};
    pub const Stmt = opaque {};
    /// The C prototype is a function pointer, but the only value nxdns passes
    /// is the `SQLITE_TRANSIENT` sentinel (-1), which is not a valid function
    /// address — a Zig fn-pointer type would reject it on targets with aligned
    /// function pointers (aarch64). `?*anyopaque` is ABI-identical.
    pub const Destructor = ?*anyopaque;

    /// `SQLITE_TRANSIENT`: tells SQLite to copy the bound bytes immediately.
    pub const transient: Destructor = @ptrFromInt(@as(usize, @bitCast(@as(isize, -1))));

    pub extern fn sqlite3_open_v2(filename: [*:0]const u8, ppDb: *?*Sqlite3, flags: c_int, zVfs: ?[*:0]const u8) c_int;
    pub extern fn sqlite3_close_v2(db: ?*Sqlite3) c_int;
    pub extern fn sqlite3_extended_result_codes(db: *Sqlite3, onoff: c_int) c_int;
    pub extern fn sqlite3_busy_timeout(db: *Sqlite3, ms: c_int) c_int;
    pub extern fn sqlite3_exec(db: *Sqlite3, sql: [*:0]const u8, cb: ?*const anyopaque, arg: ?*anyopaque, errmsg: ?*?[*:0]u8) c_int;
    pub extern fn sqlite3_errmsg(db: *Sqlite3) [*:0]const u8;
    pub extern fn sqlite3_errcode(db: *Sqlite3) c_int;
    pub extern fn sqlite3_extended_errcode(db: *Sqlite3) c_int;
    pub extern fn sqlite3_errstr(code: c_int) [*:0]const u8;
    pub extern fn sqlite3_prepare_v2(db: *Sqlite3, sql: [*]const u8, n_byte: c_int, ppStmt: *?*c.Stmt, pzTail: ?*?[*]const u8) c_int;
    pub extern fn sqlite3_step(stmt: *c.Stmt) c_int;
    pub extern fn sqlite3_reset(stmt: *c.Stmt) c_int;
    pub extern fn sqlite3_clear_bindings(stmt: *c.Stmt) c_int;
    pub extern fn sqlite3_finalize(stmt: ?*c.Stmt) c_int;
    pub extern fn sqlite3_bind_int64(stmt: *c.Stmt, idx: c_int, value: i64) c_int;
    pub extern fn sqlite3_bind_text(stmt: *c.Stmt, idx: c_int, text: [*]const u8, n: c_int, d: Destructor) c_int;
    pub extern fn sqlite3_bind_null(stmt: *c.Stmt, idx: c_int) c_int;
    pub extern fn sqlite3_bind_parameter_count(stmt: *c.Stmt) c_int;
    pub extern fn sqlite3_column_count(stmt: *c.Stmt) c_int;
    pub extern fn sqlite3_column_type(stmt: *c.Stmt, col: c_int) c_int;
    pub extern fn sqlite3_column_int64(stmt: *c.Stmt, col: c_int) i64;
    pub extern fn sqlite3_column_double(stmt: *c.Stmt, col: c_int) f64;
    pub extern fn sqlite3_column_text(stmt: *c.Stmt, col: c_int) ?[*]const u8;
    pub extern fn sqlite3_column_bytes(stmt: *c.Stmt, col: c_int) c_int;
    pub extern fn sqlite3_set_authorizer(db: *Sqlite3, xAuth: ?*const AuthCallback, user_data: ?*anyopaque) c_int;
    pub extern fn sqlite3_get_autocommit(db: *Sqlite3) c_int;
    pub extern fn sqlite3_last_insert_rowid(db: *Sqlite3) i64;

    /// `int (*)(void*, int, const char*, const char*, const char*, const char*)`.
    /// The four name arguments are NULL for actions that do not use them, which
    /// `SQLITE_TRANSACTION` is except for its operation name.
    pub const AuthCallback = fn (
        user_data: ?*anyopaque,
        action: c_int,
        arg1: ?[*:0]const u8,
        arg2: ?[*:0]const u8,
        database: ?[*:0]const u8,
        trigger_or_view: ?[*:0]const u8,
    ) callconv(.c) c_int;
    pub extern fn sqlite3_changes(db: *Sqlite3) c_int;
    pub extern fn sqlite3_total_changes(db: *Sqlite3) c_int;
};

/// Result codes, from the vendored `sqlite3.h` (3.53.4).
pub const result = struct {
    pub const ok: c_int = 0;
    pub const err: c_int = 1;
    pub const internal: c_int = 2;
    pub const perm: c_int = 3;
    pub const abort: c_int = 4;
    pub const busy: c_int = 5;
    pub const locked: c_int = 6;
    pub const nomem: c_int = 7;
    pub const readonly: c_int = 8;
    pub const interrupt: c_int = 9;
    pub const ioerr: c_int = 10;
    pub const corrupt: c_int = 11;
    pub const notfound: c_int = 12;
    pub const full: c_int = 13;
    pub const cantopen: c_int = 14;
    pub const protocol: c_int = 15;
    pub const empty: c_int = 16;
    pub const schema: c_int = 17;
    pub const toobig: c_int = 18;
    pub const constraint: c_int = 19;
    pub const mismatch: c_int = 20;
    pub const misuse: c_int = 21;
    pub const nolfs: c_int = 22;
    pub const auth: c_int = 23;
    pub const format: c_int = 24;
    pub const range: c_int = 25;
    pub const notadb: c_int = 26;
    pub const row: c_int = 100;
    pub const done: c_int = 101;
};

/// Open flags, from the vendored `sqlite3.h` (3.53.4).
pub const open_flag = struct {
    pub const readonly: c_int = 0x1;
    pub const readwrite: c_int = 0x2;
    pub const create: c_int = 0x4;
    pub const uri: c_int = 0x40;
    pub const nomutex: c_int = 0x8000;
    pub const fullmutex: c_int = 0x10000;
    pub const exrescode: c_int = 0x2000000;
};

/// The authorizer verdicts and the one action code nxdns denies, from the
/// vendored `sqlite3.h` (3.53.4). `SQLITE_DENY` fails the *prepare* with
/// `SQLITE_AUTH`, which is what makes it a real guard: the statement never
/// runs at all.
pub const auth = struct {
    pub const deny: c_int = 1;
    pub const transaction: c_int = 22;
    /// `SAVEPOINT`, `RELEASE` and `ROLLBACK TO` report under this code, not
    /// under `transaction`. A savepoint at the outermost level opens a real
    /// transaction and `RELEASE` commits it, so a step using one splits the
    /// migration exactly as a bare `COMMIT` would.
    pub const savepoint: c_int = 32;
};

/// Column type codes returned by `sqlite3_column_type`.
pub const column_type = struct {
    pub const integer: c_int = 1;
    pub const float: c_int = 2;
    pub const text: c_int = 3;
    pub const blob: c_int = 4;
    pub const null_value: c_int = 5;
};

pub const Error = error{
    Abort,
    Auth,
    Busy,
    CantOpen,
    Constraint,
    Corrupt,
    Empty,
    Format,
    Full,
    Internal,
    Interrupt,
    IoErr,
    Locked,
    Mismatch,
    Misuse,
    NoLfs,
    NotADb,
    NotFound,
    Perm,
    Protocol,
    Range,
    ReadOnly,
    Schema,
    TooBig,
    SqliteError,
    OutOfMemory,
    Unexpected,
    /// Not a SQLite result code. An `OpenMode.immutable` read would have
    /// answered from a stale main file, because `<path>-wal` holds bytes or the
    /// files moved while the read ran. Raised by the open and again by
    /// `Db.verifyImmutable`. See `OpenMode.immutable`.
    WalPending,
};

/// Maps a primary SQLite result code to `Error`. `SQLITE_NOMEM` becomes
/// `error.OutOfMemory` so it joins `transport.LocalResource` semantics: out of
/// memory is never the data's fault.
///
/// The switch runs on the primary code (`code & 0xff`), so every extended code
/// (`SQLITE_IOERR_*`, `SQLITE_CONSTRAINT_*`, `SQLITE_BUSY_SNAPSHOT`, …) lands on
/// its family. The extended code stays visible to humans through `Db.lastError`.
///
/// `SQLITE_ERROR` — the generic "SQL error" — maps to `error.Unexpected`, not to
/// `error.SqliteError`. `SqliteError` is reserved for a primary code this
/// function does not know, so an unmapped future code stays distinguishable
/// from an ordinary SQL error.
pub fn mapCode(code: c_int) Error {
    const primary = code & 0xff;
    assert(primary != result.ok);
    assert(primary != result.row);
    assert(primary != result.done);
    return switch (primary) {
        result.err => error.Unexpected,
        result.internal => error.Internal,
        result.perm => error.Perm,
        result.abort => error.Abort,
        result.busy => error.Busy,
        result.locked => error.Locked,
        result.nomem => error.OutOfMemory,
        result.readonly => error.ReadOnly,
        result.interrupt => error.Interrupt,
        result.ioerr => error.IoErr,
        result.corrupt => error.Corrupt,
        result.notfound => error.NotFound,
        result.full => error.Full,
        result.cantopen => error.CantOpen,
        result.protocol => error.Protocol,
        result.empty => error.Empty,
        result.schema => error.Schema,
        result.toobig => error.TooBig,
        result.constraint => error.Constraint,
        result.mismatch => error.Mismatch,
        result.misuse => error.Misuse,
        result.nolfs => error.NoLfs,
        result.auth => error.Auth,
        result.format => error.Format,
        result.range => error.Range,
        result.notadb => error.NotADb,
        else => error.SqliteError,
    };
}

fn check(code: c_int) Error!void {
    if (code == result.ok) return;
    return mapCode(code);
}

pub const OpenMode = union(enum) {
    read_write_create,
    read_write_existing,
    read_only,
    memory,
    /// Read a database that this process promises not to change, and that no
    /// writer may be touching: `SQLITE_OPEN_READONLY` plus the `immutable=1` URI
    /// parameter, which makes the pager treat the file like a temp file — no
    /// locking, no rollback journal, no wal-index — so **no `-wal` and no `-shm`
    /// appear beside it**.
    ///
    /// This mode exists for `nxdns check` (milestone-13 ruling F-c), which must
    /// validate without writing. `.read_only` alone is not enough, and this is
    /// measured, not assumed: reading a database whose header says WAL makes
    /// SQLite build the wal-index, so `config.db-wal` (0 bytes) and
    /// `config.db-shm` (32 KiB) are created, and a read-only connection cannot
    /// remove them on close. A command that claims to write nothing must not
    /// leave two files behind. Do not "simplify" this back to `.read_only`.
    ///
    /// What `immutable=1` costs: SQLite then **ignores any `-wal` file**. The
    /// newest committed rows live there, so an immutable read of a database with
    /// an un-checkpointed WAL would answer from stale data and say nothing — a
    /// worse failure than the two sidecar files it removes. `Db.open` therefore
    /// refuses this mode with `error.WalPending` whenever `<path>-wal` exists and
    /// is not empty; the caller reports that as a failure and names `nxdns run`,
    /// which opens read-write and checkpoints, as the fix.
    ///
    /// The guard lives inside `open` rather than in a helper callers are trusted
    /// to call, because the failure it prevents is silent: a caller that forgets
    /// a helper gets a plausible wrong answer, and nothing anywhere reports it.
    ///
    /// **The open is half the guard.** `immutable=1` takes no lock, so nothing
    /// keeps a writer out for the duration of the read, and a check made only
    /// before the read can say only that the log was empty *then*.
    /// `Db.verifyImmutable` makes the other half, and a caller that grades what
    /// it read without calling it is back to the stale answer this mode exists
    /// to refuse.
    ///
    /// A zero-length `-wal` does not block the open: it holds no frames, so the
    /// main file is complete. That is the exact leftover the pre-F-c `check`
    /// used to create.
    ///
    /// The `std.Io` is for that probe — the one filesystem call nxdns itself
    /// makes in this file. Passing it is what makes the guard unskippable.
    immutable: std.Io,
};

pub const OpenOptions = struct {
    mode: OpenMode = .read_write_create,
    busy_timeout_ms: c_int = 5000,
};

/// SQLite's name for the write-ahead log beside `<path>`. Exported so a caller
/// reporting `error.WalPending` can name the file without hard-coding SQLite's
/// naming convention, which this file owns.
pub const wal_suffix = "-wal";

/// One SQLite connection.
///
/// A `Db` must not move once a `Stmt` prepared from it is alive: every `Stmt`
/// holds a `*Db`.
pub const Db = struct {
    handle: *c.Sqlite3,
    /// Set by `OpenMode.immutable` and null in every other mode: what the
    /// database file and its `-wal` looked like when the read began, for
    /// `verifyImmutable` to compare against when it ends.
    immutable_guard: ?ImmutableGuard = null,

    /// Every mode carries `FULLMUTEX` (serialized mode). The query logger and
    /// the web API handlers share one handle across `std.Io` tasks, and a
    /// per-handle mutex inside SQLite is cheaper to be correct about than a
    /// hand-rolled one; `config.db` write volume is negligible. `EXRESCODE`
    /// makes `sqlite3_extended_errcode` meaningful from the first call.
    ///
    /// `open` deliberately applies no pragmas — see `applyPragmas`, which the
    /// migration runner must call before it opens a transaction.
    ///
    /// For `.memory`, `path` is ignored and `":memory:"` is used.
    pub fn open(path: [:0]const u8, options: OpenOptions) Error!Db {
        const base = open_flag.exrescode | open_flag.fullmutex;
        const flags: c_int = switch (options.mode) {
            .read_write_create, .memory => base | open_flag.readwrite | open_flag.create,
            .read_write_existing => base | open_flag.readwrite,
            .read_only => base | open_flag.readonly,
            // Its own function: the URI buffer is 12 KiB, and every other open
            // in the process would carry it in this frame.
            .immutable => |io| return openImmutable(io, path, options.busy_timeout_ms),
        };
        const filename: [:0]const u8 = switch (options.mode) {
            .memory => ":memory:",
            else => path,
        };

        const h = try openHandle(filename, flags);
        return applyBusyTimeout(h, options.busy_timeout_ms);
    }

    /// Proves that the files an `OpenMode.immutable` read answered from stood
    /// still while it ran, and fails the read with `error.WalPending` when they
    /// did not. Call it after the last read and before anything read is
    /// reported.
    ///
    /// `immutable=1` takes no lock at all — that is what stops the pager
    /// building a wal-index, and the price is that nothing keeps a writer out.
    /// The probe at open time can only say the log was empty at that instant: a
    /// writer that appends one frame the instant after leaves the read answering
    /// from the older pages of the main file, silently, which is the whole
    /// failure `OpenMode.immutable`'s guard exists to prevent.
    ///
    /// Two things are compared, because a writer can hide in either:
    ///
    /// - `<path>-wal` holding bytes now. A log that appeared, one that grew, and
    ///   one written into the empty file the open accepted all land here.
    /// - `<path>` itself moving — size, inode, mtime or ctime. This is the
    ///   checkpoint the log cannot show: a writer that checkpointed into the main
    ///   file and truncated its log back to nothing leaves both stats saying "no
    ///   frames" while the pages the read saw have been replaced.
    ///
    /// Best effort, and honestly so: a filesystem with coarse timestamps can
    /// hide a rewrite that lands on the same byte count in the same tick. That
    /// cannot be fixed from outside SQLite's locking, and taking a lock is the
    /// one thing this mode may not do. What it closes is the window a single
    /// stat before the read leaves open for the whole of the read.
    ///
    /// Two stats and nothing else: no `-wal` or `-shm` is created, and neither
    /// sidecar is removed or truncated. Calling this on a handle opened in any
    /// other mode is a caller bug.
    pub fn verifyImmutable(self: *Db) Error!void {
        const guard = self.immutable_guard orelse unreachable;
        const now = try markImmutable(guard.io, guard.path);
        if (!std.meta.eql(now, guard.mark)) {
            log.warn(
                "'{s}' changed while it was being read without a lock; the read is not trustworthy",
                .{guard.path},
            );
            return error.WalPending;
        }
    }

    pub fn close(self: *Db) void {
        const rc = c.sqlite3_close_v2(self.handle);
        if (rc != result.ok) {
            log.err("sqlite3_close_v2 returned {s} (code {d})", .{
                std.mem.span(c.sqlite3_errstr(rc)),
                rc,
            });
        }
    }

    /// Borrowed; valid until the next SQLite call on this handle. Formats as
    /// "<message> (code <primary>/<extended>)".
    pub fn lastError(self: *Db, buf: []u8) []const u8 {
        const extended = c.sqlite3_extended_errcode(self.handle);
        const message = std.mem.span(c.sqlite3_errmsg(self.handle));
        return std.fmt.bufPrint(buf, "{s} (code {d}/{d})", .{
            message,
            extended & 0xff,
            extended,
        }) catch "sqlite error (message did not fit the buffer)";
    }

    /// For DDL and multi-statement scripts. `errmsg` is passed as null and the
    /// message is read back through `sqlite3_errmsg`, so there is no
    /// `sqlite3_free` obligation.
    pub fn exec(self: *Db, sql: [:0]const u8) Error!void {
        if (execFaultTripped(sql)) return error.Internal;
        return check(c.sqlite3_exec(self.handle, sql.ptr, null, null, null));
    }

    /// `sql` must hold exactly one statement; text with a second statement in it
    /// is `error.Misuse` and belongs in `exec`.
    pub fn prepare(self: *Db, sql: []const u8) Error!Stmt {
        if (sql.len > std.math.maxInt(c_int)) return error.TooBig;
        var handle: ?*c.Stmt = null;
        var tail: ?[*]const u8 = null;
        try check(c.sqlite3_prepare_v2(self.handle, sql.ptr, @intCast(sql.len), &handle, &tail));
        const h = handle orelse return error.Misuse;

        const tail_ptr = tail orelse sql.ptr + sql.len;
        const consumed = @intFromPtr(tail_ptr) - @intFromPtr(sql.ptr);
        const remaining = std.mem.trim(u8, sql[consumed..], " \t\r\n");
        if (remaining.len != 0) {
            _ = c.sqlite3_finalize(h);
            return error.Misuse;
        }
        return .{ .handle = h, .db = self };
    }

    /// Runs `sql` (which must yield exactly one row with one integer column) and
    /// returns it. A statement that produces no row is `error.SqliteError`.
    ///
    /// The row shape is verified, not assumed: a result with a column count
    /// other than 1, a first column that is not `SQLITE_INTEGER` (NULL, text,
    /// float and blob all count), or a second row is `error.Misuse`. That is the
    /// same member `prepare` returns for a caller that hands it the wrong SQL,
    /// because these are the same class of fault — a caller bug or schema drift,
    /// never a runtime condition. Without the checks a `SELECT` of the wrong
    /// column silently returns 0.
    pub fn queryInt(self: *Db, sql: []const u8) Error!i64 {
        var stmt = try self.prepare(sql);
        defer stmt.deinit();
        if (!try stmt.step()) {
            log.warn("queryInt produced no row for '{s}'", .{sql});
            return error.SqliteError;
        }
        const columns = c.sqlite3_column_count(stmt.handle);
        if (columns != 1) {
            log.warn("queryInt expects 1 column, got {d}, for '{s}'", .{ columns, sql });
            return error.Misuse;
        }
        const kind = c.sqlite3_column_type(stmt.handle, 0);
        if (kind != column_type.integer) {
            log.warn("queryInt expects an integer column, got type {d}, for '{s}'", .{ kind, sql });
            return error.Misuse;
        }
        const value = stmt.columnInt(0);
        if (try stmt.step()) {
            log.warn("queryInt expects 1 row, got more, for '{s}'", .{sql});
            return error.Misuse;
        }
        return value;
    }

    /// False while a transaction is open on this connection. The migration
    /// runner's belt check: a step that somehow ended the runner's transaction
    /// must not be allowed to look like a success.
    pub fn inTransaction(self: *Db) bool {
        return c.sqlite3_get_autocommit(self.handle) == 0;
    }

    /// Denies every transaction statement — `BEGIN`, `COMMIT`, `ROLLBACK`,
    /// `SAVEPOINT`, `RELEASE` — until `clearAuthorizer` runs.
    ///
    /// `guard` must outlive the installed window: SQLite keeps the pointer.
    /// Install and clear are a scoped pair; the migration runner clears on
    /// every exit path, because a leaked authorizer would go on denying the
    /// ROLLBACK that cleans up after the very statement it rejected.
    pub fn denyTransactions(self: *Db, guard: *TransactionGuard) Error!void {
        guard.* = .{};
        return check(c.sqlite3_set_authorizer(self.handle, transactionAuthorizer, guard));
    }

    /// Never fails in a way the caller can act on: passing a null callback only
    /// clears state SQLite already holds. A failure is logged and swallowed so
    /// this stays usable in `defer`.
    pub fn clearAuthorizer(self: *Db) void {
        const rc = c.sqlite3_set_authorizer(self.handle, null, null);
        if (rc != result.ok) {
            log.err("sqlite3_set_authorizer(null) returned {s} (code {d})", .{
                std.mem.span(c.sqlite3_errstr(rc)),
                rc,
            });
        }
    }

    pub fn lastInsertRowid(self: *Db) i64 {
        return c.sqlite3_last_insert_rowid(self.handle);
    }

    pub fn changes(self: *Db) i64 {
        return c.sqlite3_changes(self.handle);
    }

    /// Every row this connection has inserted, updated or deleted since it was
    /// opened. Monotonic, so a caller proves "this call wrote nothing" by
    /// reading it either side and comparing — which is stronger than comparing
    /// content, because an UPDATE that rewrites identical values still moves
    /// this counter.
    pub fn totalChanges(self: *Db) i64 {
        return c.sqlite3_total_changes(self.handle);
    }
};

/// Records whether the authorizer installed by `Db.denyTransactions` actually
/// rejected anything. The rejection reaches the caller as `error.Auth`, which
/// is indistinguishable from any other authorization failure; this flag is what
/// lets the migration runner name the real cause.
pub const TransactionGuard = struct {
    denied: bool = false,
};

fn transactionAuthorizer(
    user_data: ?*anyopaque,
    action: c_int,
    arg1: ?[*:0]const u8,
    arg2: ?[*:0]const u8,
    database: ?[*:0]const u8,
    trigger_or_view: ?[*:0]const u8,
) callconv(.c) c_int {
    _ = arg2;
    _ = database;
    _ = trigger_or_view;
    if (action != auth.transaction and action != auth.savepoint) return result.ok;
    const guard: *TransactionGuard = @ptrCast(@alignCast(user_data.?));
    guard.denied = true;
    log.warn("migration step attempted a transaction statement: {s}", .{
        if (arg1) |op| std.mem.span(op) else "(unnamed)",
    });
    return auth.deny;
}

fn openHandle(filename: [:0]const u8, flags: c_int) Error!*c.Sqlite3 {
    var handle: ?*c.Sqlite3 = null;
    const rc = c.sqlite3_open_v2(filename.ptr, &handle, flags, null);
    if (rc != result.ok) {
        // sqlite3_open_v2 allocates a handle even on failure. Read the
        // message from it, then close it; dropping it leaks on every
        // failed open.
        // Logged at `warn`, not `err`: the failure itself reaches the
        // caller as a typed error, and this line only carries the message
        // that would otherwise die with the handle.
        if (handle) |h| {
            log.warn("sqlite3_open_v2 failed for '{s}': {s} (code {d}/{d})", .{
                filename,
                std.mem.span(c.sqlite3_errmsg(h)),
                rc & 0xff,
                c.sqlite3_extended_errcode(h),
            });
            _ = c.sqlite3_close_v2(h);
        } else {
            log.warn("sqlite3_open_v2 failed for '{s}': {s} (code {d})", .{
                filename,
                std.mem.span(c.sqlite3_errstr(rc)),
                rc,
            });
        }
        return mapCode(rc);
    }
    return handle orelse error.SqliteError;
}

/// A silently ignored busy timeout is how a contended WAL database turns into
/// random SQLITE_BUSY failures under load.
fn applyBusyTimeout(h: *c.Sqlite3, busy_timeout_ms: c_int) Error!Db {
    check(c.sqlite3_busy_timeout(h, busy_timeout_ms)) catch |e| {
        _ = c.sqlite3_close_v2(h);
        return e;
    };
    return .{ .handle = h };
}

// ---------------------------------------------------------------------------
// OpenMode.immutable
// ---------------------------------------------------------------------------

const uri_scheme = "file:";
const uri_immutable_query = "?immutable=1";

/// Worst case: every byte of the longest path the platform accepts becomes
/// `%HH`.
const immutable_uri_buf_len =
    uri_scheme.len + 3 * std.Io.Dir.max_path_bytes + uri_immutable_query.len + 1;

fn openImmutable(io: std.Io, path: [:0]const u8, busy_timeout_ms: c_int) Error!Db {
    const mark = try markImmutable(io, path);

    var buf: [immutable_uri_buf_len]u8 = undefined;
    const uri = try immutableUri(&buf, path);

    // `uri` without `open_flag.uri` would be opened as a filename spelled
    // "file:...", creating nothing and finding nothing.
    const flags = open_flag.exrescode | open_flag.fullmutex |
        open_flag.readonly | open_flag.uri;
    const h = try openHandle(uri, flags);
    var database = try applyBusyTimeout(h, busy_timeout_ms);
    database.immutable_guard = .{ .io = io, .path = path, .mark = mark };
    return database;
}

/// What `OpenMode.immutable` recorded at the start of a read so that
/// `Db.verifyImmutable` can prove nothing moved by the end of it.
pub const ImmutableGuard = struct {
    io: std.Io,
    /// Borrowed. Must outlive the `Db`, which every caller satisfies by owning
    /// the path for at least as long as the connection it opened with it.
    path: []const u8,
    mark: FileMark,
};

/// The main database file at one instant, in the fields an outside observer can
/// compare cheaply. `atime` is deliberately absent: reading the file changes it,
/// so comparing it would report every read as a change.
///
/// All zero when the file does not exist, which is itself a state worth
/// comparing — a database replaced by an unlink is a database that moved.
const FileMark = struct {
    present: bool,
    size: u64,
    inode: std.Io.File.INode,
    mtime_ns: i96,
    ctime_ns: i96,
};

/// The state an immutable read must find unchanged, or `error.WalPending` when
/// `<path>-wal` already holds bytes.
///
/// The `-wal` rule is deliberately conservative: any non-empty log fails.
/// Deciding whether it really holds committed frames means running WAL recovery
/// — checksums, salt, the wal-index — which is the writing that
/// `OpenMode.immutable` exists to avoid. A live writer, a crash, and a
/// checkpointed-but-retained log all land here, and refusing to answer is the
/// right side to err on: the alternative is a stale answer nobody can see is
/// stale. A zero-length log holds no frames, so the main file is complete and it
/// passes.
///
/// A failed stat is not "no WAL": it means this cannot be known, so it stays a
/// failure.
fn markImmutable(io: std.Io, path: []const u8) Error!FileMark {
    var buf: [std.Io.Dir.max_path_bytes + wal_suffix.len]u8 = undefined;
    const sidecar = std.fmt.bufPrint(&buf, "{s}{s}", .{ path, wal_suffix }) catch
        return error.TooBig;

    if (try statOrAbsent(io, sidecar)) |wal| {
        if (wal.size > 0) return error.WalPending;
    }

    const main = try statOrAbsent(io, path) orelse return .{
        .present = false,
        .size = 0,
        .inode = 0,
        .mtime_ns = 0,
        .ctime_ns = 0,
    };
    return .{
        .present = true,
        .size = main.size,
        .inode = main.inode,
        .mtime_ns = main.mtime.nanoseconds,
        .ctime_ns = main.ctime.nanoseconds,
    };
}

fn statOrAbsent(io: std.Io, path: []const u8) Error!?std.Io.Dir.Stat {
    return std.Io.Dir.cwd().statFile(io, path, .{}) catch |e| switch (e) {
        error.FileNotFound => return null,
        else => {
            log.warn("cannot stat '{s}': {t}", .{ path, e });
            return error.IoErr;
        },
    };
}

/// `file:` + percent-encoded `path` + `?immutable=1`.
///
/// The encoding is load-bearing, not cosmetic. `?` opens SQLite's query section
/// and `#` its fragment, so an unencoded data directory named `dns?db` would
/// silently open a *different* file; `%` must be encoded because SQLite decodes
/// `%HH` on its way back to a filename. `--data-dir` is operator input, so all
/// three are reachable.
///
/// Everything outside the unreserved set (`A-Z a-z 0-9 - . _ ~ /`) is encoded,
/// which is always safe: SQLite decodes every escape in the path before handing
/// the name to its VFS, so the bytes it opens are the bytes passed in.
///
/// `/` stays literal to keep diagnostics readable, with one exception. SQLite
/// reads `file://…` as a URI authority and rejects any authority but the empty
/// one or `localhost` (`sqlite3ParseUri`), so a path beginning with `//` — legal
/// POSIX — has its second slash encoded.
fn immutableUri(buf: []u8, path: []const u8) error{TooBig}![:0]const u8 {
    var out: usize = 0;
    try appendSlice(buf, &out, uri_scheme);
    for (path, 0..) |ch, i| {
        const opens_authority = i == 1 and ch == '/' and path[0] == '/';
        if (isUriUnreserved(ch) and !opens_authority) {
            try appendByte(buf, &out, ch);
        } else {
            const hex = "0123456789ABCDEF";
            try appendByte(buf, &out, '%');
            try appendByte(buf, &out, hex[ch >> 4]);
            try appendByte(buf, &out, hex[ch & 0xf]);
        }
    }
    try appendSlice(buf, &out, uri_immutable_query);
    try appendByte(buf, &out, 0);
    return buf[0 .. out - 1 :0];
}

fn isUriUnreserved(ch: u8) bool {
    return switch (ch) {
        'a'...'z', 'A'...'Z', '0'...'9', '-', '.', '_', '~', '/' => true,
        else => false,
    };
}

fn appendByte(buf: []u8, out: *usize, ch: u8) error{TooBig}!void {
    if (out.* == buf.len) return error.TooBig;
    buf[out.*] = ch;
    out.* += 1;
}

fn appendSlice(buf: []u8, out: *usize, bytes: []const u8) error{TooBig}!void {
    for (bytes) |ch| try appendByte(buf, out, ch);
}

/// One prepared statement.
///
/// There is deliberately **no prepared-statement cache in this milestone**.
/// `config.db` is written a handful of times per process lifetime, so a cache is
/// unmeasured complexity here. Phase 6's query-log flush loop is the only hot
/// path and it owns its own long-lived statements. This is a decision, not an
/// oversight against PLAN §3.4.
pub const Stmt = struct {
    handle: *c.Stmt,
    db: *Db,
    /// The code of the last failed `step`, or `SQLITE_OK`. `sqlite3_reset` and
    /// `sqlite3_finalize` both re-report that code; without this the caller
    /// would see one failure logged as a second, unrelated one.
    pending_error: c_int = result.ok,

    pub fn deinit(self: *Stmt) void {
        const rc = c.sqlite3_finalize(self.handle);
        if (rc != result.ok and rc != self.pending_error) {
            log.err("sqlite3_finalize returned {s} (code {d})", .{
                std.mem.span(c.sqlite3_errstr(rc)),
                rc,
            });
        }
    }

    pub fn reset(self: *Stmt) Error!void {
        const rc = c.sqlite3_reset(self.handle);
        self.pending_error = result.ok;
        try check(rc);
        try check(c.sqlite3_clear_bindings(self.handle));
    }

    /// 1-based, matching SQLite.
    pub fn bindInt(self: *Stmt, idx: c_int, value: i64) Error!void {
        return check(c.sqlite3_bind_int64(self.handle, idx, value));
    }

    pub fn bindBool(self: *Stmt, idx: c_int, value: bool) Error!void {
        return self.bindInt(idx, if (value) 1 else 0);
    }

    /// Binds with `SQLITE_TRANSIENT`, so SQLite copies the bytes and the caller
    /// never has to keep `value` alive. The copy costs an allocation per bind;
    /// at config.db volumes that is invisible, and it removes a whole class of
    /// use-after-free from every caller.
    pub fn bindText(self: *Stmt, idx: c_int, value: []const u8) Error!void {
        if (value.len > std.math.maxInt(c_int)) return error.TooBig;
        return check(c.sqlite3_bind_text(self.handle, idx, value.ptr, @intCast(value.len), c.transient));
    }

    pub fn bindTextOrNull(self: *Stmt, idx: c_int, value: ?[]const u8) Error!void {
        if (value) |v| return self.bindText(idx, v);
        return self.bindNull(idx);
    }

    pub fn bindNull(self: *Stmt, idx: c_int) Error!void {
        return check(c.sqlite3_bind_null(self.handle, idx));
    }

    /// true = a row is available, false = the statement finished.
    pub fn step(self: *Stmt) Error!bool {
        const rc = c.sqlite3_step(self.handle);
        if (rc == result.row) return true;
        if (rc == result.done) return false;
        self.pending_error = rc;
        return mapCode(rc);
    }

    /// Runs to completion; asserts no rows were produced.
    pub fn exec(self: *Stmt) Error!void {
        const has_row = try self.step();
        assert(!has_row);
    }

    pub fn columnInt(self: *Stmt, col: c_int) i64 {
        return c.sqlite3_column_int64(self.handle, col);
    }

    pub fn columnBool(self: *Stmt, col: c_int) bool {
        return self.columnInt(col) != 0;
    }

    pub fn isNull(self: *Stmt, col: c_int) bool {
        return c.sqlite3_column_type(self.handle, col) == column_type.null_value;
    }

    /// Borrowed: valid only until the next `step`, `reset` or `deinit` on this
    /// statement. Every caller that keeps the value must copy it.
    ///
    /// A NULL column reads as `""`. A `NOT NULL` column makes that unreachable
    /// in practice, but it must not be undefined behaviour.
    pub fn columnText(self: *Stmt, col: c_int) []const u8 {
        const ptr = c.sqlite3_column_text(self.handle, col) orelse return "";
        const len = c.sqlite3_column_bytes(self.handle, col);
        if (len <= 0) return "";
        return ptr[0..@intCast(len)];
    }

    /// Borrowed under the same rules as `columnText`; NULL reads as `null`.
    pub fn columnTextOrNull(self: *Stmt, col: c_int) ?[]const u8 {
        if (self.isNull(col)) return null;
        return self.columnText(col);
    }

    /// Copies into `gpa`. Caller owns the result.
    pub fn columnTextAlloc(self: *Stmt, gpa: std.mem.Allocator, col: c_int) error{OutOfMemory}![]u8 {
        return gpa.dupe(u8, self.columnText(col));
    }

    /// Copies into `gpa`. Caller owns the result. NULL reads as `null`.
    pub fn columnTextAllocOrNull(self: *Stmt, gpa: std.mem.Allocator, col: c_int) error{OutOfMemory}!?[]u8 {
        const value = self.columnTextOrNull(col) orelse return null;
        return try gpa.dupe(u8, value);
    }
};

pub const Pragmas = struct {
    journal_wal: bool = true,
    synchronous_normal: bool = true,
    foreign_keys: bool = true,
};

/// MUST be called before any transaction is opened: `PRAGMA foreign_keys` is a
/// no-op inside a transaction, so applying it later silently leaves referential
/// integrity off.
pub fn applyPragmas(self: *Db, p: Pragmas) Error!void {
    if (p.journal_wal) {
        // The pragma returns a row holding the mode it actually reached. `exec`
        // would discard that answer, and an in-memory database — which cannot do
        // WAL — would look fine.
        var stmt = try self.prepare("PRAGMA journal_mode = WAL");
        defer stmt.deinit();
        if (!try stmt.step()) return error.SqliteError;
        const mode = stmt.columnText(0);
        const wal = std.ascii.eqlIgnoreCase(mode, "wal");
        const memory = std.ascii.eqlIgnoreCase(mode, "memory");
        if (!wal and !memory) {
            log.warn("PRAGMA journal_mode = WAL reported '{s}'", .{mode});
            return error.SqliteError;
        }
    }
    if (p.synchronous_normal) {
        try self.exec("PRAGMA synchronous = NORMAL;");
    }
    if (p.foreign_keys) {
        try self.exec("PRAGMA foreign_keys = ON;");
        if (try self.queryInt("PRAGMA foreign_keys") != 1) {
            log.warn("PRAGMA foreign_keys did not take", .{});
            return error.SqliteError;
        }
    }
}

/// A write transaction.
///
/// Usage contract, followed everywhere in this milestone:
///
/// ```zig
/// var tx = try Tx.begin(db);
/// errdefer tx.rollback();
/// ... // all writes
/// try tx.commit();
/// ```
///
/// `commit` and `rollback` both clear `active`, so the `errdefer` after a
/// successful commit is a no-op.
pub const Tx = struct {
    db: *Db,
    active: bool,

    /// BEGIN IMMEDIATE — takes the write lock up front. A deferred transaction
    /// that upgrades mid-way can fail with SQLITE_BUSY after arbitrary work;
    /// immediate cannot.
    pub fn begin(db: *Db) Error!Tx {
        try db.exec("BEGIN IMMEDIATE;");
        return .{ .db = db, .active = true };
    }

    pub fn commit(self: *Tx) Error!void {
        assert(self.active);
        try self.db.exec("COMMIT;");
        self.active = false;
    }

    /// Safe in `errdefer` and after `commit`. Never returns an error; a failed
    /// ROLLBACK is logged at `err` level with the SQLite message, because a
    /// database that will not roll back is an operational event, not a detail.
    pub fn rollback(self: *Tx) void {
        if (!self.active) return;
        self.active = false;
        self.db.exec("ROLLBACK;") catch {
            var buf: [256]u8 = undefined;
            log.err("ROLLBACK failed: {s}", .{self.db.lastError(&buf)});
        };
    }
};

/// A read transaction: one consistent view of the file across several
/// statements.
///
/// `BEGIN DEFERRED`, not `Tx`'s `BEGIN IMMEDIATE`. A reader that took the write
/// lock would stall the logger and retention for the length of an HTTP
/// response; a deferred transaction that only ever reads never upgrades, so it
/// cannot hit the mid-way `SQLITE_BUSY` the `Tx` doc warns about. In WAL mode it
/// pins the snapshot at the first read, which is the point: an aggregate and the
/// coverage watermark beside it describe the same database state even when
/// retention prunes between them.
///
/// Ending it is fallible, and the caller must treat it that way. A connection
/// left inside a transaction refuses the next `BEGIN`, so a swallowed failure
/// here does not cost one response — it costs every later one on the same
/// connection, and they would each be a correct-looking answer over a foreign
/// snapshot or an outright error the client was never told about.
///
/// ```zig
/// var tx = try ReadTx.begin(db);
/// errdefer tx.rollback();
/// ... // reads only
/// try tx.commit();
/// ```
pub const ReadTx = struct {
    db: *Db,
    active: bool,

    pub fn begin(db: *Db) Error!ReadTx {
        try db.exec("BEGIN DEFERRED;");
        return .{ .db = db, .active = true };
    }

    /// Ends the transaction, and says so. A read transaction has nothing to
    /// conflict over, so a failed COMMIT means the connection is in a state
    /// this code did not put it in: the ROLLBACK below is the attempt to hand
    /// the next caller a usable connection anyway, and the error is returned so
    /// the response it was serving fails rather than reporting success over a
    /// database whose state nobody can name.
    pub fn commit(self: *ReadTx) Error!void {
        assert(self.active);
        self.active = false;
        self.execCommit() catch |err| {
            self.reportFault("COMMIT");
            self.forceRollback();
            return err;
        };
    }

    fn execCommit(self: *ReadTx) Error!void {
        if (commitFaultTripped()) return error.Internal;
        return self.db.exec("COMMIT;");
    }

    /// Safe in `errdefer` and after `commit`. Never returns an error: it runs
    /// on the path where something has already gone wrong, and that error is
    /// the one worth reporting.
    pub fn rollback(self: *ReadTx) void {
        if (!self.active) return;
        self.active = false;
        self.forceRollback();
    }

    /// A ROLLBACK that fails leaves the connection inside a transaction with no
    /// way left to get it out. Every later `begin` on it fails, which is the
    /// visible symptom this reports the cause of.
    fn forceRollback(self: *ReadTx) void {
        self.db.exec("ROLLBACK;") catch {
            self.reportFault("ROLLBACK");
        };
    }

    /// The only record that this connection may be unusable, so in a real build
    /// it is `err`. A test that deliberately causes the fault captures it
    /// instead — see `read_tx_faults`.
    fn reportFault(self: *ReadTx, comptime what: []const u8) void {
        var buf: [256]u8 = undefined;
        const message = self.db.lastError(&buf);
        if (faultCaptured()) return;
        log.err("read-transaction " ++ what ++ " failed: {s}", .{message});
    }
};

/// Drives and observes the read-transaction teardown faults, which a unit test
/// cannot arrange against a healthy SQLite connection. Test builds only; it
/// reduces to nothing everywhere else — the rotation seam's shape
/// (logging.zig).
const read_tx_seam = if (builtin.is_test) struct {
    var fail_next_commit: bool = false;
    var capturing: bool = false;
    var faults: usize = 0;
} else struct {};

fn commitFaultTripped() bool {
    if (!builtin.is_test) return false;
    if (!read_tx_seam.fail_next_commit) return false;
    read_tx_seam.fail_next_commit = false;
    return true;
}

/// True when a test has said it is expecting this fault and will assert on it.
/// Capture is opt-in for exactly one reason: the test runner fails a test that
/// logs at `err`, so a blanket silence would turn an unexpected teardown fault
/// in some unrelated test into a silent pass.
fn faultCaptured() bool {
    if (!builtin.is_test) return false;
    if (!read_tx_seam.capturing) return false;
    read_tx_seam.faults += 1;
    return true;
}

/// The seam's controls, for tests in this file and in the web layer.
pub const read_tx_faults = if (builtin.is_test) struct {
    /// Fails the next `ReadTx.commit` before it issues COMMIT, so the
    /// transaction is still open when the recovery path runs — which is the
    /// shape of a real COMMIT failure.
    pub fn failNextCommit() void {
        read_tx_seam.fail_next_commit = true;
    }

    pub fn beginCapture() void {
        read_tx_seam.capturing = true;
        read_tx_seam.faults = 0;
    }

    /// Stops capturing and answers how many faults were reported meanwhile.
    pub fn endCapture() usize {
        read_tx_seam.capturing = false;
        return read_tx_seam.faults;
    }
} else struct {};

/// Fails one chosen `Db.exec` so a test can drive a failure SQLite itself will
/// not produce on demand. Test builds only, same shape as `read_tx_seam`.
///
/// The migration paths this exists for — the legacy restamp and the
/// post-commit pragma restore — run statements that always succeed against a
/// healthy file, and their recovery behaviour is the whole point of the
/// milestone. Matching on the SQL text rather than counting calls keeps a test
/// naming the statement it means.
const exec_seam = if (builtin.is_test) struct {
    var fail_matching: ?[]const u8 = null;
} else struct {};

fn execFaultTripped(sql: []const u8) bool {
    if (!builtin.is_test) return false;
    const needle = exec_seam.fail_matching orelse return false;
    if (std.mem.indexOf(u8, sql, needle) == null) return false;
    exec_seam.fail_matching = null;
    return true;
}

/// The seam's controls, for tests in this file and in the storage layer.
pub const exec_faults = if (builtin.is_test) struct {
    /// Arms the next `Db.exec` whose SQL contains `needle` to fail with
    /// `error.Internal` before the statement reaches SQLite. One shot: it
    /// disarms itself when it trips. Pair it with `defer disarm()` so a test
    /// that never trips the fault cannot leak it into the next one.
    pub fn failNextMatching(needle: []const u8) void {
        exec_seam.fail_matching = needle;
    }

    pub fn disarm() void {
        exec_seam.fail_matching = null;
    }

    pub fn armed() bool {
        return exec_seam.fail_matching != null;
    }
} else struct {};

const testing = std.testing;

fn openMemory() Error!Db {
    return Db.open(":memory:", .{ .mode = .memory });
}

test "mapCode maps every primary result code to a distinct error" {
    var seen: [26]Error = undefined;
    var code: c_int = 1;
    while (code <= 26) : (code += 1) {
        seen[@intCast(code - 1)] = mapCode(code);
    }
    for (seen, 0..) |a, i| {
        for (seen[i + 1 ..]) |b| {
            try testing.expect(a != b);
        }
    }
}

test "mapCode maps SQLITE_NOMEM to error.OutOfMemory and keeps extended codes in the family" {
    try testing.expectEqual(Error.OutOfMemory, mapCode(result.nomem));
    // SQLITE_IOERR_READ = 266, SQLITE_CONSTRAINT_UNIQUE = 2067.
    try testing.expectEqual(Error.IoErr, mapCode(266));
    try testing.expectEqual(Error.Constraint, mapCode(2067));
    // A primary code this build does not know stays visible as SqliteError.
    try testing.expectEqual(Error.SqliteError, mapCode(99));
}

test "open and close an in-memory database" {
    var db = try openMemory();
    defer db.close();
    try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT 1"));
}

test "queryInt rejects a result that is not exactly one row of one integer" {
    var db = try openMemory();
    defer db.close();

    // No row keeps the documented error.SqliteError.
    try testing.expectError(error.SqliteError, db.queryInt("SELECT 1 WHERE 0"));

    // Wrong column count.
    try testing.expectError(error.Misuse, db.queryInt("SELECT 1, 2"));

    // Wrong column type: NULL, text, float and blob are all rejected.
    try testing.expectError(error.Misuse, db.queryInt("SELECT NULL"));
    try testing.expectError(error.Misuse, db.queryInt("SELECT 'one'"));
    try testing.expectError(error.Misuse, db.queryInt("SELECT 1.5"));
    try testing.expectError(error.Misuse, db.queryInt("SELECT x'00'"));

    // A second row.
    try testing.expectError(error.Misuse, db.queryInt("SELECT 1 UNION ALL SELECT 2"));
}

test "queryInt accepts a single integer row after the shape checks" {
    var db = try openMemory();
    defer db.close();
    try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);");
    try db.exec("INSERT INTO t (id, name) VALUES (7, 'only');");

    try testing.expectEqual(@as(i64, 7), try db.queryInt("SELECT id FROM t"));
    try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
    try testing.expectEqual(@as(i64, -3), try db.queryInt("SELECT -3"));
    // sum() over an empty table is NULL, not an integer: a caller that wants a
    // total from a possibly-empty table must write total(), or COALESCE.
    try testing.expectError(error.Misuse, db.queryInt("SELECT sum(id) FROM t WHERE 0"));
    try testing.expectEqual(@as(i64, 7), try db.queryInt("SELECT sum(id) FROM t"));
}

test "applyPragmas succeeds and foreign_keys reads back as 1" {
    var db = try openMemory();
    defer db.close();
    try applyPragmas(&db, .{});
    try testing.expectEqual(@as(i64, 1), try db.queryInt("PRAGMA foreign_keys"));
}

test "open on a directory path returns error.CantOpen and leaks no handle" {
    var i: usize = 0;
    while (i < 1000) : (i += 1) {
        try testing.expectError(error.CantOpen, Db.open(".", .{}));
    }
}

test "prepare rejects text holding more than one statement" {
    var db = try openMemory();
    defer db.close();
    try testing.expectError(error.Misuse, db.prepare("SELECT 1; SELECT 2"));
    var stmt = try db.prepare("SELECT 1;");
    stmt.deinit();
}

test "bind, step and column round-trip including a NULL text column" {
    var db = try openMemory();
    defer db.close();
    try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, note TEXT, flag INTEGER NOT NULL);");

    var insert = try db.prepare("INSERT INTO t (name, note, flag) VALUES (?1, ?2, ?3)");
    defer insert.deinit();
    try insert.bindText(1, "kitchen");
    try insert.bindTextOrNull(2, null);
    try insert.bindBool(3, true);
    try insert.exec();
    try testing.expectEqual(@as(i64, 1), db.changes());
    try testing.expectEqual(@as(i64, 1), db.lastInsertRowid());

    var select = try db.prepare("SELECT id, name, note, flag FROM t");
    defer select.deinit();
    try testing.expect(try select.step());
    try testing.expectEqual(@as(i64, 1), select.columnInt(0));
    try testing.expectEqualStrings("kitchen", select.columnText(1));
    try testing.expect(select.isNull(2));
    try testing.expectEqual(@as(?[]const u8, null), select.columnTextOrNull(2));
    try testing.expectEqualStrings("", select.columnText(2));
    try testing.expect(select.columnBool(3));
    try testing.expect(!try select.step());
}

test "columnTextAlloc returns an owned copy that survives a subsequent step" {
    var db = try openMemory();
    defer db.close();
    try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);");
    try db.exec("INSERT INTO t (id, name) VALUES (1, 'first'), (2, 'second');");

    var stmt = try db.prepare("SELECT name FROM t ORDER BY id");
    defer stmt.deinit();
    try testing.expect(try stmt.step());
    const owned = try stmt.columnTextAlloc(testing.allocator, 0);
    defer testing.allocator.free(owned);
    const owned_or_null = try stmt.columnTextAllocOrNull(testing.allocator, 0);
    defer if (owned_or_null) |v| testing.allocator.free(v);

    try testing.expect(try stmt.step());
    try testing.expectEqualStrings("second", stmt.columnText(0));
    try testing.expectEqualStrings("first", owned);
    try testing.expectEqualStrings("first", owned_or_null.?);
}

test "transaction commit persists and rollback discards" {
    var db = try openMemory();
    defer db.close();
    try applyPragmas(&db, .{});
    try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");

    {
        var tx = try Tx.begin(&db);
        errdefer tx.rollback();
        try db.exec("INSERT INTO t (id) VALUES (1);");
        try tx.commit();
    }
    try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));

    {
        var tx = try Tx.begin(&db);
        try db.exec("INSERT INTO t (id) VALUES (2);");
        tx.rollback();
    }
    try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
}

test "rollback after commit is a no-op" {
    var db = try openMemory();
    defer db.close();
    try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");

    var tx = try Tx.begin(&db);
    try db.exec("INSERT INTO t (id) VALUES (1);");
    try tx.commit();
    try testing.expect(!tx.active);
    tx.rollback();
    tx.rollback();
    try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
}

test "a read transaction sees one state and frees the connection when it commits" {
    var db = try openMemory();
    defer db.close();
    try applyPragmas(&db, .{});
    try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
    try db.exec("INSERT INTO t (id) VALUES (1);");

    var tx = try ReadTx.begin(&db);
    try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
    try tx.commit();
    try testing.expect(!tx.active);
    // Committed, so the connection takes a fresh transaction again.
    var second = try ReadTx.begin(&db);
    second.rollback();
    try testing.expect(!second.active);
    // Idempotent, so an `errdefer` that survives a successful rollback is a
    // no-op rather than a stray ROLLBACK against the next transaction.
    second.rollback();

    var third = try ReadTx.begin(&db);
    try third.commit();
}

test "a connection left inside a transaction refuses the next begin" {
    var db = try openMemory();
    defer db.close();
    try applyPragmas(&db, .{});

    // The poisoned connection, reached the only way it can be: a transaction
    // that was opened and never ended. This is what a swallowed COMMIT failure
    // would leave behind, and the point is that it is *loud* — the next reader
    // gets an error it must report, never a silent read outside a snapshot.
    try db.exec("BEGIN DEFERRED;");
    try testing.expectError(error.Unexpected, ReadTx.begin(&db));

    // And it is recoverable: ending the stray transaction restores the
    // connection, which is what `commit`'s rollback attempt is reaching for.
    try db.exec("ROLLBACK;");
    var tx = try ReadTx.begin(&db);
    try tx.commit();
}

test "a failed commit is reported, rolled back, and leaves the connection usable" {
    var db = try openMemory();
    defer db.close();
    try applyPragmas(&db, .{});
    try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
    try db.exec("INSERT INTO t (id) VALUES (1);");

    read_tx_faults.beginCapture();
    defer _ = read_tx_faults.endCapture();

    var tx = try ReadTx.begin(&db);
    try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));

    // The failure lands where a real one does: the transaction is still open
    // when the recovery path runs.
    read_tx_faults.failNextCommit();
    try testing.expectError(error.Internal, tx.commit());
    try testing.expect(!tx.active);

    // Exactly one fault: the COMMIT. The ROLLBACK behind it succeeded, which is
    // the whole point of attempting it.
    try testing.expectEqual(@as(usize, 1), read_tx_faults.endCapture());

    // The connection is not poisoned — the next reader gets a transaction
    // rather than inheriting the fault.
    read_tx_faults.beginCapture();
    var next = try ReadTx.begin(&db);
    try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
    try next.commit();
    try testing.expectEqual(@as(usize, 0), read_tx_faults.endCapture());
}

test "an armed commit fault fires once and no further" {
    var db = try openMemory();
    defer db.close();
    try applyPragmas(&db, .{});

    read_tx_faults.beginCapture();
    defer _ = read_tx_faults.endCapture();
    read_tx_faults.failNextCommit();

    var first = try ReadTx.begin(&db);
    try testing.expectError(error.Internal, first.commit());

    // The seam disarms itself, so it cannot leak into a later test in the same
    // binary and fail a commit nobody asked to fail.
    var second = try ReadTx.begin(&db);
    try second.commit();
    try testing.expectEqual(@as(usize, 1), read_tx_faults.endCapture());
}

test "a row-producing statement reports its row through step" {
    var db = try openMemory();
    defer db.close();
    // Stmt.exec asserts on this shape; the test observes it through `step`
    // instead, so the assertion path stays out of the test binary.
    var stmt = try db.prepare("SELECT 1");
    defer stmt.deinit();
    try testing.expect(try stmt.step());
}

// ---------------------------------------------------------------------------
// OpenMode.immutable
// ---------------------------------------------------------------------------

/// `std.testing.tmpDir` creates its directory under `.zig-cache/tmp/` relative to
/// the process working directory, which is also how SQLite's VFS resolves the
/// filename it is handed (`queries_repo.zig:721`).
const tmp_prefix = ".zig-cache/tmp/";
const sub_path_len = @typeInfo(@FieldType(testing.TmpDir, "sub_path")).array.len;
const test_io = testing.io;

fn tmpPath(buf: []u8, tmp: *const testing.TmpDir, name: []const u8) ![:0]const u8 {
    return std.fmt.bufPrintZ(buf, "{s}{s}/{s}", .{ tmp_prefix, &tmp.sub_path, name });
}

/// A file database in WAL mode holding one row, `id = marker`. Closing the last
/// connection checkpoints and unlinks both sidecars, but the header keeps saying
/// WAL — which is what makes a later `.read_only` open recreate them.
fn writeWalDatabase(path: [:0]const u8, marker: i64) !void {
    var database = try Db.open(path, .{ .mode = .read_write_create });
    defer database.close();
    try applyPragmas(&database, .{});
    try database.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
    var stmt = try database.prepare("INSERT INTO t (id) VALUES (?1)");
    defer stmt.deinit();
    try stmt.bindInt(1, marker);
    try stmt.exec();
}

/// A second writer doing what a running nxdns does: it appends to the
/// write-ahead log and, being the last connection, checkpoints into the main
/// file and unlinks both sidecars on close. The blob is what makes the main
/// file grow by whole pages, so a test that watches for the change does not rest
/// on the filesystem's timestamp resolution.
fn checkpointOver(path: [:0]const u8) !void {
    var database = try Db.open(path, .{ .mode = .read_write_existing });
    defer database.close();
    try applyPragmas(&database, .{});
    try database.exec("INSERT INTO t (id) VALUES (8);");
    try database.exec("CREATE TABLE bulk (v TEXT);");
    try database.exec("INSERT INTO bulk (v) VALUES (hex(randomblob(30000)));");
}

fn expectAbsent(dir: std.Io.Dir, name: []const u8) !void {
    dir.access(test_io, name, .{}) catch |e| switch (e) {
        error.FileNotFound => return,
        else => |other| return other,
    };
    std.debug.print("sidecar '{s}' exists and must not\n", .{name});
    return error.SidecarPresent;
}

test "immutableUri encodes what would otherwise change which file is opened" {
    var buf: [256]u8 = undefined;

    try testing.expectEqualStrings(
        "file:/var/lib/nxdns/config.db?immutable=1",
        try immutableUri(&buf, "/var/lib/nxdns/config.db"),
    );
    // '?' would start SQLite's query section, '#' its fragment, '%' an escape.
    try testing.expectEqualStrings(
        "file:/data%3Fdir/config.db?immutable=1",
        try immutableUri(&buf, "/data?dir/config.db"),
    );
    try testing.expectEqualStrings(
        "file:/data%23dir/config.db?immutable=1",
        try immutableUri(&buf, "/data#dir/config.db"),
    );
    try testing.expectEqualStrings(
        "file:/data%25dir/config.db?immutable=1",
        try immutableUri(&buf, "/data%dir/config.db"),
    );
    try testing.expectEqualStrings(
        "file:/a%20b/c%3Fd%23e%25f.db?immutable=1",
        try immutableUri(&buf, "/a b/c?d#e%f.db"),
    );
    // A relative path stays relative: SQLite's VFS resolves it against the
    // working directory, exactly as a bare filename would be.
    try testing.expectEqualStrings(
        "file:config.db?immutable=1",
        try immutableUri(&buf, "config.db"),
    );
    // A leading "//" would be read as a URI authority and rejected.
    try testing.expectEqualStrings(
        "file:/%2Fnet/share/config.db?immutable=1",
        try immutableUri(&buf, "//net/share/config.db"),
    );
    // Only the authority position is special: "//" further in stays literal.
    try testing.expectEqualStrings(
        "file:/net//share/config.db?immutable=1",
        try immutableUri(&buf, "/net//share/config.db"),
    );
    // Non-ASCII bytes survive the round trip because SQLite decodes them back.
    try testing.expectEqualStrings(
        "file:/caf%C3%A9/config.db?immutable=1",
        try immutableUri(&buf, "/café/config.db"),
    );

    var small: [16]u8 = undefined;
    try testing.expectError(error.TooBig, immutableUri(&small, "/var/lib/nxdns/config.db"));
}

test "an immutable open of a WAL database creates no -wal and no -shm" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined;
    const path = try tmpPath(&path_buf, &tmp, "config.db");

    try writeWalDatabase(path, 7);
    try expectAbsent(tmp.dir, "config.db-wal");
    try expectAbsent(tmp.dir, "config.db-shm");

    {
        var database = try Db.open(path, .{ .mode = .{ .immutable = test_io } });
        defer database.close();
        try testing.expectEqual(@as(i64, 7), try database.queryInt("SELECT id FROM t"));
        // The point of the mode: a `.read_only` open creates both of these here,
        // and cannot delete them on close.
        try expectAbsent(tmp.dir, "config.db-wal");
        try expectAbsent(tmp.dir, "config.db-shm");
    }
    try expectAbsent(tmp.dir, "config.db-wal");
    try expectAbsent(tmp.dir, "config.db-shm");
}

test "an immutable open of a path holding URI metacharacters opens the intended file" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var decoy_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined;
    var path_buf: [tmp_prefix.len + sub_path_len + 64]u8 = undefined;

    // Unencoded, SQLite cuts the filename at the '?' and opens "<tmp>/d". That
    // file exists here and holds a different database, so the failure without
    // percent-encoding is a wrong answer, not an error.
    try tmp.dir.createDirPath(test_io, "d?x#y%z");
    try writeWalDatabase(try tmpPath(&decoy_buf, &tmp, "d"), 99);
    const path = try tmpPath(&path_buf, &tmp, "d?x#y%z/config.db");
    try writeWalDatabase(path, 7);

    var database = try Db.open(path, .{ .mode = .{ .immutable = test_io } });
    defer database.close();
    try testing.expectEqual(@as(i64, 7), try database.queryInt("SELECT id FROM t"));
}

test "an immutable open refuses a database whose -wal holds bytes" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined;
    const path = try tmpPath(&path_buf, &tmp, "config.db");
    try writeWalDatabase(path, 7);

    // What an unclean shutdown leaves behind, written directly so the case does
    // not depend on when SQLite decides to checkpoint.
    try tmp.dir.writeFile(test_io, .{ .sub_path = "config.db" ++ wal_suffix, .data = &([_]u8{0x37} ** 32) });
    try testing.expectError(error.WalPending, Db.open(path, .{ .mode = .{ .immutable = test_io } }));

    // A zero-length `-wal` holds no frames, so the main file is complete: the
    // exact leftover the pre-F-c `check` created must not block a check.
    try tmp.dir.writeFile(test_io, .{ .sub_path = "config.db" ++ wal_suffix, .data = "" });
    var database = try Db.open(path, .{ .mode = .{ .immutable = test_io } });
    defer database.close();
    try testing.expectEqual(@as(i64, 7), try database.queryInt("SELECT id FROM t"));
}

test "an immutable read refuses a -wal that arrives while it is in flight" {
    // The probe at open time can only say the log was empty *then*.
    // `immutable=1` takes no lock, so a writer is free to arrive one instant
    // later, and the read goes on answering from the older pages of the main
    // file with nothing anywhere reporting it.
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined;
    const path = try tmpPath(&path_buf, &tmp, "config.db");
    try writeWalDatabase(path, 7);

    var database = try Db.open(path, .{ .mode = .{ .immutable = test_io } });
    defer database.close();
    try testing.expectEqual(@as(i64, 7), try database.queryInt("SELECT id FROM t"));
    // Nothing has moved yet, so the read stands.
    try database.verifyImmutable();

    // A zero-length log still holds no frames: the rule at the end of the read
    // is the rule at the start of it.
    try tmp.dir.writeFile(test_io, .{ .sub_path = "config.db" ++ wal_suffix, .data = "" });
    try database.verifyImmutable();

    // Frames, now, in the log the open accepted as empty.
    try tmp.dir.writeFile(test_io, .{
        .sub_path = "config.db" ++ wal_suffix,
        .data = &([_]u8{0x37} ** 32),
    });
    try testing.expectError(error.WalPending, database.verifyImmutable());
    // Repeatable: reporting the race is all it does.
    try testing.expectError(error.WalPending, database.verifyImmutable());

    // And it repairs nothing. The operator's log is byte for byte what was
    // written, and no wal-index appeared beside it.
    const wal = try tmp.dir.statFile(test_io, "config.db" ++ wal_suffix, .{});
    try testing.expectEqual(@as(u64, 32), wal.size);
    try expectAbsent(tmp.dir, "config.db-shm");
}

test "an immutable read refuses a main file checkpointed under it" {
    // The case a `-wal` probe cannot see at either end: a writer checkpointed
    // into the main file and, closing, unlinked its log again. Both stats say
    // "no frames" while the pages the read answered from have been replaced.
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined;
    const path = try tmpPath(&path_buf, &tmp, "config.db");
    try writeWalDatabase(path, 7);

    var database = try Db.open(path, .{ .mode = .{ .immutable = test_io } });
    defer database.close();
    try testing.expectEqual(@as(i64, 7), try database.queryInt("SELECT id FROM t"));
    try database.verifyImmutable();

    const before = try tmp.dir.statFile(test_io, "config.db", .{});
    try checkpointOver(path);
    const after = try tmp.dir.statFile(test_io, "config.db", .{});

    // The premise of the test, proved rather than assumed: the main file really
    // did move, and the log really is gone again.
    try testing.expect(after.size != before.size);
    try expectAbsent(tmp.dir, "config.db" ++ wal_suffix);

    try testing.expectError(error.WalPending, database.verifyImmutable());
}

test "a duplicate insert into a UNIQUE column returns error.Constraint" {
    var db = try openMemory();
    defer db.close();
    try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);");
    try db.exec("INSERT INTO t (name) VALUES ('only');");

    var stmt = try db.prepare("INSERT INTO t (name) VALUES (?1)");
    defer stmt.deinit();
    try stmt.bindText(1, "only");
    try testing.expectError(error.Constraint, stmt.step());
}

test "the transaction authorizer denies transaction statements and clears cleanly" {
    var db = try openMemory();
    defer db.close();
    try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");

    try db.exec("BEGIN IMMEDIATE;");
    try testing.expect(db.inTransaction());

    var guard: TransactionGuard = .{};
    try db.denyTransactions(&guard);
    // Ordinary work still runs: only the transaction statements are refused.
    try db.exec("INSERT INTO t (id) VALUES (1);");
    try testing.expect(!guard.denied);
    try testing.expectError(error.Auth, db.exec("COMMIT;"));
    try testing.expect(guard.denied);
    // The deny happens at prepare, so the transaction is still open.
    try testing.expect(db.inTransaction());

    // SAVEPOINT and its RELEASE report under a different action code, and they
    // are the same bypass: at the outermost level they are a transaction under
    // another name, and inside one they can still discard the migration's work.
    guard.denied = false;
    try testing.expectError(error.Auth, db.exec("SAVEPOINT half_a_migration;"));
    try testing.expect(guard.denied);
    guard.denied = false;
    try testing.expectError(error.Auth, db.exec("RELEASE half_a_migration;"));
    try testing.expect(guard.denied);
    try testing.expect(db.inTransaction());

    db.clearAuthorizer();
    // The same connection is usable again — a leaked authorizer would strand it
    // inside the transaction by denying this too.
    try db.exec("ROLLBACK;");
    try testing.expect(!db.inTransaction());
    try testing.expectEqual(@as(i64, 0), try db.queryInt("SELECT count(*) FROM t"));
}

test "the exec fault seam fires once, on the statement it names" {
    var db = try openMemory();
    defer db.close();
    try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");

    exec_faults.failNextMatching("COMMIT");
    defer exec_faults.disarm();

    try db.exec("BEGIN IMMEDIATE;");
    try db.exec("INSERT INTO t (id) VALUES (1);");
    try testing.expectError(error.Internal, db.exec("COMMIT;"));
    try testing.expect(!exec_faults.armed());
    // Disarmed: the retry is a real COMMIT.
    try db.exec("COMMIT;");
    try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
}
