//! What every mutation handler shares: the collaborator checks, the database
//! error mapping, the per-row validation, and the two ways a change is applied
//! to the running server.
//!
//! Three conventions hold across `web/handlers/`:
//!
//! - Every repository call in this layer allocates from the per-request arena,
//!   so the repositories' `freeX` helpers are deliberately not called: the
//!   arena is reset when the response is written. Nothing read here outlives
//!   the request.
//! - A collaborator this layer needs and does not have is a 503, never a crash
//!   and never a silent success. `web.enabled = false` opens no database at
//!   all, and a half-wired `WebState` must fail the same way.
//! - Domain outcomes are status codes (ruling 8): `error.NotFound` is 404,
//!   `error.Constraint` is 409 with the constraint named in words, a value the
//!   validator rejects is 400, and everything else is a 500 whose cause is
//!   logged at `warn` and never sent to the client (PLAN §19).
//!
//! `WebState.config_lock` exists because `std.http.Server` connections are served
//! concurrently while all of them share one config connection. SQLite is built
//! in serialized mode, so the connection is safe — but `changes()` and
//! `lastInsertRowid()` describe *the connection's* last statement, and those
//! are exactly what `crud.execStrict` and every `insertXRow` read. Two
//! concurrent writers would read each other's answer. One lock around the
//! database work of a mutation makes the read-back belong to the writer that
//! caused it.

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

const db = @import("../../storage/db.zig");
const forward_zones = @import("../../local/forward_zones.zig");
const local_repo = @import("../../storage/repositories/local_repo.zig");
const local_records = @import("../../local/records.zig");
const local_tables = @import("../../server/local_tables.zig");
const migrations = @import("../../storage/migrations.zig");
const http_util = @import("../http_util.zig");
const model = @import("../../config/model.zig");
const server = @import("../server.zig");
const settings_repo = @import("../../storage/repositories/settings_repo.zig");
const validate = @import("../../config/validate.zig");

const clients_repo = @import("../../storage/repositories/clients_repo.zig");
const groups_repo = @import("../../storage/repositories/groups_repo.zig");
const rules_repo = @import("../../storage/repositories/rules_repo.zig");
const sources_repo = @import("../../storage/repositories/sources_repo.zig");
const upstreams_repo = @import("../../storage/repositories/upstreams_repo.zig");

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

pub const Request = http_util.Request;
pub const HandlerError = http_util.HandlerError;

/// Why a request did not succeed. Every handler in this directory decides in a
/// function that takes no `std.http.Server.Request`, returns one of these, and
/// leaves the response to `respondFailure` — so the decision is testable
/// against an in-memory database, with no socket anywhere.
pub const Failure = union(enum) {
    /// The id names no row: 404.
    not_found,
    /// A constraint of the schema or of the configuration: 409. The text names
    /// which one, because the client can only fix what it is told.
    conflict: []const u8,
    /// A value the validator refused: 400, with the validator's own text.
    invalid: []const u8,
    /// A collaborator this request needs is not wired: 503.
    unavailable: []const u8,
    /// Anything else the database reported: 500, cause logged, not sent.
    internal: db.Error,
    /// The write landed and the running server could not be told about it.
    /// A 500 that says exactly that, because retrying the write would not help
    /// and reporting success would leave the operator with a stale server.
    not_applied,
};

pub fn respondFailure(request: *Request, failure: Failure, what: []const u8) HandlerError!void {
    return switch (failure) {
        .not_found => http_util.respondError(request, .not_found, "not found"),
        .conflict => |message| http_util.respondError(request, .conflict, message),
        .invalid => |message| http_util.respondError(request, .bad_request, message),
        .unavailable => |message| http_util.respondError(request, .service_unavailable, message),
        .internal => |err| {
            log.warn("{s} failed: {t}", .{ what, err });
            return http_util.respondError(request, .internal_server_error, "internal error");
        },
        .not_applied => http_util.respondError(
            request,
            .internal_server_error,
            "the change was saved but could not be applied; restart nxdns",
        ),
    };
}

