//! `/api/clients` and `/api/client-prefixes` — which device belongs to which
//! group.
//!
//! Clients have no POST (ruling 9): a row appears because the DNS path saw the
//! address or because an import wrote it. What the API adds is an edit — a name
//! and a group — and an edit is what turns a materialised row into
//! configuration, so every PUT sets `hand_edited` and the stale-client prune
//! stops considering the row (W2's `ClientEdit`).
//!
//! `ip` is not editable. It is the identity `upsertSeen` matches a live device
//! by; rewriting it would collide with the row the tracker re-materialises for
//! the device that still holds the address. A DELETE is how an operator forgets
//! a device, and a device that keeps querying comes back materialised.
//!
//! Client prefixes are one small list resource, replaced whole and atomically
//! (ruling 9): the table is a handful of rows and a partial update of an
//! ordered, priority-carrying set is more ways to be wrong than to be right.
//! Each prefix is stored in canonical text (dotted decimal, RFC 5952, host
//! bits zeroed), so two spellings of one network collide in the API instead
//! of surviving as an ambiguous pair the next restart's validation rejects.

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

const address = @import("../../platform/address.zig");
const clients_repo = @import("../../storage/repositories/clients_repo.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 Failure = mutations.Failure;
const Request = http_util.Request;
const HandlerError = http_util.HandlerError;

const group_conflict = "that group does not exist";
const prefix_conflict = "that prefix is listed twice, or names a group that does not exist";

const ClientBody = struct {
    name: []const u8 = "",
    group_id: i64,
};

const PrefixItem = struct {
    prefix: []const u8,
    group_id: i64,
    priority: i32 = 100,
};

const PrefixesBody = struct {
    client_prefixes: []const PrefixItem,
};

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

fn applyUpdate(
    state: *server.WebState,
    io: std.Io,
    id: i64,
    edit: clients_repo.ClientEdit,
) ?Failure {
    const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;

    state.config_lock.lockUncancelable(io);
    const written = clients_repo.updateClient(database, id, edit);
    state.config_lock.unlock(io);

    written catch |err| return mutations.dbFailure(err, group_conflict);
    return mutations.reload(state, io);
}

fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
    const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;

    state.config_lock.lockUncancelable(io);
    const written = clients_repo.deleteClient(database, id);
    state.config_lock.unlock(io);

    written catch |err| return mutations.dbFailure(err, group_conflict);
    return mutations.reload(state, io);
}

fn applyReplacePrefixes(
    state: *server.WebState,
    io: std.Io,
    arena: Allocator,
    items: []const clients_repo.ClientPrefixInput,
) error{OutOfMemory}!?Failure {
    const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;

    // Canonical duplicates are the same UNIQUE collision the database would
    // report for identical text, so they answer 409 (ruling 9) before the
    // validator can call the second spelling a 400. Unparseable text stays
    // out of the set; the validator names it below.
    const stored = try arena.alloc(clients_repo.ClientPrefixInput, items.len);
    var seen: std.StringHashMapUnmanaged(void) = .empty;
    for (stored, items) |*out, item| {
        out.* = item;
        const parsed = address.Prefix.parse(item.prefix) catch continue;
        out.prefix = try canonicalText(arena, parsed);
        const entry = try seen.getOrPut(arena, out.prefix);
        if (entry.found_existing) return .{ .conflict = prefix_conflict };
    }

    if (try checkPrefixSet(arena, stored)) |problem| return .{ .invalid = problem };

    state.config_lock.lockUncancelable(io);
    const written = clients_repo.replaceClientPrefixes(database, stored);
    state.config_lock.unlock(io);

    written catch |err| return mutations.dbFailure(err, prefix_conflict);
    return mutations.reload(state, io);
}

fn canonicalText(arena: Allocator, prefix: address.Prefix) error{OutOfMemory}![]u8 {
    // The longest form this writes is an IPv6 prefix, 45 + 4 bytes.
    var buf: [64]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    prefix.format(&w) catch unreachable;
    return arena.dupe(u8, w.buffered());
}

/// The whole candidate list through the real validator, inside the same
/// skeleton `mutations.checkClientPrefix` uses — group ids cannot be mapped
/// to names here, so every row wears the skeleton group and the foreign key
/// still answers for ids that name no group.
fn checkPrefixSet(
    arena: Allocator,
    items: []const clients_repo.ClientPrefixInput,
) error{OutOfMemory}!?[]const u8 {
    const rows = try arena.alloc(model.ClientPrefix, items.len);
    for (rows, items) |*row, item| row.* = .{ .prefix = item.prefix, .priority = item.priority };
    return mutations.firstProblem(arena, .{
        .upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
        .groups = &.{.{ .name = "default" }},
        .client_prefixes = rows,
    });
}

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

