//! Conditional forward zones (PLAN §6.5): an immutable, longest-suffix-first
//! table built once from the `forward_zones` rows and read on the query path
//! without allocating. Pure: an allocator and plain values, no `std.Io`, no
//! clock.
//!
//! Resolver URLs are parsed by `config/validate.zig`'s `parseResolver`, whose
//! doc comment names this file as its importer. There is no second resolver
//! parser.

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

const model = @import("../config/model.zig");
const validate = @import("../config/validate.zig");
const name = @import("../dns/name.zig");
const types = @import("../dns/types.zig");

pub const Zone = struct {
    /// Normalized: lowercase, no trailing dot.
    zone: []const u8,
    resolver: validate.Resolver,
};

pub const Error = error{ OutOfMemory, BadZone, BadResolver, TooManyZones };
pub const max_zones: usize = 1_000;

pub const Zones = struct {
    /// Sorted by descending label count then by name, so the first match found
    /// by a forward scan is the longest one.
    items: []const Zone,
    /// One block holding every `Zone.zone`; freed as a unit.
    names: []const u8,

    pub const empty: Zones = .{ .items = &.{}, .names = &.{} };

    /// `gpa` owns the result; `deinit` frees it. A row that fails to parse is
    /// an error, not a skipped row — `validate.zig` already rejects these, so
    /// reaching one here means the database was edited behind nxdns's back and
    /// silence would send a zone's queries to the wrong resolver.
    pub fn build(gpa: Allocator, rows: []const model.ForwardZone) Error!Zones {
        if (rows.len == 0) return .empty;
        if (rows.len > max_zones) return error.TooManyZones;

        var names: std.ArrayList(u8) = .empty;
        defer names.deinit(gpa);
        var spans: std.ArrayList(Span) = .empty;
        defer spans.deinit(gpa);

        var buf: [types.max_name_len]u8 = undefined;
        for (rows) |row| {
            // The root is rejected with everything else `normalizeText` refuses:
            // a zone that forwards everything would bypass the upstream pool
            // entirely, which is not what conditional forwarding means.
            const zone = name.normalizeText(row.zone, &buf) catch return error.BadZone;
            const resolver = validate.parseResolver(row.resolver) catch return error.BadResolver;
            try spans.append(gpa, .{
                .offset = names.items.len,
                .len = zone.len,
                .resolver = resolver,
            });
            try names.appendSlice(gpa, zone);
        }

        const name_bytes = try names.toOwnedSlice(gpa);
        errdefer gpa.free(name_bytes);

        const items = try gpa.alloc(Zone, spans.items.len);
        for (items, spans.items) |*item, span| item.* = .{
            .zone = name_bytes[span.offset..][0..span.len],
            .resolver = span.resolver,
        };
        std.mem.sort(Zone, items, {}, lessThan);

        return .{ .items = items, .names = name_bytes };
    }

    pub fn deinit(self: *Zones, gpa: Allocator) void {
        gpa.free(self.items);
        gpa.free(self.names);
        self.* = .empty;
    }

    /// Longest-suffix match on label boundaries: `lan.home` matches
    /// `nas.lan.home` and `lan.home`, and does not match `notlan.home`.
    /// `domain` must be normalized (lowercase, no trailing dot).
    /// Allocation-free.
    pub fn match(self: *const Zones, domain: []const u8) ?*const Zone {
        for (self.items) |*zone| {
            if (suffixMatches(zone.zone, domain)) return zone;
        }
        return null;
    }
};

// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------

/// `Zone.zone` slices are cut only after the name block stops growing, so the
/// build pass records offsets instead of pointers.
const Span = struct {
    offset: usize,
    len: usize,
    resolver: validate.Resolver,
};

fn labelCount(zone: []const u8) usize {
    return std.mem.count(u8, zone, ".") + 1;
}

fn lessThan(_: void, a: Zone, b: Zone) bool {
    const a_labels = labelCount(a.zone);
    const b_labels = labelCount(b.zone);
    if (a_labels != b_labels) return a_labels > b_labels;
    return std.mem.order(u8, a.zone, b.zone) == .lt;
}