/// Turns a repository error into a `Failure`. `conflict` names the constraint
/// that can fire for this statement (W2 documents one per function).
pub fn dbFailure(err: db.Error, conflict: []const u8) Failure {
    return switch (err) {
        error.NotFound => .not_found,
        error.Constraint => .{ .conflict = conflict },
        else => .{ .internal = err },
    };
}

/// The 503 a state with no config connection earns. One constant, because every
/// caller reports the same missing collaborator in the same words.
pub const no_config_db: Failure = .{ .unavailable = "no configuration database" };

/// The config connection, or `error.NoConfigDb` for the caller to turn into
/// `no_config_db` in whatever shape it answers with — a `Failure`, a `Created`,
/// or a written response.
pub fn requireConfigDb(state: *server.WebState) error{NoConfigDb}!*db.Db {
    return state.config_db orelse error.NoConfigDb;
}

pub fn nowSeconds(io: std.Io) i64 {
    return std.Io.Clock.real.now(io).toSeconds();
}

// ---------------------------------------------------------------------------
// the identical half of an id-addressed resource
// ---------------------------------------------------------------------------

/// The `list`, `get` and `remove` handlers every id-addressed resource in this
/// directory writes the same way: take the config connection or answer 503, call
/// one repository function, and turn what comes back into the response. Nothing
/// a resource decides for itself is here — the create and update bodies, the
/// constraint texts, and the four reload flavors stay hand-written beside the
/// descriptor that names these three.
///
/// `desc` is an anonymous struct literal rather than a typed struct because
/// `anytype` is not legal as a struct *field* type and the members are functions
/// of five signatures. Every member is checked below, so a descriptor that is
/// missing one or spells one wrong is a compile error that names it.
///
/// Members:
///
/// - `Row: type` — what the repository returns for one row.
/// - `list: fn (*db.Db, Allocator) db.Error!std.ArrayList(Row)`.
/// - `get: fn (*db.Db, Allocator, i64) db.Error!?Row`, or `null` for a resource
///   with no `/{id}` route.
/// - `remove: fn (*server.WebState, std.Io, i64) ?Failure`, or the same with an
///   `Allocator` before the id for a delete decision that reads rows, or `null`.
/// - `label: []const u8` — "an upstream": what "reading" and "deleting" take as
///   their object in the log context a 500 carries.
/// - `plural: []const u8` — "upstreams": what "listing" takes as its object.
/// - `envelope: []const u8` — the JSON key the list arrives under.
/// - `view: fn (Row) View` — optional. A resource whose wire shape is not its
///   row spells the difference here; without it the row is serialised as it is.
pub fn Resource(comptime desc: anytype) type {
    const Desc = @TypeOf(desc);
    for ([_][]const u8{ "Row", "list", "get", "remove", "label", "plural", "envelope" }) |name| {
        if (!@hasField(Desc, name)) {
            @compileError("resource descriptor has no `" ++ name ++ "`");
        }
    }
    if (@TypeOf(desc.Row) != type) @compileError("resource descriptor `Row` must be a type");
    const Row = desc.Row;

    expectType("list", @TypeOf(desc.list), fn (*db.Db, Allocator) db.Error!std.ArrayList(Row));
    if (!isNull(@TypeOf(desc.get))) {
        expectType("get", @TypeOf(desc.get), fn (*db.Db, Allocator, i64) db.Error!?Row);
    }
    if (!isNull(@TypeOf(desc.remove))) {
        const Remove = @TypeOf(desc.remove);
        if (removeTakesArena(Remove)) {
            if (removeIsFallible(Remove)) {
                expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) error{OutOfMemory}!?Failure);
            } else {
                expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) ?Failure);
            }
        } else {
            expectType("remove", Remove, fn (*server.WebState, std.Io, i64) ?Failure);
        }
    }
    _ = @as([]const u8, desc.label);
    _ = @as([]const u8, desc.plural);
    _ = @as([]const u8, desc.envelope);

    const has_view = @hasField(Desc, "view");
    if (has_view) {
        const info = @typeInfo(@TypeOf(desc.view)).@"fn";
        if (info.params.len != 1 or info.params[0].type.? != Row) {
            @compileError("resource descriptor `view` must take one " ++ @typeName(Row));
        }
    }
    const View = if (has_view) @typeInfo(@TypeOf(desc.view)).@"fn".return_type.? else Row;

    const names: [1][:0]const u8 = .{desc.envelope};
    const types: [1]type = .{[]const View};
    const attrs: [1]std.builtin.Type.StructField.Attributes = .{.{}};
    const Envelope = @Struct(.auto, null, &names, &types, &attrs);

    const list_what = "listing " ++ desc.plural;
    const get_what = "reading " ++ desc.label;
    const remove_what = "deleting " ++ desc.label;

    return struct {
        pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
            _ = io;
            const database = requireConfigDb(state) catch
                return respondFailure(request, no_config_db, list_what);

            const rows = desc.list(database, request.arena) catch |err|
                return respondFailure(request, .{ .internal = err }, list_what);

            const items: []const View = if (has_view) views: {
                const views = try request.arena.alloc(View, rows.items.len);
                for (views, rows.items) |*view, row| view.* = desc.view(row);
                break :views views;
            } else rows.items;

            var payload: Envelope = undefined;
            @field(payload, desc.envelope) = items;
            return http_util.respondJson(request, .ok, payload, &.{});
        }

        pub const get = if (isNull(@TypeOf(desc.get))) {} else getRow;
        pub const remove = if (isNull(@TypeOf(desc.remove))) {} else removeRow;

        fn getRow(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
            _ = io;
            const database = requireConfigDb(state) catch
                return respondFailure(request, no_config_db, get_what);

            const row = desc.get(database, request.arena, request.id.?) catch |err|
                return respondFailure(request, .{ .internal = err }, get_what);
            const found = row orelse return respondFailure(request, .not_found, "");

            const body: View = if (has_view) desc.view(found) else found;
            return http_util.respondJson(request, .ok, body, &.{});
        }

        fn removeRow(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
            const failure = if (comptime !removeTakesArena(@TypeOf(desc.remove)))
                desc.remove(state, io, request.id.?)
            else if (comptime removeIsFallible(@TypeOf(desc.remove)))
                try desc.remove(state, io, request.arena, request.id.?)
            else
                desc.remove(state, io, request.arena, request.id.?);

            if (failure) |value| return respondFailure(request, value, remove_what);
            return http_util.respondEmpty(request, .no_content);
        }
    };
}

