//! `/api/local-records` and `/api/forward-zones` — the names nxdns answers
//! itself and the zones it hands to another resolver.
//!
//! Both take effect live (ruling 12), and not through the blocklist snapshot:
//! the two tables are rebuilt from the database and published into
//! `state.local_tables`, so the next query reads the new generation. The reload
//! seam is called as well, so the composition root learns about every
//! configuration change through one path.
//!
//! A record's type travels as the word the schema stores — `A`, `AAAA`,
//! `CNAME` — which is also the word the config file uses.

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

const http_util = @import("../http_util.zig");
const local_repo = @import("../../storage/repositories/local_repo.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 record_conflict = "that name, type and value are already stored";
const zone_conflict = "that zone already has a resolver";

const RecordBody = struct {
    name: []const u8,
    rtype: []const u8,
    value: []const u8,
    ttl: u32 = 300,
};

const ZoneBody = struct {
    zone: []const u8,
    resolver: []const u8,
};

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

fn toRecord(body: RecordBody) union(enum) { record: model.LocalRecord, fail: Failure } {
    const rtype = model.RecordType.fromDb(body.rtype) orelse
        return .{ .fail = .{ .invalid = "rtype must be 'A', 'AAAA' or 'CNAME'" } };
    return .{ .record = .{
        .name = body.name,
        .rtype = rtype,
        .value = body.value,
        .ttl = body.ttl,
    } };
}

/// The wire shape of a local record: the row with its type spelled the way the
/// schema spells it.
const RecordView = struct {
    id: i64,
    name: []const u8,
    rtype: []const u8,
    value: []const u8,
    ttl: u32,

    fn from(row: local_repo.LocalRecordRow) RecordView {
        return .{
            .id = row.id,
            .name = row.name,
            .rtype = row.rtype.toDb(),
            .value = row.value,
            .ttl = row.ttl,
        };
    }
};

// ---------------------------------------------------------------------------
// local records: decisions
// ---------------------------------------------------------------------------

/// Publishes the rebuilt tables and then announces the change. The swap comes
/// first because it is what makes the answer live; the seam only tells the rest
/// of the server that something moved.
///
/// Callers hold `state.config_lock` across the database write and this call:
/// the rebuild reads the generation the write produced, and the swap publishes
/// in write order — a concurrent mutation cannot overwrite a newer generation
/// with an older one.
fn publish(state: *server.WebState, io: std.Io, arena: Allocator, database: *@import("../../storage/db.zig").Db) ?Failure {
    if (mutations.swapLocalTables(state, io, arena, database)) |failure| return failure;
    return mutations.reload(state, io);
}

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

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

    const id = local_repo.insertLocalRecordRow(database, item) catch |err|
        return .{ .fail = mutations.dbFailure(err, record_conflict) };
    if (publish(state, io, arena, database)) |failure| return .{ .fail = failure };
    return .{ .id = id };
}

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

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

    local_repo.updateLocalRecord(database, id, item) catch |err|
        return mutations.dbFailure(err, record_conflict);
    return publish(state, io, arena, database);
}

fn applyDeleteRecord(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);
    defer state.config_lock.unlock(io);

    local_repo.deleteLocalRecord(database, id) catch |err|
        return mutations.dbFailure(err, record_conflict);
    return publish(state, io, arena, database);
}

// ---------------------------------------------------------------------------
// forward zones: decisions
// ---------------------------------------------------------------------------

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

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

    const id = local_repo.insertForwardZoneRow(database, item) catch |err|
        return .{ .fail = mutations.dbFailure(err, zone_conflict) };
    if (publish(state, io, arena, database)) |failure| return .{ .fail = failure };
    return .{ .id = id };
}

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

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

    local_repo.updateForwardZone(database, id, item) catch |err|
        return mutations.dbFailure(err, zone_conflict);
    return publish(state, io, arena, database);
}

fn applyDeleteZone(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);
    defer state.config_lock.unlock(io);

    local_repo.deleteForwardZone(database, id) catch |err|
        return mutations.dbFailure(err, zone_conflict);
    return publish(state, io, arena, database);
}

// ---------------------------------------------------------------------------
// local records: routes
// ---------------------------------------------------------------------------

