//! `GET`/`PUT /api/settings` — the scalar configuration, the rows of the
//! `settings` table (ruling 16).
//!
//! Every key applies live except the two that create or destroy a socket —
//! `bind` (a listener's address, port or existence) and `web_lifecycle`
//! (`web.enabled`) — which set `restart_pending` for milestone 35 to execute.
//! `restart_required` in the response is that pair of key groups and nothing
//! else. `apply.zig` holds the table that decides which owner a key belongs to
//! and drives the prepare → commit → publish → retire the write contract asks
//! for; a section added to the model fails to compile until its keys have
//! owners there.
//!
//! `web.password` is write-only and `web.password_hash` is neither readable nor
//! directly writable. A PUT carrying `web.password` hashes it with the import
//! path's argon2id parameters and stores the hash alone (PLAN §19: the plain
//! password is never stored, never logged, never echoed). Changing the hash
//! ends every session, because the old cookies were minted under the old
//! password.
//!
//! A PUT is partial: a section left out, or a field left out of a section, keeps
//! what is stored. The merged configuration is validated whole — the same check
//! the next start runs — before a single row is written, so a settings PUT
//! cannot leave a configuration the server would refuse to boot from.

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

const apply = @import("apply.zig");
const auth = @import("../auth.zig");
const db = @import("../../storage/db.zig");
const http_util = @import("../http_util.zig");
const model = @import("../../config/model.zig");
const mutations = @import("mutations.zig");
const server = @import("../server.zig");
const settings_repo = @import("../../storage/repositories/settings_repo.zig");

const Failure = mutations.Failure;
const Request = http_util.Request;
const HandlerError = http_util.HandlerError;

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

/// Holds any PHC-encoded argon2id string comfortably (import.zig's number).
/// Equal to the live holder's capacity by construction, so a hash written here
/// always fits the copy `applyLogin` takes.
const hash_buf_len = auth.LiveHash.max_len;

const isHidden = apply.isHidden;
const isWriteOnly = apply.isWriteOnly;
const isScalarSection = apply.isScalarSection;

/// The keys whose change waits for a restart: `bind` and `web_lifecycle`, the
/// two operations milestone 35 executes. The apply table is the authority.
pub const restart_required_keys = apply.restart_required_keys;

// ---------------------------------------------------------------------------
// the patch a PUT carries
// ---------------------------------------------------------------------------

/// `Section` with every field optional, so an absent field means "leave it".
/// Generated rather than written out: a hand-copied mirror of `model.Config`
/// would drift the first time a setting is added.
fn Partial(comptime Section: type, comptime section_name: []const u8) type {
    const info = @typeInfo(Section).@"struct";
    var names: [info.fields.len][:0]const u8 = undefined;
    var types: [info.fields.len]type = undefined;
    var attrs: [info.fields.len]std.builtin.Type.StructField.Attributes = undefined;
    var count: usize = 0;
    for (info.fields) |field| {
        if (isHidden(section_name, field.name)) continue;
        const Field = ?FieldType(field.type);
        const default: Field = null;
        names[count] = field.name;
        types[count] = Field;
        attrs[count] = .{ .default_value_ptr = @ptrCast(&default) };
        count += 1;
    }
    const final_names = names[0..count].*;
    const final_types = types[0..count].*;
    const final_attrs = attrs[0..count].*;
    return @Struct(.auto, null, &final_names, &final_types, &final_attrs);
}

/// Enums arrive as the words the database stores, so they are parsed from text
/// rather than by tag name (`logging.level` is `error`, whose tag cannot be).
///
/// An optional model field collapses to its child, because `Partial` wraps
/// every field in one optional of its own and that optional already carries the
/// only meaning a PUT has for absence — "leave it". A double optional would be
/// two ways to say the same thing, and `std.json` cannot parse the outer one.
fn FieldType(comptime T: type) type {
    return switch (@typeInfo(T)) {
        .@"enum" => []const u8,
        .optional => |info| FieldType(info.child),
        else => T,
    };
}

