//! The apply table and the prepare → commit → publish → retire machinery a
//! configuration write drives.
//!
//! Every settings key names one CONCRETE operation — the owner that has to be
//! told, not a class of change. `bind` and `web_lifecycle` are the two that
//! cannot be executed in-process: they create or destroy a socket, so they set
//! `restart_pending` and milestone 35 executes them. Everything else applies
//! live, and no response or document claims otherwise.
//!
//! The four phases are the write contract, and `Plan` is what carries state
//! between them:
//!
//! 1. `prepare` builds and validates ONE candidate per affected owner from the
//!    FINAL MERGED configuration. It is the only fallible phase. A failure
//!    leaves the database and the running server untouched, and the caller
//!    calls `abandon`.
//! 2. The caller commits its transaction. A commit failure is also `abandon`.
//! 3. `publish` swaps handles and pointers and stores atomics. Infallible and
//!    I/O-free: nothing here opens, closes, joins or frees.
//! 4. `retire` closes and frees what publish displaced, and reconciles the
//!    upstream diagnostics — event rows are SQLite I/O and are banned from a
//!    publish.
//!
//! An operation whose owner this `WebState` does not have is skipped: the web
//! layer must build without a whole running server, and a handler test wires
//! only what it asserts on. The database row is still written, because the row
//! is the configuration and the runtime is a consumer of it.

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

const cert_store = @import("../../server/cert_store.zig");
const dns_cache = @import("../../cache/dns_cache.zig");
const dns_handler = @import("../../server/handler.zig");
const disk_monitor = @import("../../storage/disk_monitor.zig");
const logger_controller = @import("../../storage/logger_controller.zig");
const logging = @import("../../platform/logging.zig");
const model = @import("../../config/model.zig");
const mutations = @import("mutations.zig");
const rate_limiter = @import("../../server/rate_limiter.zig");
const server = @import("../server.zig");
const upstream_owner = @import("../../upstream/owner.zig");
const upstreams_repo = @import("../../storage/repositories/upstreams_repo.zig");

const Failure = mutations.Failure;

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

// ---------------------------------------------------------------------------
// the key table
// ---------------------------------------------------------------------------

/// The owner a settings key belongs to. One entry per owner, never one per key:
/// two keys of the same owner produce ONE candidate.
pub const Operation = enum {
    dns_policy,
    trusted_proxies,
    logger_privacy,
    logger_flush,
    disk_thresholds,
    upstream_generation,
    cache,
    rate_limiter,
    sessions_ttl,
    api_limiter,
    retention,
    certs_doh,
    certs_dot,
    log_sink,
    scheduler,
    logger_queue,
    /// A listener's address, port, or existence. Milestone 35 executes these.
    bind,
    /// `web.enabled`. Milestone 35 executes it.
    web_lifecycle,

    /// The broad class, DERIVED rather than listed a second time: a second
    /// table would be a second authority, and the two would drift.
    pub fn class(self: Operation) Class {
        return switch (self) {
            .bind => .bind,
            .web_lifecycle => .web_lifecycle,
            // Everything a candidate has to be built for before it can be
            // published: memory, a file, a connection, a task.
            .upstream_generation,
            .cache,
            .rate_limiter,
            .certs_doh,
            .certs_dot,
            .log_sink,
            .logger_queue,
            .trusted_proxies,
            => .subsystem,
            .dns_policy,
            .logger_privacy,
            .logger_flush,
            .disk_thresholds,
            .sessions_ttl,
            .api_limiter,
            .retention,
            .scheduler,
            => .live,
        };
    }

    /// Whether a change to this owner waits for a restart. The two that do are
    /// the two milestone 35 owns.
    pub fn needsRestart(self: Operation) bool {
        return switch (self.class()) {
            .bind, .web_lifecycle => true,
            .live, .subsystem => false,
        };
    }
};

pub const Class = enum { live, subsystem, bind, web_lifecycle };

pub const Set = std.EnumSet(Operation);

pub const Entry = struct { key: []const u8, operation: Operation };

