//! `/api/upstreams` — the resolvers nxdns forwards to.
//!
//! Ruling 9 makes this a resource like any other, and milestone 34 makes it
//! live like the rest of them: an upstream added, edited or removed here is
//! applied in-process. The row set the write will leave behind is built into a
//! candidate generation BEFORE the write, so a set that cannot produce clients
//! is refused with nothing changed; the candidate is published after the commit
//! and the displaced generation retires once the exchanges holding it finish.
//! `restart_required` in the response is therefore `false`, and stays in the
//! shape so the UI has one field with one meaning across every mutation.
//!
//! `tls_name` is the DoT-only SNI and certificate name (migration v2). It is
//! empty for every other scheme, and the validator refuses it there.

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

const apply = @import("apply.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 upstreams_repo = @import("../../storage/repositories/upstreams_repo.zig");

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

const url_conflict = "an upstream with that url already exists";

const Body = struct {
    url: []const u8,
    priority: i32 = 100,
    enabled: bool = true,
    tls_name: []const u8 = "",
};

const Created = union(enum) { id: i64, fail: Failure };

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

/// Prepares the generation the row set after `mutation` would produce, on a
/// caller that already holds `config_lock`. Returns the plan for the caller to
/// publish or abandon.
fn planFor(
    state: *server.WebState,
    io: std.Io,
    arena: Allocator,
    database: *db.Db,
    mutation: apply.RowMutation,
) error{OutOfMemory}!union(enum) { plan: apply.Plan, fail: Failure } {
    const cfg = mutations.loadConfig(arena, database) catch |err| switch (err) {
        error.OutOfMemory => return error.OutOfMemory,
        else => return .{ .fail = .{ .internal = err } },
    };
    const rows = upstreams_repo.listUpstreamRows(database, arena) catch |err| switch (err) {
        error.OutOfMemory => return error.OutOfMemory,
        else => return .{ .fail = .{ .internal = err } },
    };
    const servers = try apply.hypotheticalRows(arena, rows.items, mutation);

    var plan: apply.Plan = .init(state, arena, cfg, .initEmpty());
    if (try plan.prepareUpstreams(io, servers)) |failure| {
        plan.abandon(io);
        return .{ .fail = failure };
    }
    return .{ .plan = plan };
}

fn applyCreate(
    state: *server.WebState,
    io: std.Io,
    arena: Allocator,
    item: model.UpstreamServer,
) error{OutOfMemory}!Created {
    const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
    if (try mutations.checkUpstream(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };

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

    var plan = switch (try planFor(state, io, arena, database, .{ .add = item })) {
        .fail => |failure| return .{ .fail = failure },
        .plan => |p| p,
    };

    const id = upstreams_repo.insertUpstreamRow(database, item) catch |err| {
        plan.abandon(io);
        return .{ .fail = mutations.dbFailure(err, url_conflict) };
    };

    plan.publish(io);
    plan.retire(io);
    return .{ .id = id };
}

fn applyUpdate(
    state: *server.WebState,
    io: std.Io,
    arena: Allocator,
    id: i64,
    item: model.UpstreamServer,
) error{OutOfMemory}!?Failure {
    const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
    if (try mutations.checkUpstream(arena, item)) |problem| return .{ .invalid = problem };

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

    // The same rule `applyDelete` enforces: a set with no enabled upstream
    // would refuse to boot, so the write that would create one is a conflict.
    if (!item.enabled) {
        const remaining = countEnabledExcept(database, arena, id) catch |err|
            return mutations.dbFailure(err, url_conflict);
        switch (remaining) {
            .missing => return .not_found,
            .count => |left| if (left == 0) return .{
                .conflict = "the last enabled upstream cannot be disabled",
            },
        }
    }

    var plan = switch (try planFor(state, io, arena, database, .{
        .replace = .{ .id = id, .item = item },
    })) {
        .fail => |failure| return failure,
        .plan => |p| p,
    };

    upstreams_repo.updateUpstream(database, id, item) catch |err| {
        plan.abandon(io);
        return mutations.dbFailure(err, url_conflict);
    };

    plan.publish(io);
    plan.retire(io);
    return null;
}

/// The last enabled upstream cannot go: a resolver with nowhere to forward to
/// answers nothing, and `validate.validate` refuses that configuration at
/// startup — so allowing it here would only produce a box that will not boot.
fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) error{OutOfMemory}!?Failure {
    const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;

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

    const remaining = countEnabledExcept(database, arena, id) catch |err|
        return mutations.dbFailure(err, url_conflict);
    switch (remaining) {
        .missing => return .not_found,
        .count => |left| if (left == 0) return .{
            .conflict = "the last enabled upstream cannot be removed",
        },
    }

    var plan = switch (try planFor(state, io, arena, database, .{ .remove = id })) {
        .fail => |failure| return failure,
        .plan => |p| p,
    };

    upstreams_repo.deleteUpstream(database, id) catch |err| {
        plan.abandon(io);
        return mutations.dbFailure(err, url_conflict);
    };

    plan.publish(io);
    plan.retire(io);
    return null;
}

const Remaining = union(enum) { missing, count: usize };

fn countEnabledExcept(
    database: *db.Db,
    arena: Allocator,
    id: i64,
) db.Error!Remaining {
    const rows = try upstreams_repo.listUpstreamRows(database, arena);
    var found = false;
    var left: usize = 0;
    for (rows.items) |row| {
        if (row.id == id) {
            found = true;
            continue;
        }
        if (row.enabled) left += 1;
    }
    return if (found) .{ .count = left } else .missing;
}

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

const resource = mutations.Resource(.{
    .Row = upstreams_repo.UpstreamRow,
    .list = upstreams_repo.listUpstreamRows,
    .get = upstreams_repo.getUpstream,
    .remove = applyDelete,
    .label = "an upstream",
    .plural = "upstreams",
    .envelope = "upstreams",
});

pub const list = resource.list;
pub const get = resource.get;
pub const remove = resource.remove;

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

    return switch (try applyCreate(state, io, request.arena, item)) {
        .fail => |failure| mutations.respondFailure(request, failure, "creating an upstream"),
        .id => |id| http_util.respondJson(request, .created, .{
            .id = id,
            .url = item.url,
            .priority = item.priority,
            .enabled = item.enabled,
            .tls_name = item.tls_name,
            .restart_required = false,
        }, &.{}),
    };
}

pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
    const parsed = http_util.parseBody(Body, request) catch |err|
        return mutations.respondBadBody(request, err);
    const item = toModel(parsed.value);
    const id = request.id.?;

    if (try applyUpdate(state, io, request.arena, id, item)) |failure| {
        return mutations.respondFailure(request, failure, "updating an upstream");
    }
    return http_util.respondJson(request, .ok, .{
        .id = id,
        .url = item.url,
        .priority = item.priority,
        .enabled = item.enabled,
        .tls_name = item.tls_name,
        .restart_required = false,
    }, &.{});
}

fn toModel(body: Body) model.UpstreamServer {
    return .{
        .url = body.url,
        .priority = body.priority,
        .enabled = body.enabled,
        .tls_name = body.tls_name,
    };
}

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

const testing = std.testing;

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

test "a created upstream is stored" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    const created = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
    const row = (try upstreams_repo.getUpstream(&bench.database, bench.arena(), created.id)).?;
    try testing.expectEqualStrings(doh.url, row.url);
    try testing.expect(row.enabled);
    try testing.expectEqualStrings("", row.tls_name);
}

test "an upstream change never announces a reload or a restart" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    const created = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
    _ = try applyUpdate(&bench.state, bench.io(), bench.arena(), created.id, .{
        .url = doh.url,
        .priority = 50,
        .enabled = true,
    });
    // Ruling 12: the pool is built at startup, so nothing is live to reload.
    try testing.expectEqual(@as(usize, 0), bench.reloads);
}

test "a url the validator refuses never reaches the database" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    const scheme = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .url = "udp://1.1.1.1:53" });
    try testing.expect(scheme.fail == .invalid);

    const misplaced_name = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
        .url = "https://dns.example/dns-query",
        .tls_name = "dns.example",
    });
    try testing.expect(misplaced_name.fail == .invalid);

    try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM upstreams"));
}

test "no upstream write owes a restart, refused or accepted" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    try testing.expect((try applyCreate(&bench.state, bench.io(), bench.arena(), .{
        .url = "udp://1.1.1.1:53",
    })).fail == .invalid);
    try testing.expect(!bench.state.restart_pending.load(.monotonic));

    try testing.expect((try applyUpdate(&bench.state, bench.io(), bench.arena(), 999, doh)).? == .not_found);
    try testing.expect(!bench.state.restart_pending.load(.monotonic));

    try testing.expect((try applyDelete(&bench.state, bench.io(), bench.arena(), 999)).? == .not_found);
    try testing.expect(!bench.state.restart_pending.load(.monotonic));

    // Milestone 34: an accepted write is applied in-process, so it owes no
    // restart either. `restart_pending` now has exactly two sources, and
    // neither of them is here.
    _ = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
    try testing.expect(!bench.state.restart_pending.load(.monotonic));
}

test "a duplicate url is a conflict" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    _ = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
    const again = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
    try testing.expectEqualStrings(url_conflict, again.fail.conflict);
}

test "the last enabled upstream cannot be deleted" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    const created = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
    const failure = try applyDelete(&bench.state, bench.io(), bench.arena(), created.id);
    try testing.expectEqualStrings("the last enabled upstream cannot be removed", failure.?.conflict);

    const second = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
        .url = "tls://1.1.1.1:853",
        .tls_name = "one.one.one.one",
    });
    try testing.expectEqual(
        @as(?Failure, null),
        try applyDelete(&bench.state, bench.io(), bench.arena(), created.id),
    );
    try testing.expectEqual(
        @as(i64, 1),
        try bench.queryInt("SELECT count(*) FROM upstreams"),
    );
    try testing.expect(second == .id);
}

test "a disabled upstream can be created while an enabled one exists" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    _ = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
    const spare = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
        .url = "tls://1.1.1.1:853",
        .tls_name = "one.one.one.one",
        .enabled = false,
    });
    try testing.expect(spare == .id);
    try testing.expectEqual(
        @as(i64, 0),
        try bench.queryInt("SELECT enabled FROM upstreams WHERE url = 'tls://1.1.1.1:853'"),
    );
}

test "the last enabled upstream cannot be disabled" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    const created = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
    const off: model.UpstreamServer = .{ .url = doh.url, .enabled = false };

    const refused = try applyUpdate(&bench.state, bench.io(), bench.arena(), created.id, off);
    try testing.expectEqualStrings("the last enabled upstream cannot be disabled", refused.?.conflict);
    try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM upstreams WHERE enabled = 1"));

    _ = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
        .url = "tls://1.1.1.1:853",
        .tls_name = "one.one.one.one",
    });
    try testing.expectEqual(
        @as(?Failure, null),
        try applyUpdate(&bench.state, bench.io(), bench.arena(), created.id, off),
    );
    try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM upstreams WHERE enabled = 1"));
}

test "an id no upstream holds is a 404 on both update and delete" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    try testing.expectEqual(
        Failure.not_found,
        (try applyUpdate(&bench.state, bench.io(), bench.arena(), 999, doh)).?,
    );
    try testing.expectEqual(
        Failure.not_found,
        (try applyDelete(&bench.state, bench.io(), bench.arena(), 999)).?,
    );
}