fn suffixMatches(zone: []const u8, domain: []const u8) bool {
    if (domain.len == zone.len) return std.mem.eql(u8, domain, zone);
    if (domain.len < zone.len + 1) return false;
    const start = domain.len - zone.len;
    return domain[start - 1] == '.' and std.mem.eql(u8, domain[start..], zone);
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

const testing = std.testing;

const lan_resolver = "udp://192.168.1.1:53";
const home_resolver = "tcp://[fd00::1]:5353";

test "a zone matches itself and its subdomains on label boundaries" {
    const rows = [_]model.ForwardZone{
        .{ .zone = "lan.home", .resolver = lan_resolver },
    };
    var zones = try Zones.build(testing.allocator, &rows);
    defer zones.deinit(testing.allocator);

    for ([_][]const u8{ "lan.home", "nas.lan.home", "a.b.lan.home" }) |domain| {
        const found = zones.match(domain) orelse return error.TestExpectedMatch;
        try testing.expectEqualStrings("lan.home", found.zone);
    }

    for ([_][]const u8{ "notlan.home", "home", "lan.home.evil.net", "" }) |domain| {
        try testing.expect(zones.match(domain) == null);
    }
}

test "the longest configured zone wins" {
    const rows = [_]model.ForwardZone{
        .{ .zone = "home", .resolver = home_resolver },
        .{ .zone = "lan.home", .resolver = lan_resolver },
    };
    var zones = try Zones.build(testing.allocator, &rows);
    defer zones.deinit(testing.allocator);

    const nas = zones.match("nas.lan.home") orelse return error.TestExpectedMatch;
    try testing.expectEqualStrings("lan.home", nas.zone);
    try testing.expectEqual(validate.ResolverScheme.udp, nas.resolver.scheme);

    const printer = zones.match("printer.home") orelse return error.TestExpectedMatch;
    try testing.expectEqualStrings("home", printer.zone);
    try testing.expectEqual(validate.ResolverScheme.tcp, printer.resolver.scheme);
    try testing.expectEqual(@as(u16, 5353), printer.resolver.port);
}

test "a reverse zone matches every name under it" {
    const rows = [_]model.ForwardZone{
        .{ .zone = "10.in-addr.arpa", .resolver = lan_resolver },
    };
    var zones = try Zones.build(testing.allocator, &rows);
    defer zones.deinit(testing.allocator);

    const found = zones.match("5.4.3.10.in-addr.arpa") orelse return error.TestExpectedMatch;
    try testing.expectEqualStrings("10.in-addr.arpa", found.zone);
    try testing.expect(zones.match("5.4.3.11.in-addr.arpa") == null);
}

test "uppercase and trailing-dot zones normalize to one key" {
    const rows = [_]model.ForwardZone{
        .{ .zone = "LAN.Home.", .resolver = lan_resolver },
    };
    var zones = try Zones.build(testing.allocator, &rows);
    defer zones.deinit(testing.allocator);

    try testing.expectEqualStrings("lan.home", zones.items[0].zone);
    try testing.expect(zones.match("nas.lan.home") != null);
}

test "the resolver comes from parseResolver" {
    const rows = [_]model.ForwardZone{
        .{ .zone = "lan.home", .resolver = lan_resolver },
    };
    var zones = try Zones.build(testing.allocator, &rows);
    defer zones.deinit(testing.allocator);

    const expected = try validate.parseResolver(lan_resolver);
    const found = zones.match("lan.home") orelse return error.TestExpectedMatch;
    try testing.expectEqual(expected.scheme, found.resolver.scheme);
    try testing.expectEqual(expected.port, found.resolver.port);
    try testing.expect(expected.addr.eql(found.resolver.addr));
}

test "a bad resolver URL is an error" {
    for ([_][]const u8{ "https://dns.example/dns-query", "udp://192.168.1.1", "udp://nas.lan:53" }) |url| {
        const rows = [_]model.ForwardZone{.{ .zone = "lan.home", .resolver = url }};
        try testing.expectError(error.BadResolver, Zones.build(testing.allocator, &rows));
    }
}

test "a bad zone is an error" {
    for ([_][]const u8{ "lan..home", ".", "" }) |zone| {
        const rows = [_]model.ForwardZone{.{ .zone = zone, .resolver = lan_resolver }};
        try testing.expectError(error.BadZone, Zones.build(testing.allocator, &rows));
    }
}

test "too many rows is an error" {
    const rows = try testing.allocator.alloc(model.ForwardZone, max_zones + 1);
    defer testing.allocator.free(rows);
    for (rows) |*row| row.* = .{ .zone = "lan.home", .resolver = lan_resolver };
    try testing.expectError(error.TooManyZones, Zones.build(testing.allocator, rows));
}

test "an empty row set builds the empty table" {
    var zones = try Zones.build(testing.allocator, &[_]model.ForwardZone{});
    defer zones.deinit(testing.allocator);

    try testing.expectEqual(@as(usize, 0), zones.items.len);
    try testing.expect(zones.match("lan.home") == null);
}

fn buildUnderFailure(gpa: Allocator) !void {
    const rows = [_]model.ForwardZone{
        .{ .zone = "home", .resolver = home_resolver },
        .{ .zone = "lan.home", .resolver = lan_resolver },
    };
    var zones = try Zones.build(gpa, &rows);
    defer zones.deinit(gpa);
    try testing.expectEqualStrings("lan.home", zones.items[0].zone);
}

test "build leaks nothing under allocation failure" {
    try testing.checkAllAllocationFailures(testing.allocator, buildUnderFailure, .{});
}