/// Every settings key, in `model.Config` declaration order, with the owner it
/// belongs to. `web.password` is write-only and `web.password_hash` is hidden;
/// neither is a settings key, and the completeness test below spells out the
/// same two exclusions the read and write shapes use.
pub const table = [_]Entry{
    .{ .key = "upstream.attempt_timeout_ms", .operation = .upstream_generation },
    // The forward-zone client's read deadline, which the per-query policy
    // carries — not one of the pool's two timeouts.
    .{ .key = "upstream.read_timeout_ms", .operation = .dns_policy },
    .{ .key = "upstream.total_timeout_ms", .operation = .upstream_generation },

    .{ .key = "dns.bind_ipv4", .operation = .bind },
    .{ .key = "dns.bind_ipv6", .operation = .bind },
    .{ .key = "dns.port", .operation = .bind },
    .{ .key = "dns.rate_limit", .operation = .rate_limiter },
    .{ .key = "dns.rate_window_seconds", .operation = .rate_limiter },

    .{ .key = "blocking.response", .operation = .dns_policy },
    .{ .key = "blocking.ttl", .operation = .dns_policy },

    .{ .key = "cache.size", .operation = .cache },
    // The cache keeps no copy of its configuration: `classify` reads the
    // negative ceiling off the per-query policy.
    .{ .key = "cache.negative_ttl_max", .operation = .dns_policy },

    .{ .key = "web.enabled", .operation = .web_lifecycle },
    .{ .key = "web.bind", .operation = .bind },
    .{ .key = "web.port", .operation = .bind },
    .{ .key = "web.session_ttl_hours", .operation = .sessions_ttl },
    .{ .key = "web.api_rate_limit_per_min", .operation = .api_limiter },
    .{ .key = "web.api_localhost_exempt", .operation = .api_limiter },
    .{ .key = "web.sse_max_connections_per_ip", .operation = .api_limiter },
    .{ .key = "web.trusted_proxies", .operation = .trusted_proxies },

    .{ .key = "doh_server.enabled", .operation = .bind },
    .{ .key = "doh_server.bind", .operation = .bind },
    .{ .key = "doh_server.port", .operation = .bind },
    .{ .key = "doh_server.cert_path", .operation = .certs_doh },
    .{ .key = "doh_server.key_path", .operation = .certs_doh },

    .{ .key = "dot_server.enabled", .operation = .bind },
    .{ .key = "dot_server.bind", .operation = .bind },
    .{ .key = "dot_server.port", .operation = .bind },
    .{ .key = "dot_server.cert_path", .operation = .certs_dot },
    .{ .key = "dot_server.key_path", .operation = .certs_dot },

    .{ .key = "edns.ecs_mode", .operation = .dns_policy },

    .{ .key = "logging.level", .operation = .log_sink },
    .{ .key = "logging.retention_days", .operation = .retention },
    .{ .key = "logging.query_log_buffer_max", .operation = .logger_queue },
    .{ .key = "logging.query_log_flush_interval_s", .operation = .logger_flush },
    .{ .key = "logging.hide_domains", .operation = .logger_privacy },
    .{ .key = "logging.hide_client_ips", .operation = .logger_privacy },
    .{ .key = "logging.output", .operation = .log_sink },
    .{ .key = "logging.file_path", .operation = .log_sink },
    .{ .key = "logging.max_size_mb", .operation = .log_sink },
    .{ .key = "logging.max_files", .operation = .log_sink },

    .{ .key = "disk.min_free_mb", .operation = .disk_thresholds },
    .{ .key = "disk.warn_free_mb", .operation = .disk_thresholds },

    .{ .key = "blocklist_update.enabled", .operation = .scheduler },
    .{ .key = "blocklist_update.interval_hours", .operation = .scheduler },
};

/// Fields a client may neither read nor write directly. `password_hash` is
/// derived from `password`; exposing it would let a client install a hash nxdns
/// never computed.
pub fn isHidden(comptime section: []const u8, comptime field: []const u8) bool {
    return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password_hash");
}

/// `web.password` is accepted on a PUT and never returned. It is not a settings
/// key: the hash it produces is, and its apply is the live-hash install.
pub fn isWriteOnly(comptime section: []const u8, comptime field: []const u8) bool {
    return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password");
}