fn isNull(comptime T: type) bool {
    return T == @TypeOf(null);
}

fn removeTakesArena(comptime T: type) bool {
    return @typeInfo(T) == .@"fn" and @typeInfo(T).@"fn".params.len == 4;
}

/// A delete decision that builds a candidate for the running server allocates
/// while it does so, so it may run out of memory. The two shapes are checked
/// exactly, so a `remove` that returns some other error set is still a compile
/// error naming what it should have been.
fn removeIsFallible(comptime T: type) bool {
    return @typeInfo(@typeInfo(T).@"fn".return_type.?) == .error_union;
}

fn expectType(comptime name: []const u8, comptime Actual: type, comptime Expected: type) void {
    if (Actual != Expected) @compileError("resource descriptor `" ++ name ++ "` must be " ++
        @typeName(Expected) ++ ", found " ++ @typeName(Actual));
}

// ---------------------------------------------------------------------------
// applying a change to the running server (ruling 12)
// ---------------------------------------------------------------------------

/// Rebuilds the blocklist snapshot so the change is live on the next query.
///
/// A state with no `reload_fn` has nothing to reload — that is the shape of a
/// web layer under test, and of one whose composition root wired no manager.
///
/// Locking contract, and it is the reason every one of the fourteen call sites
/// in `groups.zig`, `rules.zig`, `clients.zig` and `blocklists.zig` may call
/// this *after* releasing `state.config_lock`: `reload_fn` must re-read all of
/// the state it publishes from the database itself, under the manager's own
/// writer lock. It must never accept rows the caller read. Rows read under
/// `config_lock` and passed across its release are already stale, so a
/// signature change that adds a row parameter here silently breaks the
/// correctness of all fourteen sites — every one of them would have to move
/// the call back inside the lock.
///
/// `swapLocalTables` below is the pre-read shape and is exactly the contrast:
/// it takes the rows, so `local.zig`'s `publish` calls it while it still holds
/// `config_lock`, and the ordering rationale lives on that function.
pub fn reload(state: *server.WebState, io: std.Io) ?Failure {
    const reload_fn = state.reload_fn orelse return null;
    reload_fn(state, io) catch |err| {
        log.warn("applying a configuration change failed: {s}", .{@errorName(err)});
        return .not_applied;
    };
    return null;
}