/// The whole PUT body: every section optional, every field optional.
pub const Patch = blk: {
    const config_fields = @typeInfo(model.Config).@"struct".fields;
    var names: [config_fields.len][:0]const u8 = undefined;
    var types: [config_fields.len]type = undefined;
    var attrs: [config_fields.len]std.builtin.Type.StructField.Attributes = undefined;
    var count: usize = 0;
    for (config_fields) |section_field| {
        if (!isScalarSection(section_field.type)) continue;
        const Section = ?Partial(section_field.type, section_field.name);
        const default: Section = null;
        names[count] = section_field.name;
        types[count] = Section;
        attrs[count] = .{ .default_value_ptr = @ptrCast(&default) };
        count += 1;
    }
    const final_names = names[0..count].*;
    const final_types = types[0..count].*;
    const final_attrs = attrs[0..count].*;
    break :blk @Struct(.auto, null, &final_names, &final_types, &final_attrs);
};

/// Applies `patch` onto `cfg`. A word an enum does not know is the one failure
/// this can report, and it names the key.
fn merge(cfg: *model.Config, patch: Patch, bad_key: *[]const u8) bool {
    inline for (@typeInfo(Patch).@"struct".fields) |section_field| {
        if (@field(patch, section_field.name)) |section| {
            inline for (@typeInfo(@TypeOf(section)).@"struct".fields) |field| {
                if (@field(section, field.name)) |value| {
                    const Target = @TypeOf(@field(@field(cfg, section_field.name), field.name));
                    if (@typeInfo(Target) == .@"enum") {
                        const decoded = Target.fromDb(value) orelse {
                            bad_key.* = section_field.name ++ "." ++ field.name;
                            return false;
                        };
                        @field(@field(cfg, section_field.name), field.name) = decoded;
                    } else {
                        @field(@field(cfg, section_field.name), field.name) = value;
                    }
                }
            }
        }
    }
    return true;
}

/// Whether the patch carries a new password.
fn newPassword(patch: Patch) ?[]const u8 {
    const web = patch.web orelse return null;
    const password = web.password orelse return null;
    if (password.len == 0) return null;
    return password;
}

// ---------------------------------------------------------------------------
// the read shape
// ---------------------------------------------------------------------------

const BlockingView = struct { response: []const u8, ttl: u32 };
const EdnsView = struct { ecs_mode: []const u8 };

const LoggingView = struct {
    level: []const u8,
    retention_days: u16,
    query_log_buffer_max: u32,
    query_log_flush_interval_s: u16,
    hide_domains: bool,
    hide_client_ips: bool,
    output: []const u8,
    file_path: []const u8,
    max_size_mb: u32,
    max_files: u8,
};

const WebView = struct {
    enabled: bool,
    bind: []const u8,
    port: u16,
    session_ttl_hours: u16,
    api_rate_limit_per_min: u32,
    api_localhost_exempt: bool,
    sse_max_connections_per_ip: u16,
    trusted_proxies: []const u8,
    /// Derived, not stored: the hash itself is never serialized, and the UI
    /// still has to know whether a password is set.
    auth_enabled: bool,
};

pub const View = struct {
    upstream: model.Upstream,
    dns: model.Dns,
    blocking: BlockingView,
    cache: model.Cache,
    web: WebView,
    doh_server: model.TlsEndpoint,
    dot_server: model.TlsEndpoint,
    edns: EdnsView,
    logging: LoggingView,
    disk: model.Disk,
    blocklist_update: model.BlocklistUpdate,
};

pub fn view(cfg: model.Config) View {
    return .{
        .upstream = cfg.upstream,
        .dns = cfg.dns,
        .blocking = .{ .response = cfg.blocking.response.toDb(), .ttl = cfg.blocking.ttl },
        .cache = cfg.cache,
        .web = .{
            .enabled = cfg.web.enabled,
            .bind = cfg.web.bind,
            .port = cfg.web.port,
            .session_ttl_hours = cfg.web.session_ttl_hours,
            .api_rate_limit_per_min = cfg.web.api_rate_limit_per_min,
            .api_localhost_exempt = cfg.web.api_localhost_exempt,
            .sse_max_connections_per_ip = cfg.web.sse_max_connections_per_ip,
            .trusted_proxies = cfg.web.trusted_proxies,
            .auth_enabled = auth.authEnabled(cfg.web),
        },
        .doh_server = cfg.doh_server,
        .dot_server = cfg.dot_server,
        .edns = .{ .ecs_mode = cfg.edns.ecs_mode.toDb() },
        .logging = .{
            .level = cfg.logging.level.toDb(),
            .retention_days = cfg.logging.retention_days,
            .query_log_buffer_max = cfg.logging.query_log_buffer_max,
            .query_log_flush_interval_s = cfg.logging.query_log_flush_interval_s,
            .hide_domains = cfg.logging.hide_domains,
            .hide_client_ips = cfg.logging.hide_client_ips,
            .output = cfg.logging.output.toDb(),
            .file_path = cfg.logging.file_path,
            .max_size_mb = cfg.logging.max_size_mb,
            .max_files = cfg.logging.max_files,
        },
        .disk = cfg.disk,
        .blocklist_update = cfg.blocklist_update,
    };
}