pub fn isScalarSection(comptime T: type) bool {
    return @typeInfo(T) == .@"struct";
}

fn find(comptime key: []const u8) ?Operation {
    @setEvalBranchQuota(20_000);
    for (table) |entry| {
        if (std.mem.eql(u8, entry.key, key)) return entry.operation;
    }
    return null;
}

/// The keys whose change waits for a restart, which is exactly the two
/// milestone-35 operations. `/api/settings` reports this list as field-level
/// metadata about the form.
pub const restart_required_keys: []const []const u8 = &restart_keys;

const restart_keys = blk: {
    var list: [table.len][]const u8 = undefined;
    var count = 0;
    for (table) |entry| {
        if (!entry.operation.needsRestart()) continue;
        list[count] = entry.key;
        count += 1;
    }
    const final = list[0..count].*;
    break :blk final;
};

/// Whether a scalar settings value differs between two configurations. Strings
/// compare by bytes; everything else a settings row can hold compares by value.
fn differs(comptime T: type, a: T, b: T) bool {
    if (T == []const u8) return !std.mem.eql(u8, a, b);
    return a != b;
}

/// The owners a change from `before` to `after` has to tell.
///
/// Derived from the values, not from the keys the patch named: a PUT that
/// rewrites a setting to what it already was changes nothing, so it must not
/// resize a queue, rebuild a pool, or owe a restart. The admin form submits
/// every field, and treating that as eighteen applies would be wrong as well as
/// wasteful.
pub fn changedOperations(before: model.Config, after: model.Config) Set {
    var ops: Set = .initEmpty();
    inline for (@typeInfo(model.Config).@"struct".fields) |section_field| {
        if (comptime isScalarSection(section_field.type)) {
            inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
                comptime if (isHidden(section_field.name, field.name)) continue;
                comptime if (isWriteOnly(section_field.name, field.name)) continue;
                const operation = comptime find(section_field.name ++ "." ++ field.name).?;
                const old = @field(@field(before, section_field.name), field.name);
                const new = @field(@field(after, section_field.name), field.name);
                if (differs(@TypeOf(old), old, new)) ops.insert(operation);
            }
        }
    }
    return ops;
}

// ---------------------------------------------------------------------------
// the plan
// ---------------------------------------------------------------------------