/// Rebuilds the local records and the forward zones from the database and
/// publishes both (ruling 12). Local answers therefore change live, without the
/// blocklist snapshot being rebuilt.
///
/// Both tables are built before either is published, so a failure leaves the
/// running server with the generation it already had.
pub fn swapLocalTables(
    state: *server.WebState,
    io: std.Io,
    arena: Allocator,
    database: *db.Db,
) ?Failure {
    const tables = state.local_tables orelse return null;
    const gpa = state.gpa;

    const record_rows = local_repo.listLocalRecords(database, arena) catch |err|
        return rebuildFailed("reading the local records", @errorName(err));

    const zone_rows = local_repo.listForwardZones(database, arena) catch |err|
        return rebuildFailed("reading the forward zones", @errorName(err));

    var built_records = local_records.Records.build(gpa, record_rows.items) catch |err|
        return rebuildFailed("building the local records", @errorName(err));
    errdefer built_records.deinit(gpa);

    const built_zones = forward_zones.Zones.build(gpa, zone_rows.items) catch |err|
        return rebuildFailed("building the forward zones", @errorName(err));

    tables.swap(io, gpa, built_records, built_zones);
    return null;
}

fn rebuildFailed(what: []const u8, cause: []const u8) Failure {
    log.warn("{s} after a change failed: {s}", .{ what, cause });
    return .not_applied;
}

// ---------------------------------------------------------------------------
// per-row validation
// ---------------------------------------------------------------------------
//
// `config/validate.zig` validates a whole configuration and is not this
// session's to split, so a candidate row is checked by handing the real
// validator a configuration that holds the skeleton it insists on (one default
// group, one upstream) plus the one row under test. The row's own rules —
// domain syntax, record values, rule patterns, CIDR prefixes, source urls, TTL
// ranges — are then exactly the shipped ones, with no second copy to drift.
//
// Cross-row facts are deliberately NOT checked here: a duplicate is the
// database's UNIQUE constraint and answers 409 (ruling 9), and a group that
// does not exist is a foreign key and answers 409 too. Reporting either as a
// 400 would be a second, weaker opinion about the same fact.

const skeleton_group = "default";
const skeleton_upstream: model.UpstreamServer = .{ .url = "https://dns.example/dns-query" };

/// Runs the shipped validator over `cfg` and returns the first failure's text,
/// or null when the candidate is valid. The text is arena-allocated.
///
/// Failures only: a warning describes a configuration that is legal, and a row
/// this API is about to store cannot be rejected for one. The blocklist source
/// a POST creates is in no group yet — a warning by design, and never a 400.
pub fn firstProblem(arena: Allocator, cfg: model.Config) error{OutOfMemory}!?[]const u8 {
    var diags: validate.Diagnostics = .init(arena);
    defer diags.deinit();

    validate.validate(cfg, &diags) catch |err| switch (err) {
        error.OutOfMemory => return error.OutOfMemory,
        else => {},
    };
    const problem = diags.firstFailure() orelse return null;
    return try std.fmt.allocPrint(arena, "{s}: {s}", .{ problem.path, problem.message });
}

/// The configuration skeleton every candidate is validated inside.
fn skeleton(groups: []const model.Group) model.Config {
    return .{
        .upstreams = &.{skeleton_upstream},
        .groups = groups,
    };
}

const default_groups = [_]model.Group{.{ .name = skeleton_group }};

pub fn checkLocalRecord(arena: Allocator, record: model.LocalRecord) error{OutOfMemory}!?[]const u8 {
    var cfg = skeleton(&default_groups);
    cfg.local_records = &.{record};
    return firstProblem(arena, cfg);
}

pub fn checkForwardZone(arena: Allocator, zone: model.ForwardZone) error{OutOfMemory}!?[]const u8 {
    var cfg = skeleton(&default_groups);
    cfg.forward_zones = &.{zone};
    return firstProblem(arena, cfg);
}