// ---------------------------------------------------------------------------
// decisions
// ---------------------------------------------------------------------------

/// Reads, merges, validates, writes, and — when the password changed — ends
/// every session. Returns the configuration as it now stands.
fn applyPut(
    state: *server.WebState,
    io: std.Io,
    arena: Allocator,
    patch: Patch,
) error{OutOfMemory}!union(enum) { config: model.Config, fail: Failure } {
    const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };

    // Ruling 18 of milestone 16: argon2id at m=19 MiB is the longest thing this
    // handler does, and its input is the parsed patch alone — nothing under the
    // lock. Hashing inside the lock stalled every settings read and every other
    // mutation for its duration. The login path already hashes unlocked
    // (auth.zig), and `LiveHash`'s generation check closes the install race.
    const password = newPassword(patch);
    var new_hash: []const u8 = "";
    if (password) |plain| {
        if (plain.len > auth.max_password_len) {
            return .{ .fail = .{ .invalid = "web.password is too long" } };
        }
        const buf = try arena.alloc(u8, hash_buf_len);
        new_hash = hashPassword(io, arena, plain, buf) catch |err| switch (err) {
            error.OutOfMemory => return error.OutOfMemory,
            error.Canceled => return .{ .fail = .{ .unavailable = "shutting down" } },
            else => return .{ .fail = .{ .internal = error.Unexpected } },
        };
    }

    state.config_lock.lockUncancelable(io);
    defer state.config_lock.unlock(io);

    const stored = mutations.loadConfig(arena, database) catch |err| switch (err) {
        error.OutOfMemory => return error.OutOfMemory,
        else => return .{ .fail = .{ .internal = err } },
    };
    var cfg = stored;

    var bad_key: []const u8 = "";
    if (!merge(&cfg, patch, &bad_key)) {
        return .{ .fail = .{ .invalid = try std.fmt.allocPrint(
            arena,
            "{s}: not one of the values this setting accepts",
            .{bad_key},
        ) } };
    }

    // The password never becomes a row: the hash made above is what the merged
    // configuration — and therefore the settings table — carries.
    const previous_hash = cfg.web.password_hash orelse "";
    if (password != null) cfg.web.password_hash = new_hash;
    cfg.web.password = null;

    if (try problem(arena, cfg)) |text| return .{ .fail = .{ .invalid = text } };

    // The gpa copy the live holder will own, made before the write so a
    // committed transaction can never be followed by a failed revocation.
    const merged_hash = cfg.web.password_hash orelse "";
    const hash_changed = password != null and !std.mem.eql(u8, previous_hash, merged_hash);
    const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, merged_hash) else null;
    errdefer if (replacement) |hash| state.gpa.free(hash);

    // Prepare: one candidate per owner whose value actually moved, built from
    // the merged configuration. Nothing is published and no row is written
    // until every one of them succeeded.
    const ops = apply.changedOperations(stored, cfg);
    var plan: apply.Plan = .init(state, arena, cfg, ops);
    if (try plan.prepare(io)) |failure| {
        plan.abandon(io);
        if (replacement) |hash| state.gpa.free(hash);
        return .{ .fail = failure };
    }

    writeSettings(arena, database, cfg) catch |err| {
        plan.abandon(io);
        if (replacement) |hash| state.gpa.free(hash);
        return .{ .fail = .{ .internal = err } };
    };

    plan.publish(io);
    plan.retire(io);

    // After the commit, never before: a validation failure or a write that
    // rolled back changed nothing, so it owes nobody a restart. Only the two
    // operations milestone 35 executes raise it; everything else is already
    // live by the time this line runs.
    var restart_owed = false;
    var it = ops.iterator();
    while (it.next()) |operation| {
        if (operation.needsRestart()) restart_owed = true;
    }
    if (restart_owed) state.restart_pending.store(true, .monotonic);

    if (replacement) |hash| {
        // Ruling 17, both halves: the running server must verify against the
        // new hash at once — a restart-free credential change — and the
        // cookies in flight were minted under the old password. One
        // LiveHash-ordered operation: a login with the new password cannot
        // mint between the hash swap and the revocation and then lose its
        // fresh cookie to it.
        state.live_hash.installAndRevoke(io, state.gpa, state.sessions, hash);
    }

    return .{ .config = cfg };
}