const records_resource = mutations.Resource(.{
    .Row = local_repo.LocalRecordRow,
    .list = local_repo.listLocalRecordRows,
    .get = local_repo.getLocalRecord,
    .remove = applyDeleteRecord,
    .label = "a local record",
    .plural = "local records",
    .envelope = "local_records",
    .view = RecordView.from,
});

pub const listRecords = records_resource.list;
pub const getRecord = records_resource.get;
pub const removeRecord = records_resource.remove;

pub fn createRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
    const parsed = http_util.parseBody(RecordBody, request) catch |err|
        return mutations.respondBadBody(request, err);
    const item = switch (toRecord(parsed.value)) {
        .fail => |failure| return mutations.respondFailure(request, failure, "creating a local record"),
        .record => |value| value,
    };

    return switch (try applyCreateRecord(state, io, request.arena, item)) {
        .fail => |failure| mutations.respondFailure(request, failure, "creating a local record"),
        .id => |id| http_util.respondJson(request, .created, .{
            .id = id,
            .name = item.name,
            .rtype = item.rtype.toDb(),
            .value = item.value,
            .ttl = item.ttl,
        }, &.{}),
    };
}

pub fn updateRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
    const parsed = http_util.parseBody(RecordBody, request) catch |err|
        return mutations.respondBadBody(request, err);
    const item = switch (toRecord(parsed.value)) {
        .fail => |failure| return mutations.respondFailure(request, failure, "updating a local record"),
        .record => |value| value,
    };
    const id = request.id.?;

    if (try applyUpdateRecord(state, io, request.arena, id, item)) |failure| {
        return mutations.respondFailure(request, failure, "updating a local record");
    }
    return http_util.respondJson(request, .ok, .{
        .id = id,
        .name = item.name,
        .rtype = item.rtype.toDb(),
        .value = item.value,
        .ttl = item.ttl,
    }, &.{});
}

// ---------------------------------------------------------------------------
// forward zones: routes
// ---------------------------------------------------------------------------

const zones_resource = mutations.Resource(.{
    .Row = local_repo.ForwardZoneRow,
    .list = local_repo.listForwardZoneRows,
    .get = local_repo.getForwardZone,
    .remove = applyDeleteZone,
    .label = "a forward zone",
    .plural = "forward zones",
    .envelope = "forward_zones",
});

pub const listZones = zones_resource.list;
pub const getZone = zones_resource.get;
pub const removeZone = zones_resource.remove;

pub fn createZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
    const parsed = http_util.parseBody(ZoneBody, request) catch |err|
        return mutations.respondBadBody(request, err);
    const item: model.ForwardZone = .{ .zone = parsed.value.zone, .resolver = parsed.value.resolver };

    return switch (try applyCreateZone(state, io, request.arena, item)) {
        .fail => |failure| mutations.respondFailure(request, failure, "creating a forward zone"),
        .id => |id| http_util.respondJson(request, .created, .{
            .id = id,
            .zone = item.zone,
            .resolver = item.resolver,
        }, &.{}),
    };
}

pub fn updateZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
    const parsed = http_util.parseBody(ZoneBody, request) catch |err|
        return mutations.respondBadBody(request, err);
    const item: model.ForwardZone = .{ .zone = parsed.value.zone, .resolver = parsed.value.resolver };
    const id = request.id.?;

    if (try applyUpdateZone(state, io, request.arena, id, item)) |failure| {
        return mutations.respondFailure(request, failure, "updating a forward zone");
    }
    return http_util.respondJson(request, .ok, .{
        .id = id,
        .zone = item.zone,
        .resolver = item.resolver,
    }, &.{});
}

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

const testing = std.testing;

const nas: model.LocalRecord = .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 60 };
const lan: model.ForwardZone = .{ .zone = "lan", .resolver = "udp://10.0.0.1:53" };

test "a created local record is answered by the published table" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    const created = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas);
    try testing.expect(created == .id);
    try testing.expectEqual(@as(usize, 1), bench.reloads);

    const handle = bench.tables.acquire(bench.io());
    defer handle.release(bench.io());
    try testing.expect(handle.records.hasName("nas.lan"));
    try testing.expectEqual(@as(usize, 1), handle.records.lookup("nas.lan", .a).len);
}

