//! The published local-answer tables: the compiled local records and the
//! compiled forward zones the query path reads (milestone-8 ruling 12).
//!
//! Both tables are immutable once built, so publishing a new one is a pointer
//! swap under an `std.Io.RwLock` — the blocklist manager's pattern at a much
//! smaller scale, and for the same reason: a shared lock held for the
//! microseconds of one lookup costs an uncontended atomic pair, and reclaiming
//! the old table without a lock would need epoch tracking this project has no
//! use for.
//!
//! The two tables live under one lock because one API call can change either
//! and the query path reads both in sequence. Two locks would double the cost
//! of every query to buy nothing.
//!
//! `acquire` brackets exactly one query. The handle borrows the live fields, so
//! it must not outlive its `release` — which is why `swap` may free the tables
//! it replaced as soon as it has the exclusive lock: no reader can still be
//! holding them.

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

const forward_zones = @import("../local/forward_zones.zig");
const records = @import("../local/records.zig");

pub const LocalTables = struct {
    lock: std.Io.RwLock = .init,
    records: records.Records = .empty,
    zones: forward_zones.Zones = .empty,

    /// No records and no zones: every name goes to the filtering path.
    pub const empty: LocalTables = .{};

    /// Reader side of the swap. The pointers are the live fields, so release
    /// the handle before the query ends and do not retain them.
    pub const Handle = struct {
        records: *const records.Records,
        zones: *const forward_zones.Zones,
        tables: *LocalTables,

        pub fn release(self: Handle, io: std.Io) void {
            self.tables.lock.unlockShared(io);
        }
    };

    /// Uncancelable, like the manager's: the critical section is a lookup with
    /// no socket and no file in it, so it always completes.
    pub fn acquire(self: *LocalTables, io: std.Io) Handle {
        self.lock.lockSharedUncancelable(io);
        return .{ .records = &self.records, .zones = &self.zones, .tables = self };
    }

    /// Publishes `new_records` and `new_zones` and frees the tables they
    /// replace. Both are installed together, so no query can see the records of
    /// one generation beside the zones of another.
    ///
    /// The caller built both tables with `gpa` and hands ownership over here.
    pub fn swap(
        self: *LocalTables,
        io: std.Io,
        gpa: Allocator,
        new_records: records.Records,
        new_zones: forward_zones.Zones,
    ) void {
        self.lock.lockUncancelable(io);
        var old_records = self.records;
        var old_zones = self.zones;
        self.records = new_records;
        self.zones = new_zones;
        // Freed while the exclusive lock is held: every reader that could hold
        // the old tables released its shared lock before this one was granted.
        old_records.deinit(gpa);
        old_zones.deinit(gpa);
        self.lock.unlock(io);
    }

    /// Frees the published tables. The caller must have stopped every reader
    /// first, exactly as it must before releasing any other borrowed collaborator.
    pub fn deinit(self: *LocalTables, gpa: Allocator) void {
        self.records.deinit(gpa);
        self.zones.deinit(gpa);
        self.* = .empty;
    }
};

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

const testing = std.testing;

const TestIo = struct {
    threaded: std.Io.Threaded,

    fn init() TestIo {
        return .{ .threaded = .init(testing.allocator, .{}) };
    }

    fn io(self: *TestIo) std.Io {
        return self.threaded.io();
    }

    fn deinit(self: *TestIo) void {
        self.threaded.deinit();
    }
};

fn buildRecords(name: []const u8, value: []const u8) !records.Records {
    return records.Records.build(testing.allocator, &.{
        .{ .name = name, .rtype = .a, .value = value, .ttl = 60 },
    });
}

fn buildZones(zone: []const u8) !forward_zones.Zones {
    return forward_zones.Zones.build(testing.allocator, &.{
        .{ .zone = zone, .resolver = "udp://10.0.0.1:53" },
    });
}

test "an empty holder answers nothing and frees nothing" {
    var t: TestIo = .init();
    defer t.deinit();

    var tables: LocalTables = .empty;
    defer tables.deinit(testing.allocator);

    const handle = tables.acquire(t.io());
    defer handle.release(t.io());

    try testing.expect(!handle.records.hasName("nas.lan"));
    try testing.expectEqual(@as(?*const forward_zones.Zone, null), handle.zones.match("nas.lan"));
}

test "a swap publishes both tables together" {
    var t: TestIo = .init();
    defer t.deinit();

    var tables: LocalTables = .empty;
    defer tables.deinit(testing.allocator);

    tables.swap(t.io(), testing.allocator, try buildRecords("nas.lan", "192.168.1.10"), try buildZones("lan"));

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

test "a second swap frees the tables it replaces" {
    var t: TestIo = .init();
    defer t.deinit();

    var tables: LocalTables = .empty;
    defer tables.deinit(testing.allocator);

    tables.swap(t.io(), testing.allocator, try buildRecords("old.lan", "192.168.1.10"), try buildZones("old"));
    tables.swap(t.io(), testing.allocator, try buildRecords("new.lan", "192.168.1.11"), try buildZones("new"));

    const handle = tables.acquire(t.io());
    defer handle.release(t.io());
    try testing.expect(!handle.records.hasName("old.lan"));
    try testing.expect(handle.records.hasName("new.lan"));
    try testing.expect(handle.zones.match("host.old") == null);
    try testing.expect(handle.zones.match("host.new") != null);
}

test "a handle keeps reading the generation it acquired" {
    var t: TestIo = .init();
    defer t.deinit();

    var tables: LocalTables = .empty;
    defer tables.deinit(testing.allocator);

    tables.swap(t.io(), testing.allocator, try buildRecords("first.lan", "192.168.1.10"), .empty);

    const handle = tables.acquire(t.io());
    try testing.expect(handle.records.hasName("first.lan"));
    // Reading twice under one handle must give one answer, which is the whole
    // point of bracketing a query rather than each lookup.
    try testing.expect(handle.records.hasName("first.lan"));
    handle.release(t.io());

    tables.swap(t.io(), testing.allocator, try buildRecords("second.lan", "192.168.1.11"), .empty);

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

test "a swap waits for a live reader and the reader sees the new tables next time" {
    var t: TestIo = .init();
    defer t.deinit();
    const io = t.io();

    var tables: LocalTables = .empty;
    defer tables.deinit(testing.allocator);

    const Swapper = struct {
        fn run(target: *LocalTables, inner: std.Io, gpa: Allocator) void {
            const built = records.Records.build(gpa, &.{
                .{ .name = "swapped.lan", .rtype = .a, .value = "192.168.1.12", .ttl = 60 },
            }) catch return;
            target.swap(inner, gpa, built, .empty);
        }
    };

    const handle = tables.acquire(io);
    var group: std.Io.Group = .init;
    try group.concurrent(io, Swapper.run, .{ &tables, io, testing.allocator });
    try testing.expect(!handle.records.hasName("swapped.lan"));
    handle.release(io);
    try group.await(io);

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