/// Everything one configuration write prepared, published and still owes a
/// retire. Exactly one of `abandon` or `publish` consumes a prepared plan, and
/// a published one is always followed by `retire`.
pub const Plan = struct {
    state: *server.WebState,
    /// The per-request arena. Holds the copied upstream report keys, which
    /// nothing outside this request reads.
    arena: Allocator,
    /// The final merged configuration every candidate is built from.
    cfg: model.Config,
    ops: Set,

    // prepared candidates ---------------------------------------------------
    proxies: ?[]u8 = null,
    cache: ?*dns_cache.DnsCache = null,
    limiter: ?*rate_limiter.RateLimiter = null,
    upstream: ?*upstream_owner.Generation = null,
    upstream_previous_keys: []const []const u8 = &.{},
    doh_paths: ?cert_store.CertStore.PreparedPaths = null,
    dot_paths: ?cert_store.CertStore.PreparedPaths = null,
    sink: ?logging.PreparedApply = null,
    log_dir: ?*disk_monitor.LogDir = null,
    queue: ?logger_controller.Prepared = null,
    /// The reading `setLimits` refills its buckets against. Taken at prepare
    /// because publish reads no clock: a clock is I/O.
    limiter_now: ?std.Io.Timestamp = null,

    // what publish displaced, for retire ------------------------------------
    retired_proxies: ?[]const u8 = null,
    retired_cache: ?*dns_cache.DnsCache = null,
    retired_limiter: ?*rate_limiter.RateLimiter = null,
    retired_upstream: ?*upstream_owner.Generation = null,
    detached_log_file: ?std.Io.File = null,
    published_upstream: bool = false,

    pub fn init(
        state: *server.WebState,
        arena: Allocator,
        cfg: model.Config,
        ops: Set,
    ) Plan {
        return .{ .state = state, .arena = arena, .cfg = cfg, .ops = ops };
    }

    /// Builds every candidate the change needs. Returns the failure that
    /// refused it, in which case the caller must still call `abandon`: an
    /// earlier owner's candidate may already exist.
    ///
    /// A propagated error abandons here instead, because the caller has no
    /// plan left to abandon: an earlier owner may already hold memory, a file,
    /// a connection, a parked writer or a lock — the cert store keeps
    /// `reload_mutex` held between its prepare and its publish.
    pub fn prepare(self: *Plan, io: std.Io) error{OutOfMemory}!?Failure {
        errdefer self.abandon(io);

        const state = self.state;
        const gpa = state.gpa;

        if (self.ops.contains(.trusted_proxies)) {
            self.proxies = try gpa.dupe(u8, self.cfg.web.trusted_proxies);
        }

        if (self.ops.contains(.cache)) {
            const candidate = try gpa.create(dns_cache.DnsCache);
            candidate.* = dns_cache.DnsCache.init(gpa, self.cfg.cache) catch |err| {
                gpa.destroy(candidate);
                return err;
            };
            self.cache = candidate;
        }

        if (self.ops.contains(.rate_limiter)) {
            const candidate = try gpa.create(rate_limiter.RateLimiter);
            candidate.* = rate_limiter.RateLimiter.init(gpa, .{
                .limit = self.cfg.dns.rate_limit,
                .window_seconds = self.cfg.dns.rate_window_seconds,
            }) catch |err| {
                gpa.destroy(candidate);
                return err;
            };
            self.limiter = candidate;
        }

        if (self.ops.contains(.upstream_generation)) {
            if (try self.prepareUpstreams(io, self.cfg.upstreams)) |failure| return failure;
        }

        if (self.ops.contains(.certs_doh)) {
            // A disabled endpoint has no store: the change is a database row
            // and nothing else, and milestone 35 validates the paths when it
            // implements enable.
            if (state.doh_certs) |store| {
                self.doh_paths = store.preparePathChange(
                    io,
                    self.cfg.doh_server.cert_path,
                    self.cfg.doh_server.key_path,
                ) catch |err| return certFailure("doh_server", err);
            }
        }

        if (self.ops.contains(.certs_dot)) {
            if (state.dot_certs) |store| {
                self.dot_paths = store.preparePathChange(
                    io,
                    self.cfg.dot_server.cert_path,
                    self.cfg.dot_server.key_path,
                ) catch |err| return certFailure("dot_server", err);
            }
        }

        if (self.ops.contains(.log_sink)) {
            self.sink = logging.prepareApply(io, self.cfg.logging) catch |err| return switch (err) {
                error.PathTooLong => Failure{ .invalid = "logging.file_path: too long for this system" },
                error.TargetUnopenable => Failure{
                    .invalid = "logging.file_path: this file cannot be opened for writing",
                },
            };
            // Derived from the final merged output AND file_path on every sink
            // apply, in both directions: output `file` measures the file's
            // directory, anything else measures nothing.
            self.log_dir = try disk_monitor.Monitor.prepareLogDir(gpa, logging.logDirname(self.cfg.logging));
        }

        if (self.ops.contains(.logger_queue)) {
            if (state.logger) |controller| {
                // The merged configuration, not the live one: a PUT that
                // changes the buffer size and a privacy flag together must not
                // hand the replacement generation the privacy it replaced.
                if (controller.prepare(io, self.cfg.logging)) |prepared| {
                    self.queue = prepared;
                } else |err| {
                    const entries = self.cfg.logging.query_log_buffer_max;
                    if (try self.queueFailure(err, entries)) |failure| return failure;
                }
            }
        }

        if (self.ops.contains(.api_limiter)) self.limiter_now = std.Io.Clock.awake.now(io);

        return null;
    }

    /// What a refused resize means to the client, or null when it means nothing
    /// — a controller with no generation to replace is a test's borrowed one,
    /// and the row is still the configuration.
    fn queueFailure(
        self: *Plan,
        err: logger_controller.PrepareError,
        entries: u32,
    ) error{OutOfMemory}!?Failure {
        var buf: [96]u8 = undefined;
        if (logger_controller.sizeMessage(err, entries, &buf)) |message| {
            return Failure{ .invalid = try std.fmt.allocPrint(
                self.arena,
                "logging.query_log_buffer_max: {s}",
                .{message},
            ) };
        }
        return switch (err) {
            error.NotResizable => null,
            error.OutOfMemory => error.OutOfMemory,
            error.PreviousResizeDraining => Failure{
                .conflict = "a previous query-log resize is still draining",
            },
            else => blk: {
                log.warn("preparing the query-log resize failed: {s}", .{@errorName(err)});
                break :blk Failure{ .unavailable = "the query logger could not be resized" };
            },
        };
    }

    /// The upstream half, shared with the upstream resource handlers: they
    /// build their candidate from the HYPOTHETICAL post-mutation row set,
    /// before the repository write.
    pub fn prepareUpstreams(
        self: *Plan,
        io: std.Io,
        servers: []const model.UpstreamServer,
    ) error{OutOfMemory}!?Failure {
        const state = self.state;
        const owner = state.upstreams orelse return null;
        const inputs = state.upstream_build orelse return null;

        self.upstream_previous_keys = try owner.copyLiveReportKeys(io, self.arena);
        self.upstream = upstream_owner.build(.{
            .gpa = state.gpa,
            .io = io,
            .servers = servers,
            .http = inputs.http,
            .bundle = inputs.bundle,
            .bundle_lock = inputs.bundle_lock,
            .timeouts = .{
                .attempt = .{ .raw = model.attemptTimeout(self.cfg.upstream), .clock = .awake },
                .total = .{ .raw = model.totalTimeout(self.cfg.upstream), .clock = .awake },
            },
            .seed = @truncate(@as(u96, @bitCast(std.Io.Clock.real.now(io).nanoseconds))),
            .diagnostics = state.events,
        }) catch |err| switch (err) {
            error.OutOfMemory => return error.OutOfMemory,
            error.NoUsableUpstreams => return Failure{
                .conflict = "no usable upstream would be left",
            },
        };
        self.ops.insert(.upstream_generation);
        return null;
    }

    /// Frees every candidate. The change did not happen: nothing was published,
    /// so nothing the running server holds is touched.
    pub fn abandon(self: *Plan, io: std.Io) void {
        const gpa = self.state.gpa;

        if (self.proxies) |text| gpa.free(text);
        if (self.cache) |candidate| {
            candidate.deinit();
            gpa.destroy(candidate);
        }
        if (self.limiter) |candidate| {
            candidate.deinit();
            gpa.destroy(candidate);
        }
        if (self.upstream) |candidate| candidate.retire(io);
        if (self.doh_paths) |prepared| self.state.doh_certs.?.abortPathChange(io, prepared);
        if (self.dot_paths) |prepared| self.state.dot_certs.?.abortPathChange(io, prepared);
        if (self.sink) |prepared| logging.abortApply(io, prepared);
        disk_monitor.Monitor.destroyPreparedLogDir(self.log_dir);
        if (self.queue) |prepared| self.state.logger.?.abandon(io, prepared);

        self.* = undefined;
    }

    /// Infallible and I/O-free. Every operation whose owner is wired is told,
    /// and what it displaced is recorded for `retire`.
    pub fn publish(self: *Plan, io: std.Io) void {
        const state = self.state;

        if (self.ops.contains(.dns_policy)) {
            if (state.handler) |h| h.setPolicy(io, .{
                .blocking = .{ .mode = self.cfg.blocking.response, .ttl = self.cfg.blocking.ttl },
                .ecs_mode = self.cfg.edns.ecs_mode,
                .forward_read_timeout = .{ .raw = model.readTimeout(self.cfg.upstream), .clock = .awake },
                .negative_ttl_max = self.cfg.cache.negative_ttl_max,
            });
        }

        if (self.proxies) |prepared| {
            self.retired_proxies = state.proxies.install(io, prepared);
            self.proxies = null;
        }

        if (self.ops.contains(.logger_privacy)) {
            if (state.logger) |controller| controller.setPrivacy(io, .{
                .hide_domains = self.cfg.logging.hide_domains,
                .hide_client_ips = self.cfg.logging.hide_client_ips,
            });
        }

        if (self.ops.contains(.logger_flush)) {
            if (state.logger) |controller| {
                controller.setFlushInterval(io, self.cfg.logging.query_log_flush_interval_s);
            }
        }

        if (self.ops.contains(.disk_thresholds)) {
            if (state.monitor) |monitor| monitor.setThresholds(self.cfg.disk);
        }

        if (self.upstream) |candidate| {
            self.retired_upstream = state.upstreams.?.replace(io, candidate);
            self.published_upstream = true;
            self.upstream = null;
        }

        if (self.cache) |candidate| {
            // A handler-less state still owns the candidate, and dropping it
            // here would leak it: `replaceCache` is the only thing that can
            // hand the old one back.
            if (state.handler) |h| {
                self.retired_cache = h.replaceCache(io, candidate);
            } else {
                self.retired_cache = candidate;
            }
            self.cache = null;
        }

        if (self.limiter) |candidate| {
            if (state.handler) |h| {
                self.retired_limiter = h.replaceRateLimiter(io, candidate);
            } else {
                self.retired_limiter = candidate;
            }
            self.limiter = null;
        }

        if (self.ops.contains(.sessions_ttl)) {
            if (state.sessions) |sessions| sessions.setTtl(io, self.cfg.web.session_ttl_hours);
        }

        if (self.limiter_now) |now| {
            if (state.limiter) |limiter| limiter.setLimits(io, now, .{
                .rate_per_min = self.cfg.web.api_rate_limit_per_min,
                .localhost_exempt = self.cfg.web.api_localhost_exempt,
                .sse_max_per_ip = self.cfg.web.sse_max_connections_per_ip,
            });
        }

        if (self.ops.contains(.retention)) {
            if (state.retention_days) |days| days.setRetentionDays(self.cfg.logging.retention_days);
        }

        if (self.doh_paths) |prepared| {
            state.doh_certs.?.publishPathChange(io, prepared);
            self.doh_paths = null;
        }
        if (self.dot_paths) |prepared| {
            state.dot_certs.?.publishPathChange(io, prepared);
            self.dot_paths = null;
        }

        if (self.sink) |prepared| {
            self.detached_log_file = logging.publishApply(prepared);
            self.sink = null;
            if (state.monitor) |monitor| {
                monitor.setLogDir(io, self.log_dir);
            } else {
                disk_monitor.Monitor.destroyPreparedLogDir(self.log_dir);
            }
            self.log_dir = null;
        }

        if (self.ops.contains(.scheduler)) {
            if (state.manager) |manager| manager.setSchedule(
                io,
                self.cfg.blocklist_update.enabled,
                self.cfg.blocklist_update.interval_hours,
            );
        }

        if (self.queue) |prepared| {
            state.logger.?.publish(io, prepared);
            self.queue = null;
        }
    }

    /// Closes, frees and reconciles after the publish. Runs on the writing
    /// task, never on a reader's release path.
    pub fn retire(self: *Plan, io: std.Io) void {
        const gpa = self.state.gpa;

        if (self.retired_proxies) |text| gpa.free(text);
        if (self.retired_cache) |old| {
            old.deinit();
            gpa.destroy(old);
        }
        if (self.retired_limiter) |old| {
            old.deinit();
            gpa.destroy(old);
        }
        logging.retireApply(io, self.detached_log_file);

        if (self.published_upstream) {
            if (self.state.events) |store| {
                const owner = self.state.upstreams.?;
                const generation = owner.acquire(io);
                defer owner.release(io, generation);
                upstream_owner.reconcileReport(
                    store,
                    io,
                    std.Io.Clock.real.now(io).toSeconds(),
                    generation.report().notes,
                    self.upstream_previous_keys,
                );
            }
            // A generation no reader held at the swap has no release left to
            // tear it down, so the publisher does — after the reconciliation,
            // which reads only the copies taken at prepare.
            if (self.retired_upstream) |old| old.retire(io);
        }

        self.* = undefined;
    }
};