pub fn checkRule(arena: Allocator, pattern: []const u8, kind: model.RuleKind) error{OutOfMemory}!?[]const u8 {
    var cfg = skeleton(&default_groups);
    cfg.rules = &.{.{ .group = skeleton_group, .pattern = pattern, .kind = kind, .action = .block }};
    return firstProblem(arena, cfg);
}

pub fn checkSource(arena: Allocator, source: model.BlocklistSource) error{OutOfMemory}!?[]const u8 {
    var cfg = skeleton(&default_groups);
    cfg.blocklist_sources = &.{source};
    return firstProblem(arena, cfg);
}

pub fn checkClientPrefix(arena: Allocator, prefix: []const u8, priority: i32) error{OutOfMemory}!?[]const u8 {
    var cfg = skeleton(&default_groups);
    cfg.client_prefixes = &.{.{ .prefix = prefix, .group = skeleton_group, .priority = priority }};
    return firstProblem(arena, cfg);
}

/// A group name is checked inside a configuration that already holds the
/// default group, so a candidate named anything else is still complete.
pub fn checkGroupName(arena: Allocator, name: []const u8) error{OutOfMemory}!?[]const u8 {
    if (std.mem.eql(u8, name, skeleton_group)) return firstProblem(arena, skeleton(&default_groups));
    const groups = [_]model.Group{ .{ .name = skeleton_group }, .{ .name = name } };
    return firstProblem(arena, skeleton(&groups));
}

/// An upstream candidate is validated next to one known-good enabled upstream,
/// so a disabled candidate does not trip the whole-config rule that at least
/// one upstream must be enabled — whether the stored set satisfies that rule is
/// the handler's own guard, not this row check's. The companion's url moves out
/// of the way of a candidate that holds the skeleton url, because a duplicate
/// is the database's answer, not the validator's.
pub fn checkUpstream(arena: Allocator, upstream: model.UpstreamServer) error{OutOfMemory}!?[]const u8 {
    const companion: model.UpstreamServer = if (std.mem.eql(u8, upstream.url, skeleton_upstream.url))
        .{ .url = "https://dns-b.example/dns-query" }
    else
        skeleton_upstream;
    var cfg = skeleton(&default_groups);
    cfg.upstreams = &.{ upstream, companion };
    return firstProblem(arena, cfg);
}

/// The 400 a malformed or unparseable body earns.
pub fn respondBadBody(request: *Request, err: anyerror) HandlerError!void {
    return switch (err) {
        error.TooLarge => http_util.respondError(request, .payload_too_large, "request body too large"),
        error.OutOfMemory => error.OutOfMemory,
        error.WriteFailed => error.WriteFailed,
        error.HttpExpectationFailed => error.HttpExpectationFailed,
        // A vanished peer mid-body is the same event as a vanished peer
        // mid-response, and ends the connection the same way (ruling 28).
        error.ReadFailed => error.WriteFailed,
        else => http_util.respondError(request, .bad_request, "malformed request body"),
    };
}

// ---------------------------------------------------------------------------
// reading the stored configuration
// ---------------------------------------------------------------------------

/// Every settings row and every collection, as one `model.Config`. The settings
/// PUT validates against this (ruling 16), so the check sees the same
/// configuration the next start would.
///
/// Every string belongs to `arena`.
pub fn loadConfig(arena: Allocator, database: *db.Db) db.Error!model.Config {
    var cfg: model.Config = .{};

    const pairs = try settings_repo.listSettings(database, arena);
    var unknown: usize = 0;
    model.fromSettings(pairs.items, &cfg, &unknown) catch |err| switch (err) {
        error.OutOfMemory => return error.OutOfMemory,
        // A stored value this build cannot decode is a corrupt row, not a
        // client error: the caller reports 500 and the operator sees the log.
        error.BadSettingValue => return error.Mismatch,
    };

    const groups = try groups_repo.listGroups(database, arena);
    cfg.groups = groups.items;
    const upstreams = try upstreams_repo.listUpstreams(database, arena);
    cfg.upstreams = upstreams.items;
    const clients = try clients_repo.listClients(database, arena);
    cfg.clients = clients.items;
    const prefixes = try clients_repo.listClientPrefixes(database, arena);
    cfg.client_prefixes = prefixes.items;
    const sources = try sources_repo.listBlocklistSources(database, arena);
    cfg.blocklist_sources = sources.items;
    const group_sources = try groups_repo.listGroupSources(database, arena);
    cfg.group_sources = group_sources.items;
    const rules = try rules_repo.listRules(database, arena);
    cfg.rules = rules.items;
    const records = try local_repo.listLocalRecords(database, arena);
    cfg.local_records = records.items;
    const zones = try local_repo.listForwardZones(database, arena);
    cfg.forward_zones = zones.items;

    return cfg;
}

