//! `/api/groups` — the client groups, and each group's blocklist assignment
//! (ruling 9).
//!
//! A group change is live (ruling 12): the write lands, the snapshot is rebuilt
//! through `state.reload_fn`, and the next query is filtered by the new rules.
//!
//! The group named `default` is the one every client falls back to and the one
//! `config/validate.zig` insists on, so it can be edited but neither renamed
//! nor deleted. Both refusals are 409: the request is well formed and names a
//! row that exists, and the conflict is with an invariant of the configuration.
//!
//! Each route is two functions: an `apply` that decides and writes, and the
//! handler that parses the body and turns the decision into a response. The
//! split is what lets the decisions be tested against an in-memory database
//! with no socket in the way.

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

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

/// The group every client without one of its own belongs to.
pub const default_group_name = "default";

const name_conflict = "a group with that name already exists";

const Body = struct {
    name: []const u8,
    safe_search: bool = false,
};

const SourcesBody = struct {
    source_ids: []const i64,
};

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

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

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

    state.config_lock.lockUncancelable(io);
    const inserted = groups_repo.insertGroupRow(database, item);
    state.config_lock.unlock(io);

    const id = inserted catch |err| return .{ .fail = mutations.dbFailure(err, name_conflict) };
    if (mutations.reload(state, io)) |failure| return .{ .fail = failure };
    return .{ .id = id };
}

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

    state.config_lock.lockUncancelable(io);
    const outcome = updateLocked(database, arena, id, item);
    state.config_lock.unlock(io);

    if (outcome) |failure| return failure;
    return mutations.reload(state, io);
}

/// The read and the write are one critical section: the name that decides
/// whether the edit is legal must be the name the update overwrites.
fn updateLocked(database: *db.Db, arena: Allocator, id: i64, item: model.Group) ?Failure {
    const row = groups_repo.getGroup(database, arena, id) catch |err|
        return mutations.dbFailure(err, name_conflict);
    const current = row orelse return .not_found;
    if (std.mem.eql(u8, current.name, default_group_name) and
        !std.mem.eql(u8, item.name, default_group_name))
    {
        return .{ .conflict = "the default group cannot be renamed" };
    }

    groups_repo.updateGroup(database, id, item) catch |err|
        return mutations.dbFailure(err, name_conflict);
    return null;
}

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

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

    if (outcome) |failure| return failure;
    return mutations.reload(state, io);
}

fn deleteLocked(database: *db.Db, arena: Allocator, id: i64) ?Failure {
    // `clients.group_id` has no `ON DELETE`, so a group a client still belongs
    // to cannot go; rules, prefixes and assignments cascade (W2's map).
    const clients_conflict = "the group still has clients; move them first";

    const row = groups_repo.getGroup(database, arena, id) catch |err|
        return mutations.dbFailure(err, clients_conflict);
    const current = row orelse return .not_found;
    if (std.mem.eql(u8, current.name, default_group_name)) {
        return .{ .conflict = "the default group cannot be deleted" };
    }

    groups_repo.deleteGroup(database, id) catch |err|
        return mutations.dbFailure(err, clients_conflict);
    return null;
}

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

    state.config_lock.lockUncancelable(io);
    const outcome = groups_repo.setGroupSources(database, id, source_ids);
    state.config_lock.unlock(io);

    outcome catch |err| return mutations.dbFailure(err, "one of those blocklist sources does not exist");
    return mutations.reload(state, io);
}

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

const resource = mutations.Resource(.{
    .Row = groups_repo.GroupRow,
    .list = groups_repo.listGroupRows,
    .get = groups_repo.getGroup,
    .remove = applyDelete,
    .label = "a group",
    .plural = "groups",
    .envelope = "groups",
});

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: model.Group = .{ .name = parsed.value.name, .safe_search = parsed.value.safe_search };

    return switch (try applyCreate(state, io, request.arena, item)) {
        .fail => |failure| mutations.respondFailure(request, failure, "creating a group"),
        .id => |id| http_util.respondJson(request, .created, .{
            .id = id,
            .name = item.name,
            .safe_search = item.safe_search,
        }, &.{}),
    };
}

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: model.Group = .{ .name = parsed.value.name, .safe_search = parsed.value.safe_search };
    const id = request.id.?;

    if (try applyUpdate(state, io, request.arena, id, item)) |failure| {
        return mutations.respondFailure(request, failure, "updating a group");
    }
    return http_util.respondJson(request, .ok, .{
        .id = id,
        .name = item.name,
        .safe_search = item.safe_search,
    }, &.{});
}

/// `GET /api/groups/{id}/sources` — the assignment the PUT replaces.
pub fn getSources(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
    _ = io;
    const database = mutations.requireConfigDb(state) catch
        return mutations.respondFailure(request, mutations.no_config_db, "reading a group");
    const id = request.id.?;

    const row = groups_repo.getGroup(database, request.arena, id) catch |err|
        return mutations.respondFailure(request, .{ .internal = err }, "reading a group");
    if (row == null) return mutations.respondFailure(request, .not_found, "");

    const ids = groups_repo.listGroupSourceIds(database, request.arena, id) catch |err|
        return mutations.respondFailure(request, .{ .internal = err }, "reading a group's blocklists");

    return http_util.respondJson(request, .ok, .{ .source_ids = ids.items }, &.{});
}