const resource = mutations.Resource(.{
    .Row = clients_repo.ClientRow,
    .list = clients_repo.listClientRows,
    .get = clients_repo.getClient,
    .remove = applyDelete,
    .label = "a client",
    .plural = "clients",
    .envelope = "clients",
});

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

/// What file authority found when it went to delete a row.
pub const ObservedDelete = enum { deleted, declared, absent };

/// Reads `hand_edited` and acts on it inside one `BEGIN IMMEDIATE`, because the
/// two halves are a single decision. Split across two statements, a concurrent
/// `nxdns import` — which takes the same write lock for its own reconcile — can
/// promote the row between the read and the DELETE, and file authority would
/// delete a client the file had just declared. Holding the write lock across
/// both makes the promotion wait, and it then sees the row already gone or
/// still there, never half of each.
///
/// A read-only outcome commits an empty transaction, which costs nothing and
/// keeps the one exit path.
fn deleteIfObserved(database: *db.Db, arena: Allocator, id: i64) db.Error!ObservedDelete {
    var tx = try db.Tx.begin(database);
    errdefer tx.rollback();

    const row = try clients_repo.getClient(database, arena, id);
    const verdict: ObservedDelete = if (row) |found|
        (if (found.hand_edited) .declared else .deleted)
    else
        .absent;

    if (verdict == .deleted) try clients_repo.deleteClient(database, id);
    try tx.commit();
    return verdict;
}

/// DELETE is a `runtime_action` in the route table (milestone-20 ruling 7), so
/// file authority lets it through: an observed row is runtime state the file
/// never declared, and without a way to remove it a mis-identified or departed
/// device would be immortal — the file can promote an IP, never forget one.
/// A row the file *declares* is configuration, and deleting it would contradict
/// the file, so it answers the same 403 the router answers elsewhere. This is
/// the one policy decision that needs a row read, which is why it is here and
/// not a table column.
///
/// A row that is not there is a 404, exactly as in database mode: file
/// authority must not turn a missing row into a policy verdict.
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
    const path = switch (state.authority) {
        .database => return resource.remove(state, io, request),
        .managed_file => |managed| managed,
    };

    const database = mutations.requireConfigDb(state) catch
        return mutations.respondFailure(request, mutations.no_config_db, delete_what);

    state.config_lock.lockUncancelable(io);
    const outcome = deleteIfObserved(database, request.arena, request.id.?);
    state.config_lock.unlock(io);

    switch (outcome catch |err| return mutations.respondFailure(
        request,
        mutations.dbFailure(err, group_conflict),
        delete_what,
    )) {
        .absent => return mutations.respondFailure(request, .not_found, ""),
        .declared => return http_util.respondManagedByFile(request, path),
        .deleted => {},
    }

    if (mutations.reload(state, io)) |failure| {
        return mutations.respondFailure(request, failure, delete_what);
    }
    return http_util.respondEmpty(request, .no_content);
}

const delete_what = "deleting a client";

/// The prefixes are one list resource with no `/{id}` route: the whole set is
/// read and replaced (ruling 9), so there is nothing to get or delete by id.
const prefixes_resource = mutations.Resource(.{
    .Row = clients_repo.ClientPrefixRow,
    .list = clients_repo.listClientPrefixRows,
    .get = null,
    .remove = null,
    .label = "a client prefix",
    .plural = "client prefixes",
    .envelope = "client_prefixes",
});

pub const listPrefixes = prefixes_resource.list;

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

    if (applyUpdate(state, io, id, .{
        .name = parsed.value.name,
        .group_id = parsed.value.group_id,
    })) |failure| {
        return mutations.respondFailure(request, failure, "updating a client");
    }

    const database = state.config_db.?;
    const row = clients_repo.getClient(database, request.arena, id) catch |err|
        return mutations.respondFailure(request, .{ .internal = err }, "reading a client");
    const found = row orelse return mutations.respondFailure(request, .not_found, "");

    return http_util.respondJson(request, .ok, found, &.{});
}

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

    const items = try request.arena.alloc(clients_repo.ClientPrefixInput, parsed.value.client_prefixes.len);
    for (items, parsed.value.client_prefixes) |*item, body| item.* = .{
        .prefix = body.prefix,
        .group_id = body.group_id,
        .priority = body.priority,
    };

    if (try applyReplacePrefixes(state, io, request.arena, items)) |failure| {
        return mutations.respondFailure(request, failure, "replacing the client prefixes");
    }

    const database = state.config_db.?;
    const rows = clients_repo.listClientPrefixRows(database, request.arena) catch |err|
        return mutations.respondFailure(request, .{ .internal = err }, "listing client prefixes");

    return http_util.respondJson(request, .ok, .{ .client_prefixes = rows.items }, &.{});
}

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