// ---------------------------------------------------------------------------
// the test bench every handler in this directory shares
// ---------------------------------------------------------------------------

/// A running web layer with no sockets in it: an in-memory config database at
/// the current schema, the local-table holder, a request arena, and a
/// `reload_fn` that counts instead of rebuilding a snapshot.
///
/// `state` is a field rather than a pointer so the reload seam can find the
/// bench through `@fieldParentPtr` — a `WebState` carries no user data, and a
/// global counter would make two tests in one binary share it.
///
/// Referenced only by this directory's tests; nothing in a shipped build calls
/// `init`, so it costs nothing there.
pub const Bench = struct {
    threaded: std.Io.Threaded,
    database: db.Db,
    tables: local_tables.LocalTables,
    arena_state: std.heap.ArenaAllocator,
    state: server.WebState,
    reloads: usize,
    reload_fails: bool,

    /// Initialises in place: `state` points at fields of `self`.
    pub fn init(self: *Bench, gpa: Allocator) !void {
        self.threaded = .init(gpa, .{});
        errdefer self.threaded.deinit();

        self.database = try db.Db.open(":memory:", .{ .mode = .memory });
        errdefer self.database.close();
        try db.applyPragmas(&self.database, .{});
        _ = try migrations.migrate(&self.database);

        self.tables = .empty;
        self.arena_state = .init(gpa);
        self.reloads = 0;
        self.reload_fails = false;
        self.state = .{
            .gpa = gpa,
            .config_db = &self.database,
            .local_tables = &self.tables,
            .reload_fn = countingReload,
        };
    }

    pub fn deinit(self: *Bench, gpa: Allocator) void {
        self.state.live_hash.deinit(gpa);
        self.tables.deinit(gpa);
        self.arena_state.deinit();
        self.database.close();
        self.threaded.deinit();
    }

    pub fn io(self: *Bench) std.Io {
        return self.threaded.io();
    }

    pub fn arena(self: *Bench) Allocator {
        return self.arena_state.allocator();
    }

    /// One statement of setup, for the rows a case needs before it starts.
    pub fn exec(self: *Bench, sql: [:0]const u8) !void {
        try self.database.exec(sql);
    }

    pub fn queryInt(self: *Bench, sql: []const u8) !i64 {
        return self.database.queryInt(sql);
    }

    fn countingReload(state: *server.WebState, io_unused: std.Io) anyerror!void {
        _ = io_unused;
        const self: *Bench = @alignCast(@fieldParentPtr("state", state));
        self.reloads += 1;
        if (self.reload_fails) return error.ReloadFailed;
    }
};

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

const testing = std.testing;

fn arenaFor(state: *std.heap.ArenaAllocator) Allocator {
    return state.allocator();
}

test "the bench wires a state whose reload seam counts" {
    var bench: Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    try testing.expect(bench.state.config_db != null);
    try testing.expectEqual(@as(?Failure, null), reload(&bench.state, bench.io()));
    try testing.expectEqual(@as(usize, 1), bench.reloads);

    bench.reload_fails = true;
    try testing.expectEqual(Failure.not_applied, reload(&bench.state, bench.io()).?);
    try testing.expectEqual(@as(usize, 2), bench.reloads);
}

test "the schema the bench opens already holds the default group" {
    var bench: Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT id FROM groups WHERE name = 'default'"));
}