fn certFailure(comptime endpoint: []const u8, err: cert_store.ReloadError) Failure {
    return switch (err) {
        error.OutOfMemory => .{ .unavailable = "out of memory loading the certificate" },
        error.CertUnreadable => .{ .invalid = endpoint ++ ".cert_path: no readable certificate there" },
        error.KeyUnreadable => .{ .invalid = endpoint ++ ".key_path: no readable key there" },
        error.KeyMismatch => .{ .invalid = endpoint ++ ": the key at that path does not match the certificate" },
        // The remaining variants are parse, size and library failures. They name
        // the pair rather than one of the two files, because which of them was
        // at fault is exactly what the loader could not decide.
        else => .{ .invalid = endpoint ++ ": the certificate and key at those paths could not be loaded" },
    };
}

/// The row set an upstream create, update or delete would leave behind, built
/// before the repository write so the candidate generation is the one the
/// commit will make true.
pub const RowMutation = union(enum) {
    add: model.UpstreamServer,
    replace: struct { id: i64, item: model.UpstreamServer },
    remove: i64,
};

pub fn hypotheticalRows(
    arena: Allocator,
    rows: []const upstreams_repo.UpstreamRow,
    mutation: RowMutation,
) Allocator.Error![]const model.UpstreamServer {
    var out: std.ArrayList(model.UpstreamServer) = .empty;
    for (rows) |row| {
        switch (mutation) {
            .add => {},
            .replace => |edit| if (row.id == edit.id) {
                try out.append(arena, edit.item);
                continue;
            },
            .remove => |id| if (row.id == id) continue,
        }
        try out.append(arena, .{
            .url = row.url,
            .priority = row.priority,
            .enabled = row.enabled,
            .tls_name = row.tls_name,
        });
    }
    if (mutation == .add) try out.append(arena, mutation.add);
    return out.items;
}

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