/// Writes every key of `cfg` in one transaction. Rewriting the unchanged rows
/// costs a few dozen upserts and buys the guarantee that the table is exactly
/// what `model.toSettings` says the merged configuration is — no key can be
/// missed and none can be left behind.
fn writeSettings(arena: Allocator, database: *db.Db, cfg: model.Config) db.Error!void {
    var pairs: std.ArrayList(model.SettingPair) = .empty;
    try model.toSettings(cfg, arena, &pairs);

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

    try write_fault.check();
    for (pairs.items) |pair| {
        try settings_repo.putSetting(database, pair.key, pair.value);
    }
    // `toSettings` stops at `web.password_hash` (ruling 4 of milestone 20: the
    // reconcile engine owns that row, because only it can tell "the file said
    // nothing" from "the file said empty"). A PUT has no such ambiguity — the
    // merged configuration is the whole truth — so this handler writes the row
    // itself rather than losing the password change.
    try settings_repo.putSetting(database, "web.password_hash", cfg.web.password_hash orelse "");
    try tx.commit();
}

/// Fails one `writeSettings` after its transaction has begun, so a test can
/// prove the `errdefer` above rolls that transaction back rather than leaving
/// the shared connection inside it. Test builds only, and it reduces to nothing
/// everywhere else — the rotation seam's shape (logging.zig).
const write_fault = if (builtin.is_test) struct {
    var armed: bool = false;

    fn check() db.Error!void {
        if (!armed) return;
        armed = false;
        return error.Internal;
    }
} else struct {
    fn check() db.Error!void {}
};

fn problem(arena: Allocator, cfg: model.Config) error{OutOfMemory}!?[]const u8 {
    return mutations.firstProblem(arena, cfg);
}

/// argon2id with the import path's parameters (OWASP t=2, m=19 MiB, p=1), so a
/// password set through the API and one set through a config import produce the
/// same kind of hash.
fn hashPassword(io: std.Io, gpa: Allocator, password: []const u8, buf: []u8) ![]const u8 {
    hash_stall.park(io);
    return std.crypto.pwhash.argon2.strHash(password, .{
        .allocator = gpa,
        .params = .owasp_2id,
        .mode = .argon2id,
        .encoding = .phc,
    }, buf, io) catch |err| switch (err) {
        error.OutOfMemory => error.OutOfMemory,
        error.Canceled => error.Canceled,
        else => {
            // Never the password, never the hash: only what went wrong.
            log.warn("hashing the new web password failed: {s}", .{@errorName(err)});
            return error.Unexpected;
        },
    };
}

/// Holds a hash still so a test can prove another request runs beside it. The
/// hash finishing on its own would prove nothing: before ruling 18 a settings
/// GET also completed, it merely waited out the hash first. The storage exists
/// in a test build only, and `park` reduces to nothing everywhere else — the
/// rotation seam's shape (logging.zig).
const hash_stall = if (builtin.is_test) struct {
    var armed: bool = false;
    var parked: std.Io.Event = .unset;
    var release: std.Io.Event = .unset;

    fn park(io: std.Io) void {
        if (!armed) return;
        parked.set(io);
        release.waitUncancelable(io);
    }
} else struct {
    fn park(io: std.Io) void {
        _ = io;
    }
};

/// The seam's controls, for the test that proves a settings read runs beside a
/// hash in flight. Present in a test build only.
pub const hash_stall_control = if (builtin.is_test) struct {
    pub fn arm() void {
        hash_stall.parked = .unset;
        hash_stall.release = .unset;
        hash_stall.armed = true;
    }

    /// Returns once a hash is parked on the seam.
    pub fn waitParked(io: std.Io) void {
        hash_stall.parked.waitUncancelable(io);
    }

    /// Lets the parked hash finish and disarms the seam for the next test.
    pub fn release(io: std.Io) void {
        hash_stall.armed = false;
        hash_stall.release.set(io);
    }
} else struct {};

