//! `/api/rules` — the per-group allow and block rules.
//!
//! A rule names its group by row id, not by name: the API identifies every
//! resource by id, and a `group_id` no group holds is then the foreign-key
//! violation it is (409) rather than a lookup that quietly writes nothing.
//!
//! `kind` and `action` travel as the words the database stores (`exact` /
//! `wildcard`, `allow` / `block`), so one vocabulary describes a rule in the
//! config file, in the database and on the wire.
//!
//! Rules take effect live: the write is followed by the reload seam, and the
//! next query is matched against the new snapshot (ruling 12).

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

const http_util = @import("../http_util.zig");
const model = @import("../../config/model.zig");
const mutations = @import("mutations.zig");
const rules_repo = @import("../../storage/repositories/rules_repo.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 Body = struct {
    group_id: i64,
    pattern: []const u8,
    kind: []const u8,
    action: []const u8,
};

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

/// A body's `kind` and `action` decoded, or the 400 that says which word was
/// not understood.
fn toInput(body: Body) union(enum) { input: rules_repo.RuleInput, fail: Failure } {
    const kind = model.RuleKind.fromDb(body.kind) orelse
        return .{ .fail = .{ .invalid = "kind must be 'exact', 'wildcard' or 'regex'" } };
    const action = model.RuleAction.fromDb(body.action) orelse
        return .{ .fail = .{ .invalid = "action must be 'allow' or 'block'" } };
    return .{ .input = .{
        .group_id = body.group_id,
        .pattern = body.pattern,
        .kind = kind,
        .action = action,
    } };
}

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

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

    state.config_lock.lockUncancelable(io);
    const inserted = rules_repo.insertRuleRow(database, item, mutations.nowSeconds(io));
    state.config_lock.unlock(io);

    const id = inserted catch |err| return .{ .fail = mutations.dbFailure(err, group_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: rules_repo.RuleInput,
) error{OutOfMemory}!?Failure {
    const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
    if (try mutations.checkRule(arena, item.pattern, item.kind)) |problem| {
        return .{ .invalid = problem };
    }

    state.config_lock.lockUncancelable(io);
    const written = rules_repo.updateRule(database, id, item);
    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 = rules_repo.deleteRule(database, id);
    state.config_lock.unlock(io);

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

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

/// The wire shape of a rule: the row with its enums spelled the way the
/// database spells them.
const RuleView = struct {
    id: i64,
    group_id: i64,
    group: []const u8,
    pattern: []const u8,
    kind: []const u8,
    action: []const u8,
    created_at: i64,

    fn from(row: rules_repo.RuleRow) RuleView {
        return .{
            .id = row.id,
            .group_id = row.group_id,
            .group = row.group,
            .pattern = row.pattern,
            .kind = row.kind.toDb(),
            .action = row.action.toDb(),
            .created_at = row.created_at,
        };
    }
};

const resource = mutations.Resource(.{
    .Row = rules_repo.RuleRow,
    .list = rules_repo.listRuleRows,
    .get = rules_repo.getRule,
    .remove = applyDelete,
    .label = "a rule",
    .plural = "rules",
    .envelope = "rules",
    .view = RuleView.from,
});

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 = switch (toInput(parsed.value)) {
        .fail => |failure| return mutations.respondFailure(request, failure, "creating a rule"),
        .input => |value| value,
    };

    return switch (try applyCreate(state, io, request.arena, item)) {
        .fail => |failure| mutations.respondFailure(request, failure, "creating a rule"),
        .id => |id| http_util.respondJson(request, .created, .{
            .id = id,
            .group_id = item.group_id,
            .pattern = item.pattern,
            .kind = item.kind.toDb(),
            .action = item.action.toDb(),
        }, &.{}),
    };
}

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 = switch (toInput(parsed.value)) {
        .fail => |failure| return mutations.respondFailure(request, failure, "updating a rule"),
        .input => |value| value,
    };
    const id = request.id.?;

    if (try applyUpdate(state, io, request.arena, id, item)) |failure| {
        return mutations.respondFailure(request, failure, "updating a rule");
    }
    return http_util.respondJson(request, .ok, .{
        .id = id,
        .group_id = item.group_id,
        .pattern = item.pattern,
        .kind = item.kind.toDb(),
        .action = item.action.toDb(),
    }, &.{});
}

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

const testing = std.testing;

const block_ads: rules_repo.RuleInput = .{
    .group_id = 1,
    .pattern = "ads.example",
    .kind = .exact,
    .action = .block,
};