const testing = std.testing;
const fixtures = @import("test_fixtures");

test "a propagated error out of prepare abandons what earlier owners already built" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = fixtures.cert_pem });
    try tmp.dir.writeFile(io, .{ .sub_path = "key.pem", .data = fixtures.key_pem });

    var cert_buf: [128]u8 = undefined;
    var key_buf: [128]u8 = undefined;
    var log_buf: [128]u8 = undefined;
    const cert_path = try std.fmt.bufPrint(&cert_buf, ".zig-cache/tmp/{s}/cert.pem", .{tmp.sub_path});
    const key_path = try std.fmt.bufPrint(&key_buf, ".zig-cache/tmp/{s}/key.pem", .{tmp.sub_path});
    const log_path = try std.fmt.bufPrint(&log_buf, ".zig-cache/tmp/{s}/nxdns.log", .{tmp.sub_path});

    var store = try cert_store.CertStore.init(testing.allocator, io, cert_path, key_path, null);
    defer store.deinit(io);

    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();

    // The cert candidate is built from the store's own allocator and succeeds;
    // the log-directory generation that follows it comes from the state's, and
    // that one is out of memory.
    var failing: std.testing.FailingAllocator = .init(testing.allocator, .{ .fail_index = 0 });
    var state: server.WebState = .{ .gpa = failing.allocator(), .doh_certs = &store };

    var cfg: model.Config = .{};
    cfg.doh_server.cert_path = cert_path;
    cfg.doh_server.key_path = key_path;
    cfg.logging.output = .file;
    cfg.logging.file_path = log_path;

    var ops: Set = .initEmpty();
    ops.insert(.certs_doh);
    ops.insert(.log_sink);

    var plan: Plan = .init(&state, arena_state.allocator(), cfg, ops);
    try testing.expectError(error.OutOfMemory, plan.prepare(io));

    // `preparePathChange` holds `reload_mutex` until its publish or its abort.
    // A prepare that failed after it must have aborted, or every later cert
    // change and every reload would block here forever.
    const second = try store.preparePathChange(io, cert_path, key_path);
    store.abortPathChange(io, second);
}