// ---------------------------------------------------------------------------
// routes
// ---------------------------------------------------------------------------

pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
    const database = mutations.requireConfigDb(state) catch
        return mutations.respondFailure(request, mutations.no_config_db, "reading the settings");

    // Under the same lock the mutation handlers hold: a PUT rewrites every
    // settings row in one transaction on this shared connection, and SQLite's
    // own mutex serializes statements, not transactions — an unlocked read
    // could see half a PUT. Released before responding, like the mutations.
    state.config_lock.lockUncancelable(io);
    const loaded = mutations.loadConfig(request.arena, database);
    state.config_lock.unlock(io);

    const cfg = loaded catch |err|
        return mutations.respondFailure(request, .{ .internal = err }, "reading the settings");

    return respondSettings(request, .ok, cfg);
}

pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
    const parsed = http_util.parseBody(Patch, request) catch |err|
        return mutations.respondBadBody(request, err);

    return switch (try applyPut(state, io, request.arena, parsed.value)) {
        .fail => |failure| mutations.respondFailure(request, failure, "writing the settings"),
        .config => |cfg| respondSettings(request, .ok, cfg),
    };
}

/// `restart_required` is field-level metadata about this form — which keys need
/// a restart to take effect. Whether one is *owed* right now is process state
/// and lives on `GET /api/config/status`, which is also the one home of the
/// configuration authority: two copies of the same fact drift.
fn respondSettings(request: *Request, status: std.http.Status, cfg: model.Config) HandlerError!void {
    return http_util.respondJson(request, status, .{
        .settings = view(cfg),
        .restart_required = restart_required_keys,
    }, &.{});
}

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

const testing = std.testing;
const auth_handlers = @import("auth.zig");

test "the restart-required list is a strict subset of the settings keys" {
    // `model.toSettings` is the key list this form writes. The restart list is
    // now the small part of it that milestone 35 owns, so it must be shorter
    // than the key list and every entry must still be a real key.
    var pairs: std.ArrayList(model.SettingPair) = .empty;
    defer {
        model.freeSettings(testing.allocator, pairs.items);
        pairs.deinit(testing.allocator);
    }
    try model.toSettings(.{}, testing.allocator, &pairs);

    try testing.expect(restart_required_keys.len < pairs.items.len);
    for (restart_required_keys) |key| {
        var is_a_setting = false;
        for (pairs.items) |pair| {
            if (std.mem.eql(u8, pair.key, key)) is_a_setting = true;
        }
        try testing.expect(is_a_setting);
    }

    var found_port = false;
    var found_ttl = false;
    for (restart_required_keys) |key| {
        if (std.mem.eql(u8, key, "dns.port")) found_port = true;
        if (std.mem.eql(u8, key, "blocking.ttl")) found_ttl = true;
    }
    try testing.expect(found_port);
    // A live key must not appear: the UI renders no restart affordance for it.
    try testing.expect(!found_ttl);
}

test "the read shape spells every enum the way the database does" {
    const rendered = view(.{
        .logging = .{ .level = .err, .output = .file },
        .blocking = .{ .response = .nxdomain },
        .edns = .{ .ecs_mode = .forward },
    });
    try testing.expectEqualStrings("error", rendered.logging.level);
    try testing.expectEqualStrings("file", rendered.logging.output);
    try testing.expectEqualStrings("nxdomain", rendered.blocking.response);
    try testing.expectEqualStrings("forward", rendered.edns.ecs_mode);
    try testing.expect(!rendered.web.auth_enabled);

    const with_password = view(.{ .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$a$b" } });
    try testing.expect(with_password.web.auth_enabled);
}

test "the patch type has no password_hash field and every field is optional" {
    const WebPatch = @typeInfo(@FieldType(Patch, "web")).optional.child;
    comptime var has_password = false;
    inline for (@typeInfo(WebPatch).@"struct".fields) |field| {
        comptime std.debug.assert(@typeInfo(field.type) == .optional);
        comptime std.debug.assert(!std.mem.eql(u8, field.name, "password_hash"));
        if (comptime std.mem.eql(u8, field.name, "password")) has_password = true;
    }
    try testing.expect(has_password);
}

fn seeded(bench: *mutations.Bench) !void {
    try bench.exec(
        \\INSERT INTO upstreams (url, priority, enabled) VALUES ('https://dns.example/dns-query', 100, 1);
        \\INSERT INTO settings (key, value) VALUES ('dns.port', '53'), ('logging.level', 'info');
    );
}

test "a partial put changes the keys it names and keeps the rest" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);
    try seeded(&bench);

    var patch: Patch = .{};
    patch.dns = .{ .port = 5353 };
    patch.logging = .{ .level = "debug" };

    const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
    try testing.expectEqual(@as(u16, 5353), outcome.config.dns.port);
    try testing.expectEqual(model.LogLevel.debug, outcome.config.logging.level);
    // Untouched keys keep their stored value, not the model default.
    try testing.expectEqual(@as(u32, 1000), outcome.config.dns.rate_limit);

    const stored = try mutations.loadConfig(bench.arena(), &bench.database);
    try testing.expectEqual(@as(u16, 5353), stored.dns.port);
    try testing.expectEqual(model.LogLevel.debug, stored.logging.level);
}