test "a created rule is stored with the clock's created_at 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(), block_ads);
    try testing.expectEqual(@as(usize, 1), bench.reloads);

    const row = (try rules_repo.getRule(&bench.database, bench.arena(), created.id)).?;
    try testing.expectEqualStrings("ads.example", row.pattern);
    try testing.expectEqual(model.RuleAction.block, row.action);
    try testing.expectEqualStrings("default", row.group);
    try testing.expect(row.created_at > 0);
}

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

    const starred = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
        .group_id = 1,
        .pattern = "ads.*.example",
        .kind = .exact,
        .action = .block,
    });
    try testing.expect(starred.fail == .invalid);

    const starless = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
        .group_id = 1,
        .pattern = "ads.example",
        .kind = .wildcard,
        .action = .allow,
    });
    try testing.expect(starless.fail == .invalid);

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

test "a group id no group holds is a conflict" {
    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(), .{
        .group_id = 404,
        .pattern = "ads.example",
        .kind = .exact,
        .action = .block,
    });
    try testing.expectEqualStrings(group_conflict, created.fail.conflict);
    try testing.expectEqual(@as(usize, 0), bench.reloads);
}

test "an edited rule keeps its created_at" {
    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(), block_ads);
    const before = (try rules_repo.getRule(&bench.database, bench.arena(), created.id)).?.created_at;

    const failure = try applyUpdate(&bench.state, bench.io(), bench.arena(), created.id, .{
        .group_id = 1,
        .pattern = "*.ads.example",
        .kind = .wildcard,
        .action = .allow,
    });
    try testing.expectEqual(@as(?Failure, null), failure);

    const row = (try rules_repo.getRule(&bench.database, bench.arena(), created.id)).?;
    try testing.expectEqualStrings("*.ads.example", row.pattern);
    try testing.expectEqual(model.RuleKind.wildcard, row.kind);
    try testing.expectEqual(before, row.created_at);
    try testing.expectEqual(@as(usize, 2), bench.reloads);
}

test "an id no rule 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, block_ads)).?,
    );
    try testing.expectEqual(Failure.not_found, applyDelete(&bench.state, bench.io(), 999).?);
    try testing.expectEqual(@as(usize, 0), bench.reloads);
}

test "a deleted rule is gone and the change is announced" {
    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(), block_ads);
    try testing.expectEqual(@as(?Failure, null), applyDelete(&bench.state, bench.io(), created.id));
    try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM rules"));
    try testing.expectEqual(@as(usize, 2), bench.reloads);
}

test "an unknown kind or action is a 400 before anything is written" {
    try testing.expect(toInput(.{
        .group_id = 1,
        .pattern = "ads.example",
        .kind = "glob",
        .action = "block",
    }).fail == .invalid);

    try testing.expect(toInput(.{
        .group_id = 1,
        .pattern = "ads.example",
        .kind = "exact",
        .action = "drop",
    }).fail == .invalid);

    const good = toInput(.{
        .group_id = 1,
        .pattern = "ads.example",
        .kind = "wildcard",
        .action = "allow",
    });
    try testing.expectEqual(model.RuleKind.wildcard, good.input.kind);
    try testing.expectEqual(model.RuleAction.allow, good.input.action);

    const third = toInput(.{
        .group_id = 1,
        .pattern = "^ad[0-9]+-",
        .kind = "regex",
        .action = "block",
    });
    try testing.expectEqual(model.RuleKind.regex, third.input.kind);
}

test "a regex rule is stored, and a pattern the engine refuses is a 400 that names it" {
    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(), .{
        .group_id = 1,
        .pattern = "^ad[0-9]+-",
        .kind = .regex,
        .action = .block,
    });
    const row = (try rules_repo.getRule(&bench.database, bench.arena(), created.id)).?;
    try testing.expectEqual(model.RuleKind.regex, row.kind);
    // Stored verbatim: a regex is not a name, so nothing lowercases or
    // dot-strips it on the way to the table.
    try testing.expectEqualStrings("^ad[0-9]+-", row.pattern);

    const unclosed = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
        .group_id = 1,
        .pattern = "(",
        .kind = .regex,
        .action = .block,
    });
    try testing.expectEqualStrings(
        "rules[0].pattern: '(' is not a valid regex pattern",
        unclosed.fail.invalid,
    );

    // The refused pattern reached no table, and the good one is still the only
    // row: a 400 costs no write and no reload.
    try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM rules"));
    try testing.expectEqual(@as(usize, 1), bench.reloads);
}