const testing = std.testing;

fn seedClient(bench: *mutations.Bench) !void {
    try bench.exec(
        \\INSERT INTO groups (id, name) VALUES (2, 'kids');
        \\INSERT INTO clients (id, ip, group_id, hand_edited, first_seen, last_seen)
        \\VALUES (1, '192.168.1.10', 1, 0, 100, 200);
    );
}

test "editing a client names it, moves it and marks it hand edited" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);
    try seedClient(&bench);

    const failure = applyUpdate(&bench.state, bench.io(), 1, .{ .name = "laptop", .group_id = 2 });
    try testing.expectEqual(@as(?Failure, null), failure);
    try testing.expectEqual(@as(usize, 1), bench.reloads);

    const row = (try clients_repo.getClient(&bench.database, bench.arena(), 1)).?;
    try testing.expectEqualStrings("laptop", row.name);
    try testing.expectEqualStrings("kids", row.group);
    try testing.expect(row.hand_edited);
    // The tracker's timestamps and the address are not the API's to move.
    try testing.expectEqualStrings("192.168.1.10", row.ip);
    try testing.expectEqual(@as(i64, 100), row.first_seen);
}

test "editing a client into a group that does not exist is a conflict" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);
    try seedClient(&bench);

    const failure = applyUpdate(&bench.state, bench.io(), 1, .{ .name = "laptop", .group_id = 404 });
    try testing.expectEqualStrings(group_conflict, failure.?.conflict);
    try testing.expectEqual(@as(usize, 0), bench.reloads);
}

test "an id no client 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,
        applyUpdate(&bench.state, bench.io(), 999, .{ .group_id = 1 }).?,
    );
    try testing.expectEqual(Failure.not_found, applyDelete(&bench.state, bench.io(), 999).?);
}

test "deleting a client removes the row and announces the change" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);
    try seedClient(&bench);

    try testing.expectEqual(@as(?Failure, null), applyDelete(&bench.state, bench.io(), 1));
    try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM clients"));
    try testing.expectEqual(@as(usize, 1), bench.reloads);
}

test "file authority deletes an observed client and refuses a declared one" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);
    try seedClient(&bench);
    try bench.exec(
        \\INSERT INTO clients (id, ip, group_id, hand_edited, first_seen, last_seen)
        \\VALUES (2, '192.168.1.11', 1, 1, 100, 200);
    );

    // The declared row is configuration; it survives, and nothing is written.
    try testing.expectEqual(ObservedDelete.declared, try deleteIfObserved(&bench.database, bench.arena(), 2));
    try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 2"));

    try testing.expectEqual(ObservedDelete.deleted, try deleteIfObserved(&bench.database, bench.arena(), 1));
    try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 1"));

    try testing.expectEqual(ObservedDelete.absent, try deleteIfObserved(&bench.database, bench.arena(), 999));
}

test "the observed check and the delete are one transaction" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);
    try seedClient(&bench);

    // SQLite refuses a `BEGIN IMMEDIATE` inside an open transaction, so a held
    // transaction is what proves this takes the write lock rather than reading
    // and deleting through two unsynchronised statements — the window a
    // concurrent `nxdns import` would promote the row in. Without the
    // transaction both statements run and the row is gone.
    var tx = try db.Tx.begin(&bench.database);
    try testing.expectError(error.Unexpected, deleteIfObserved(&bench.database, bench.arena(), 1));
    tx.rollback();

    // The row is untouched: the refusal happened before any statement ran.
    try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 1"));
}