test "every scalar settings key appears in the table exactly once" {
    // The old comptime generator walked `model.Config` to produce the key list;
    // this walks it to prove the hand-written table covers it. A section added
    // to the model fails to compile here until its keys have owners.
    comptime {
        @setEvalBranchQuota(50_000);
        var counted = 0;
        for (@typeInfo(model.Config).@"struct".fields) |section_field| {
            if (!isScalarSection(section_field.type)) continue;
            for (@typeInfo(section_field.type).@"struct".fields) |field| {
                if (isHidden(section_field.name, field.name)) continue;
                if (isWriteOnly(section_field.name, field.name)) continue;
                const key = section_field.name ++ "." ++ field.name;
                var hits = 0;
                for (table) |entry| {
                    if (std.mem.eql(u8, entry.key, key)) hits += 1;
                }
                if (hits != 1) @compileError("the apply table does not list " ++ key ++ " exactly once");
                counted += 1;
            }
        }
        if (counted != table.len) @compileError("the apply table lists a key the model does not have");
    }
}

test "the table names no secret" {
    for (table) |entry| {
        try testing.expect(!std.mem.eql(u8, entry.key, "web.password"));
        try testing.expect(!std.mem.eql(u8, entry.key, "web.password_hash"));
    }
}

test "restart-required is bind and web lifecycle, and nothing else" {
    for (table) |entry| {
        var listed = false;
        for (restart_required_keys) |key| {
            if (std.mem.eql(u8, entry.key, key)) listed = true;
        }
        try testing.expectEqual(entry.operation.needsRestart(), listed);
    }

    // The full list, so a key silently joining or leaving it is a failure.
    const expected = [_][]const u8{
        "dns.bind_ipv4",
        "dns.bind_ipv6",
        "dns.port",
        "web.enabled",
        "web.bind",
        "web.port",
        "doh_server.bind",
        "doh_server.enabled",
        "doh_server.port",
        "dot_server.bind",
        "dot_server.enabled",
        "dot_server.port",
    };
    try testing.expectEqual(expected.len, restart_required_keys.len);
    for (expected) |key| {
        var found = false;
        for (restart_required_keys) |listed| {
            if (std.mem.eql(u8, listed, key)) found = true;
        }
        try testing.expect(found);
    }
}