/// `PUT /api/groups/{id}/sources` — the whole assignment, replaced (ruling 9).
/// Sending the same set twice leaves the same server state, which is what makes
/// the UI's checkbox list safe to save repeatedly.
pub fn putSources(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
    const parsed = http_util.parseBody(SourcesBody, request) catch |err|
        return mutations.respondBadBody(request, err);

    if (applySetSources(state, io, request.id.?, parsed.value.source_ids)) |failure| {
        return mutations.respondFailure(request, failure, "assigning blocklists to a group");
    }
    return http_util.respondJson(request, .ok, .{ .source_ids = parsed.value.source_ids }, &.{});
}

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

const testing = std.testing;

test "a created group is stored, returned by id and reloaded" {
    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(), .{
        .name = "kids",
        .safe_search = true,
    });
    const id = created.id;
    try testing.expectEqual(@as(usize, 1), bench.reloads);

    const row = (try groups_repo.getGroup(&bench.database, bench.arena(), id)).?;
    try testing.expectEqualStrings("kids", row.name);
    try testing.expect(row.safe_search);
}

test "a duplicate group name is a conflict, not a validation failure" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    _ = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .name = "kids" });
    const again = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .name = "kids" });

    try testing.expectEqualStrings(name_conflict, again.fail.conflict);
    // The failed write must not have been announced as a change.
    try testing.expectEqual(@as(usize, 1), bench.reloads);
}

test "an empty group name is refused before any write" {
    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(), .{ .name = "" });
    try testing.expect(created.fail == .invalid);
    try testing.expectEqual(@as(usize, 0), bench.reloads);
    try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM groups"));
}

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

    const failure = try applyUpdate(&bench.state, bench.io(), bench.arena(), 999, .{ .name = "kids" });
    try testing.expectEqual(Failure.not_found, failure.?);
    try testing.expectEqual(@as(usize, 0), bench.reloads);
}

test "a group edit renames and reloads" {
    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(), .{ .name = "kids" });
    const failure = try applyUpdate(&bench.state, bench.io(), bench.arena(), created.id, .{
        .name = "children",
        .safe_search = true,
    });

    try testing.expectEqual(@as(?Failure, null), failure);
    try testing.expectEqual(@as(usize, 2), bench.reloads);
    const row = (try groups_repo.getGroup(&bench.database, bench.arena(), created.id)).?;
    try testing.expectEqualStrings("children", row.name);
}

test "the default group may be edited but not renamed or deleted" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    const kept = try applyUpdate(&bench.state, bench.io(), bench.arena(), 1, .{
        .name = "default",
        .safe_search = true,
    });
    try testing.expectEqual(@as(?Failure, null), kept);

    const renamed = try applyUpdate(&bench.state, bench.io(), bench.arena(), 1, .{ .name = "primary" });
    try testing.expectEqualStrings("the default group cannot be renamed", renamed.?.conflict);

    const deleted = applyDelete(&bench.state, bench.io(), bench.arena(), 1);
    try testing.expectEqualStrings("the default group cannot be deleted", deleted.?.conflict);
    try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM groups WHERE id = 1"));
}

test "a group with clients 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(), .{ .name = "kids" });
    try bench.exec("INSERT INTO clients (ip, group_id, first_seen, last_seen) VALUES ('192.168.1.9', 2, 0, 0);");

    const failure = applyDelete(&bench.state, bench.io(), bench.arena(), created.id);
    try testing.expectEqualStrings("the group still has clients; move them first", failure.?.conflict);
    try testing.expectEqual(@as(usize, 1), bench.reloads);
}

test "deleting a group removes it and reloads" {
    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(), .{ .name = "kids" });
    try testing.expectEqual(@as(?Failure, null), applyDelete(&bench.state, bench.io(), bench.arena(), created.id));
    try testing.expectEqual(@as(usize, 2), bench.reloads);
    try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM groups"));

    try testing.expectEqual(
        Failure.not_found,
        applyDelete(&bench.state, bench.io(), bench.arena(), created.id).?,
    );
}

test "a group's blocklist assignment is replaced as a set" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    try bench.exec(
        \\INSERT INTO blocklist_sources (id, url, name) VALUES
        \\  (1, 'https://a.test/list.txt', 'a'), (2, 'https://b.test/list.txt', 'b');
    );

    try testing.expectEqual(
        @as(?Failure, null),
        applySetSources(&bench.state, bench.io(), 1, &.{ 1, 2 }),
    );
    try testing.expectEqual(@as(i64, 2), try bench.queryInt("SELECT count(*) FROM group_sources"));

    // Idempotent, and a shorter set removes what it leaves out.
    try testing.expectEqual(@as(?Failure, null), applySetSources(&bench.state, bench.io(), 1, &.{2}));
    try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM group_sources"));
    try testing.expectEqual(@as(i64, 2), try bench.queryInt("SELECT source_id FROM group_sources"));
    try testing.expectEqual(@as(usize, 2), bench.reloads);
}

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

    const failure = applySetSources(&bench.state, bench.io(), 1, &.{404});
    try testing.expectEqualStrings("one of those blocklist sources does not exist", failure.?.conflict);
    try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM group_sources"));
}

test "assigning sources to a group that does not exist is a 404" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    try testing.expectEqual(Failure.not_found, applySetSources(&bench.state, bench.io(), 999, &.{}).?);
}

test "a write with no configuration database is unavailable, not a crash" {
    var state: server.WebState = .{ .gpa = testing.allocator };
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();

    const created = try applyCreate(&state, undefined, arena_state.allocator(), .{ .name = "kids" });
    try testing.expect(created.fail == .unavailable);
}

test "a reload failure after a successful write is reported as not applied" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    bench.reload_fails = true;
    const created = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .name = "kids" });

    try testing.expectEqual(Failure.not_applied, created.fail);
    // The row is there: the write succeeded and only the announcement failed.
    try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM groups WHERE name = 'kids'"));
}