test "the prefix list is replaced whole" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);
    try bench.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");

    try testing.expectEqual(@as(?Failure, null), try applyReplacePrefixes(
        &bench.state,
        bench.io(),
        bench.arena(),
        &.{
            .{ .prefix = "192.168.1.0/24", .group_id = 1, .priority = 10 },
            .{ .prefix = "192.168.2.0/24", .group_id = 2, .priority = 20 },
        },
    ));
    try testing.expectEqual(@as(i64, 2), try bench.queryInt("SELECT count(*) FROM client_prefixes"));

    try testing.expectEqual(@as(?Failure, null), try applyReplacePrefixes(
        &bench.state,
        bench.io(),
        bench.arena(),
        &.{.{ .prefix = "10.0.0.0/8", .group_id = 1 }},
    ));
    const rows = try clients_repo.listClientPrefixRows(&bench.database, bench.arena());
    try testing.expectEqual(@as(usize, 1), rows.items.len);
    try testing.expectEqualStrings("10.0.0.0/8", rows.items[0].prefix);
    try testing.expectEqual(@as(i32, 100), rows.items[0].priority);
    try testing.expectEqual(@as(usize, 2), bench.reloads);
}

test "an empty prefix list clears the table" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    _ = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{
        .{ .prefix = "192.168.1.0/24", .group_id = 1 },
    });
    try testing.expectEqual(@as(?Failure, null), try applyReplacePrefixes(
        &bench.state,
        bench.io(),
        bench.arena(),
        &.{},
    ));
    try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM client_prefixes"));
}

test "a malformed prefix is refused and the stored list survives" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    _ = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{
        .{ .prefix = "192.168.1.0/24", .group_id = 1 },
    });

    const failure = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{
        .{ .prefix = "192.168.2.0/24", .group_id = 1 },
        .{ .prefix = "not-a-prefix", .group_id = 1 },
    });
    try testing.expect(failure.? == .invalid);

    const rows = try clients_repo.listClientPrefixRows(&bench.database, bench.arena());
    try testing.expectEqual(@as(usize, 1), rows.items.len);
    try testing.expectEqualStrings("192.168.1.0/24", rows.items[0].prefix);
    try testing.expectEqual(@as(usize, 1), bench.reloads);
}

test "one prefix twice is a conflict and the old list survives" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    _ = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{
        .{ .prefix = "10.0.0.0/8", .group_id = 1 },
    });

    const failure = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{
        .{ .prefix = "192.168.1.0/24", .group_id = 1 },
        .{ .prefix = "192.168.1.0/24", .group_id = 1, .priority = 50 },
    });
    try testing.expectEqualStrings(prefix_conflict, failure.?.conflict);

    const rows = try clients_repo.listClientPrefixRows(&bench.database, bench.arena());
    try testing.expectEqual(@as(usize, 1), rows.items.len);
    try testing.expectEqualStrings("10.0.0.0/8", rows.items[0].prefix);
}

test "two spellings of one prefix in one PUT are a conflict" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    _ = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{
        .{ .prefix = "10.0.0.0/8", .group_id = 1 },
    });

    const v6_case = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{
        .{ .prefix = "fd00:abcd::/48", .group_id = 1 },
        .{ .prefix = "FD00:ABCD:0:0:0:0:0:0/48", .group_id = 1, .priority = 50 },
    });
    try testing.expectEqualStrings(prefix_conflict, v6_case.?.conflict);

    const host_bits = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{
        .{ .prefix = "192.168.1.0/24", .group_id = 1 },
        .{ .prefix = "192.168.1.55/24", .group_id = 1, .priority = 50 },
    });
    try testing.expectEqualStrings(prefix_conflict, host_bits.?.conflict);

    const rows = try clients_repo.listClientPrefixRows(&bench.database, bench.arena());
    try testing.expectEqual(@as(usize, 1), rows.items.len);
    try testing.expectEqualStrings("10.0.0.0/8", rows.items[0].prefix);
    try testing.expectEqual(@as(usize, 1), bench.reloads);
}

test "a prefix is stored and listed in canonical form" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    try testing.expectEqual(@as(?Failure, null), try applyReplacePrefixes(
        &bench.state,
        bench.io(),
        bench.arena(),
        &.{
            .{ .prefix = "FD00:ABCD:0:0:0:0:0:0/48", .group_id = 1 },
            .{ .prefix = "192.168.1.55/24", .group_id = 1, .priority = 50 },
        },
    ));

    // `listPrefixes` serves these rows, so the GET body carries the same text.
    const rows = try clients_repo.listClientPrefixRows(&bench.database, bench.arena());
    try testing.expectEqual(@as(usize, 2), rows.items.len);
    try testing.expectEqualStrings("192.168.1.0/24", rows.items[0].prefix);
    try testing.expectEqualStrings("fd00:abcd::/48", rows.items[1].prefix);
}

test "a prefix naming a group that does not exist is a conflict" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    const failure = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{
        .{ .prefix = "192.168.1.0/24", .group_id = 404 },
    });
    try testing.expectEqualStrings(prefix_conflict, failure.?.conflict);
}