test "an edited local record replaces what the table answers" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    const created = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas);
    const failure = try applyUpdateRecord(&bench.state, bench.io(), bench.arena(), created.id, .{
        .name = "printer.lan",
        .rtype = .a,
        .value = "192.168.1.11",
        .ttl = 120,
    });
    try testing.expectEqual(@as(?Failure, null), failure);

    const handle = bench.tables.acquire(bench.io());
    defer handle.release(bench.io());
    try testing.expect(!handle.records.hasName("nas.lan"));
    try testing.expect(handle.records.hasName("printer.lan"));
}

test "a deleted local record leaves the published table empty" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    const created = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas);
    try testing.expectEqual(
        @as(?Failure, null),
        applyDeleteRecord(&bench.state, bench.io(), bench.arena(), created.id),
    );

    const handle = bench.tables.acquire(bench.io());
    defer handle.release(bench.io());
    try testing.expect(!handle.records.hasName("nas.lan"));
    try testing.expectEqual(@as(usize, 2), bench.reloads);
}

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

    const bad_value = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), .{
        .name = "nas.lan",
        .rtype = .a,
        .value = "2001:db8::1",
        .ttl = 60,
    });
    try testing.expect(bad_value.fail == .invalid);

    const bad_ttl = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), .{
        .name = "nas.lan",
        .rtype = .a,
        .value = "192.168.1.10",
        .ttl = 0,
    });
    try testing.expect(bad_ttl.fail == .invalid);

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

test "the same record twice is a conflict" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    _ = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas);
    const again = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas);
    try testing.expectEqualStrings(record_conflict, again.fail.conflict);
}

test "an id no record 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 applyUpdateRecord(&bench.state, bench.io(), bench.arena(), 999, nas)).?,
    );
    try testing.expectEqual(
        Failure.not_found,
        applyDeleteRecord(&bench.state, bench.io(), bench.arena(), 999).?,
    );
}

test "a created forward zone is matched by the published table" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    const created = try applyCreateZone(&bench.state, bench.io(), bench.arena(), lan);
    try testing.expect(created == .id);

    const handle = bench.tables.acquire(bench.io());
    defer handle.release(bench.io());
    try testing.expect(handle.zones.match("nas.lan") != null);
    try testing.expect(handle.zones.match("example.test") == null);
}

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

    const created = try applyCreateZone(&bench.state, bench.io(), bench.arena(), .{
        .zone = "lan",
        .resolver = "https://10.0.0.1",
    });
    try testing.expect(created.fail == .invalid);
    try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM forward_zones"));
}

test "one zone cannot have two resolvers" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    _ = try applyCreateZone(&bench.state, bench.io(), bench.arena(), lan);
    const again = try applyCreateZone(&bench.state, bench.io(), bench.arena(), .{
        .zone = "lan",
        .resolver = "tcp://10.0.0.2:53",
    });
    try testing.expectEqualStrings(zone_conflict, again.fail.conflict);
}

test "a deleted zone stops matching" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    const created = try applyCreateZone(&bench.state, bench.io(), bench.arena(), lan);
    try testing.expectEqual(
        @as(?Failure, null),
        applyDeleteZone(&bench.state, bench.io(), bench.arena(), created.id),
    );

    const handle = bench.tables.acquire(bench.io());
    defer handle.release(bench.io());
    try testing.expect(handle.zones.match("nas.lan") == null);
}

test "a record change is visible to a reader that acquires afterwards" {
    var bench: mutations.Bench = undefined;
    try bench.init(testing.allocator);
    defer bench.deinit(testing.allocator);

    const before = bench.tables.acquire(bench.io());
    try testing.expect(!before.records.hasName("nas.lan"));
    before.release(bench.io());

    _ = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas);

    const after = bench.tables.acquire(bench.io());
    defer after.release(bench.io());
    try testing.expect(after.records.hasName("nas.lan"));
}

test "an unknown record type is a 400 before anything is written" {
    try testing.expect(toRecord(.{
        .name = "nas.lan",
        .rtype = "MX",
        .value = "mail.lan",
    }).fail == .invalid);

    const good = toRecord(.{ .name = "nas.lan", .rtype = "CNAME", .value = "other.lan" });
    try testing.expectEqual(model.RecordType.cname, good.record.rtype);
    try testing.expectEqual(@as(u32, 300), good.record.ttl);
}