test "a state with no configuration database is a 503, not a crash" {
    var bench: Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    try testing.expectEqual(&bench.database, try requireConfigDb(&bench.state));

    var bare: server.WebState = .{ .gpa = testing.allocator };
    try testing.expectError(error.NoConfigDb, requireConfigDb(&bare));
    try testing.expectEqualStrings("no configuration database", no_config_db.unavailable);
}

test "a database error maps to the status its cause deserves" {
    try testing.expectEqual(Failure.not_found, dbFailure(error.NotFound, "x"));
    try testing.expectEqualStrings("taken", dbFailure(error.Constraint, "taken").conflict);
    try testing.expectEqual(db.Error.Busy, dbFailure(error.Busy, "x").internal);
}

test "a valid candidate row reports no problem" {
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();
    const arena = arenaFor(&arena_state);

    try testing.expectEqual(
        @as(?[]const u8, null),
        try checkLocalRecord(arena, .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 60 }),
    );
    try testing.expectEqual(
        @as(?[]const u8, null),
        try checkForwardZone(arena, .{ .zone = "lan", .resolver = "udp://10.0.0.1:53" }),
    );
    try testing.expectEqual(@as(?[]const u8, null), try checkRule(arena, "*.ads.example", .wildcard));
    try testing.expectEqual(@as(?[]const u8, null), try checkClientPrefix(arena, "192.168.1.0/24", 100));
    try testing.expectEqual(@as(?[]const u8, null), try checkGroupName(arena, "kids"));
    try testing.expectEqual(@as(?[]const u8, null), try checkGroupName(arena, "default"));
    try testing.expectEqual(
        @as(?[]const u8, null),
        try checkSource(arena, .{ .url = "https://example.test/list.txt", .name = "list" }),
    );
    try testing.expectEqual(
        @as(?[]const u8, null),
        try checkUpstream(arena, .{ .url = "tls://1.1.1.1:853", .tls_name = "one.one.one.one" }),
    );
}

test "a disabled upstream candidate is valid on its own merits" {
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();
    const arena = arenaFor(&arena_state);

    try testing.expectEqual(
        @as(?[]const u8, null),
        try checkUpstream(arena, .{ .url = "https://dns.other/dns-query", .enabled = false }),
    );
    // The skeleton's own url must not read as a duplicate of the companion.
    try testing.expectEqual(
        @as(?[]const u8, null),
        try checkUpstream(arena, .{ .url = skeleton_upstream.url, .enabled = false }),
    );
    // A disabled row's other fields are still judged.
    const bad = try checkUpstream(arena, .{ .url = "udp://1.1.1.1:53", .enabled = false });
    try testing.expect(bad != null);
}

test "an invalid candidate row names the field that failed" {
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();
    const arena = arenaFor(&arena_state);

    const bad_value = try checkLocalRecord(
        arena,
        .{ .name = "nas.lan", .rtype = .a, .value = "not-an-ip", .ttl = 60 },
    );
    try testing.expect(bad_value != null);
    try testing.expect(std.mem.startsWith(u8, bad_value.?, "local_records[0].value:"));

    const bad_ttl = try checkLocalRecord(
        arena,
        .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 0 },
    );
    try testing.expect(bad_ttl != null);

    const bad_resolver = try checkForwardZone(arena, .{ .zone = "lan", .resolver = "http://10.0.0.1" });
    try testing.expect(bad_resolver != null);

    const bad_pattern = try checkRule(arena, "ads.*.example", .exact);
    try testing.expect(bad_pattern != null);

    const bad_prefix = try checkClientPrefix(arena, "192.168.1.0", 100);
    try testing.expect(bad_prefix != null);

    const empty_group = try checkGroupName(arena, "");
    try testing.expect(empty_group != null);

    const bad_source = try checkSource(arena, .{ .url = "ftp://example.test/list", .name = "list" });
    try testing.expect(bad_source != null);
}

test "a candidate is judged alone, so a duplicate is left to the database" {
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();
    const arena = arenaFor(&arena_state);

    // The same zone twice would be `DuplicateForwardZone` in a whole config;
    // one candidate row cannot collide with itself, and the UNIQUE constraint
    // is what answers 409.
    try testing.expectEqual(
        @as(?[]const u8, null),
        try checkForwardZone(arena, .{ .zone = "lan", .resolver = "udp://10.0.0.1:53" }),
    );
}