test "a put that would not validate writes nothing" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);
    try seeded(&bench);

    var patch: Patch = .{};
    patch.dns = .{ .port = 0 };

    const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
    try testing.expect(outcome.fail == .invalid);

    const stored = try mutations.loadConfig(bench.arena(), &bench.database);
    try testing.expectEqual(@as(u16, 53), stored.dns.port);
}

test "a write that fails after the transaction begins rolls it back" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);
    try seeded(&bench);

    var patch: Patch = .{};
    patch.dns = .{ .port = 5353 };

    write_fault.armed = true;
    const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
    try testing.expect(outcome.fail == .internal);

    // BEGIN IMMEDIATE inside an open transaction is an error, so a second
    // `begin` succeeding is what proves the errdefer ran.
    var tx = try db.Tx.begin(&bench.database);
    tx.rollback();

    const stored = try mutations.loadConfig(bench.arena(), &bench.database);
    try testing.expectEqual(@as(u16, 53), stored.dns.port);
}

test "an enum value the model does not know names the key it came from" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);
    try seeded(&bench);

    var patch: Patch = .{};
    patch.logging = .{ .level = "verbose" };

    const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
    try testing.expect(std.mem.startsWith(u8, outcome.fail.invalid, "logging.level:"));
}

test "a new password is stored as a hash and ends every session" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);
    try seeded(&bench);

    var sessions: auth.Sessions = .init(24);
    bench.state.sessions = &sessions;
    const cookie = sessions.createWithToken(bench.io(), @splat(7), 1_000);
    try testing.expect(sessions.validateAt(bench.io(), &cookie, 1_001));

    var patch: Patch = .{};
    patch.web = .{ .password = "correct horse battery staple" };

    const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
    try testing.expect(std.mem.startsWith(u8, outcome.config.web.password_hash.?, "$argon2id$"));
    try testing.expect(!sessions.validateAt(bench.io(), &cookie, 1_001));

    // The plain password is nowhere in the table, and the hash is.
    try testing.expectEqual(
        @as(i64, 0),
        try bench.queryInt("SELECT count(*) FROM settings WHERE key = 'web.password'"),
    );
    const stored = try mutations.loadConfig(bench.arena(), &bench.database);
    try testing.expect(std.mem.startsWith(u8, stored.web.password_hash.?, "$argon2id$"));
    try testing.expectEqual(
        auth.Outcome.ok,
        try auth.verifyPassword(bench.io(), testing.allocator, stored.web.password_hash.?, "correct horse battery staple"),
    );
}

test "changing the password revokes the old one without a restart" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);
    try seeded(&bench);

    var sessions: auth.Sessions = .init(24);
    bench.state.sessions = &sessions;

    var first: Patch = .{};
    first.web = .{ .password = "old password" };
    try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), first) == .config);

    const old_login = auth_handlers.applyLogin(&bench.state, bench.io(), "old password");
    try testing.expect(sessions.validate(bench.io(), &old_login.cookie));

    var second: Patch = .{};
    second.web = .{ .password = "new password" };
    try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), second) == .config);

    // The session minted under the old password is dead...
    try testing.expect(!sessions.validate(bench.io(), &old_login.cookie));
    // ...the old password no longer mints one...
    try testing.expect(auth_handlers.applyLogin(&bench.state, bench.io(), "old password").fail == .invalid);
    // ...and the new one works immediately, no restart in between.
    const new_login = auth_handlers.applyLogin(&bench.state, bench.io(), "new password");
    try testing.expect(sessions.validate(bench.io(), &new_login.cookie));
}