test "changed operations follow the values, not the keys a patch named" {
    const before: model.Config = .{};
    try testing.expect(changedOperations(before, before).count() == 0);

    var after = before;
    after.dns.port = 5353;
    try testing.expect(changedOperations(before, after).eql(Set.initOne(.bind)));

    // Two keys of one owner are one operation.
    var pair = before;
    pair.disk = .{ .min_free_mb = 1, .warn_free_mb = 2 };
    try testing.expect(changedOperations(before, pair).eql(Set.initOne(.disk_thresholds)));

    // A string key compares by bytes.
    var proxies = before;
    proxies.web.trusted_proxies = "10.0.0.1";
    try testing.expect(changedOperations(before, proxies).eql(Set.initOne(.trusted_proxies)));

    // Equal bytes at a different address are not a change.
    var same_bytes = before;
    var copy: [7]u8 = undefined;
    @memcpy(&copy, "0.0.0.0");
    same_bytes.dns.bind_ipv4 = &copy;
    try testing.expect(changedOperations(before, same_bytes).count() == 0);
}

test "the hypothetical row set is the one the commit will make true" {
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const rows = [_]upstreams_repo.UpstreamRow{
        .{ .id = 1, .url = "https://a.example/dns-query", .priority = 1, .enabled = true, .tls_name = "" },
        .{ .id = 2, .url = "tls://b.example:853", .priority = 2, .enabled = true, .tls_name = "b.example" },
    };

    const added = try hypotheticalRows(arena, &rows, .{ .add = .{ .url = "https://c.example/dns-query" } });
    try testing.expectEqual(@as(usize, 3), added.len);
    try testing.expectEqualStrings("https://c.example/dns-query", added[2].url);

    const removed = try hypotheticalRows(arena, &rows, .{ .remove = 1 });
    try testing.expectEqual(@as(usize, 1), removed.len);
    try testing.expectEqualStrings("tls://b.example:853", removed[0].url);

    const replaced = try hypotheticalRows(arena, &rows, .{
        .replace = .{ .id = 2, .item = .{ .url = "tls://z.example:853", .tls_name = "z.example" } },
    });
    try testing.expectEqual(@as(usize, 2), replaced.len);
    try testing.expectEqualStrings("tls://z.example:853", replaced[1].url);
}