test "a put that does not carry a password leaves the sessions alone" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);
    try seeded(&bench);

    var sessions: auth.Sessions = .init(24);
    bench.state.sessions = &sessions;
    const cookie = sessions.createWithToken(bench.io(), @splat(9), 1_000);

    var patch: Patch = .{};
    patch.cache = .{ .size = 5000 };
    _ = try applyPut(&bench.state, bench.io(), bench.arena(), patch);

    try testing.expect(sessions.validateAt(bench.io(), &cookie, 1_001));
}

test "an empty password is not a password change" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);
    try seeded(&bench);

    var patch: Patch = .{};
    patch.web = .{ .password = "" };

    const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
    try testing.expectEqualStrings("", outcome.config.web.password_hash orelse "");
}

test "a write that fails at the SQL layer owes no restart" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);
    try seeded(&bench);

    var patch: Patch = .{};
    patch.dns = .{ .port = 5353 };

    // The seam fails `writeSettings` after its transaction has begun, which is
    // the ordering this pins: the flag is stored only once the commit lands, so
    // a rolled-back write leaves the operator nothing to restart for.
    write_fault.armed = true;
    const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
    try testing.expect(outcome.fail == .internal);
    try testing.expect(!bench.state.restart_pending.load(.monotonic));

    // The same patch, written for real, does raise it.
    try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), patch) == .config);
    try testing.expect(bench.state.restart_pending.load(.monotonic));
}

test "a rejected patch raises no restart flag" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);
    try seeded(&bench);

    var invalid: Patch = .{};
    invalid.dns = .{ .port = 0 };
    try testing.expect((try applyPut(&bench.state, bench.io(), bench.arena(), invalid)).fail == .invalid);
    try testing.expect(!bench.state.restart_pending.load(.monotonic));

    var unknown_enum: Patch = .{};
    unknown_enum.logging = .{ .level = "verbose" };
    try testing.expect((try applyPut(&bench.state, bench.io(), bench.arena(), unknown_enum)).fail == .invalid);
    try testing.expect(!bench.state.restart_pending.load(.monotonic));
}

test "only a bind or web-lifecycle change raises the flag" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);
    try seeded(&bench);

    // A live key, however many of them: nothing to restart for.
    var live: Patch = .{};
    live.blocking = .{ .ttl = 30 };
    live.disk = .{ .min_free_mb = 10, .warn_free_mb = 20 };
    try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), live) == .config);
    try testing.expect(!bench.state.restart_pending.load(.monotonic));

    // `web.enabled` is the second of the two milestone-35 operations, and it
    // executes nothing: the row moves and the flag rises.
    var lifecycle: Patch = .{};
    lifecycle.web = .{ .enabled = false };
    try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), lifecycle) == .config);
    try testing.expect(bench.state.restart_pending.load(.monotonic));
    try testing.expectEqual(
        @as(i64, 1),
        try bench.queryInt("SELECT count(*) FROM settings WHERE key = 'web.enabled' AND value = 'false'"),
    );
}

test "a patch that changes nothing applies nothing" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);
    try seeded(&bench);

    // The stored port is 53, and the admin form submits every field it read.
    // Rewriting a value to itself is not a change and must not raise the flag.
    var same: Patch = .{};
    same.dns = .{ .port = 53 };
    try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), same) == .config);
    try testing.expect(!bench.state.restart_pending.load(.monotonic));
}

test "a password-only put leaves the restart flag alone" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);
    try seeded(&bench);

    var sessions: auth.Sessions = .init(24);
    bench.state.sessions = &sessions;

    var patch: Patch = .{};
    patch.web = .{ .password = "correct horse battery staple" };
    try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), patch) == .config);
    try testing.expect(!bench.state.restart_pending.load(.monotonic));
}

test "reading the settings with no database is unavailable" {
    var state: server.WebState = .{ .gpa = testing.allocator };
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();

    const outcome = try applyPut(&state, undefined, arena_state.allocator(), .{});
    try testing.expect(outcome.fail == .unavailable);
}
