//! Reverse DNS names and the hostname gate for learned client names
//! (milestone 25). Pure: bytes in, bytes out — no `std.Io`, no clock, no
//! sockets. Everything that queries a resolver or writes a row lives in
//! `src/server/`.

const std = @import("std");

const address = @import("../platform/address.zig");

const v4_suffix = "in-addr.arpa";
const v6_suffix = "ip6.arpa";

/// The v6 form is the longest: 32 nibbles, each followed by a dot, then
/// `ip6.arpa`.
pub const max_reverse_len: usize = 32 * 2 + v6_suffix.len;

/// A PTR owner name is one label per byte (v4) or per nibble (v6), least
/// significant first, under `in-addr.arpa` / `ip6.arpa`. Lowercase hex for v6,
/// no trailing dot — the form `forward_zones.Zones.match` expects.
///
/// The tracker canonicalises an IPv4-mapped v6 address to `.ip4` before a
/// `NetAddress` reaches here, so this function never sees one.
pub fn reverseName(addr: address.NetAddress, buf: *[max_reverse_len]u8) []const u8 {
    var w = std.Io.Writer.fixed(buf);
    switch (addr) {
        .ip4 => |b| {
            w.print("{d}.{d}.{d}.{d}.{s}", .{ b[3], b[2], b[1], b[0], v4_suffix }) catch unreachable;
        },
        .ip6 => |b| {
            std.debug.assert(!isIp4Mapped(b));
            var i: usize = b.len;
            while (i > 0) {
                i -= 1;
                const byte = b[i];
                w.writeByte(hex_digits[byte & 0x0f]) catch unreachable;
                w.writeByte('.') catch unreachable;
                w.writeByte(hex_digits[byte >> 4]) catch unreachable;
                w.writeByte('.') catch unreachable;
            }
            w.writeAll(v6_suffix) catch unreachable;
        },
    }
    return w.buffered();
}

const hex_digits = "0123456789abcdef";

fn isIp4Mapped(b: [16]u8) bool {
    return std.mem.eql(u8, b[0..10], &[_]u8{0} ** 10) and b[10] == 0xff and b[11] == 0xff;
}

/// The gate every PTR target passes before it is stored, logged or displayed.
/// The bytes come from whatever box the operator pointed a forward zone at, so
/// nothing weaker is enough.
///
/// Accepts only `[a-z0-9._-]` after ASCII-lowercasing `A-Z`; labels are 1–63
/// bytes and the whole name is at most 253; no label starts or ends with `-`.
/// An empty label is rejected, which also rejects a trailing dot — `formatText`
/// emits none, so one appearing means the reply was malformed.
///
/// Underscore is accepted because real DHCP hostnames carry it. Nothing else
/// outside the set is.
pub fn acceptHostname(text: []const u8) bool {
    if (text.len == 0 or text.len > 253) return false;

    var label_len: usize = 0;
    var prev: u8 = 0;
    for (text) |raw| {
        const ch = std.ascii.toLower(raw);
        if (ch == '.') {
            if (label_len == 0) return false;
            if (prev == '-') return false;
            label_len = 0;
            prev = ch;
            continue;
        }
        if (label_len == 0 and ch == '-') return false;
        if (!isHostByte(ch)) return false;
        label_len += 1;
        if (label_len > 63) return false;
        prev = ch;
    }
    if (label_len == 0) return false;
    if (prev == '-') return false;
    return true;
}

fn isHostByte(ch: u8) bool {
    return (ch >= 'a' and ch <= 'z') or (ch >= '0' and ch <= '9') or ch == '_' or ch == '-';
}

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

const testing = std.testing;

test "reverseName reverses the octets of a v4 address" {
    var buf: [max_reverse_len]u8 = undefined;
    try testing.expectEqualStrings(
        "10.1.168.192.in-addr.arpa",
        reverseName(try address.NetAddress.parse("192.168.1.10"), &buf),
    );
    try testing.expectEqualStrings(
        "0.0.0.0.in-addr.arpa",
        reverseName(try address.NetAddress.parse("0.0.0.0"), &buf),
    );
    try testing.expectEqualStrings(
        "255.255.255.255.in-addr.arpa",
        reverseName(try address.NetAddress.parse("255.255.255.255"), &buf),
    );
}

test "reverseName writes the 32-nibble lowercase ip6.arpa form" {
    var buf: [max_reverse_len]u8 = undefined;
    try testing.expectEqualStrings(
        "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.d.f.ip6.arpa",
        reverseName(try address.NetAddress.parse("fd00::1"), &buf),
    );
    // Every nibble distinct, so a swapped high/low half would show.
    try testing.expectEqualStrings(
        "b.a.9.8.7.6.5.4.3.2.1.0.f.e.d.c.b.a.9.8.7.6.5.4.3.2.1.0.f.e.d.c.ip6.arpa",
        reverseName(try address.NetAddress.parse("cdef:0123:4567:89ab:cdef:0123:4567:89ab"), &buf),
    );
}

test "the v6 form is exactly max_reverse_len bytes and the v4 form is shorter" {
    var buf: [max_reverse_len]u8 = undefined;
    const v6 = reverseName(try address.NetAddress.parse("cdef:0123:4567:89ab:cdef:0123:4567:89ab"), &buf);
    try testing.expectEqual(max_reverse_len, v6.len);
    const v4 = reverseName(try address.NetAddress.parse("255.255.255.255"), &buf);
    try testing.expect(v4.len < max_reverse_len);
}

test "an IPv4-mapped v6 literal is already canonical, so reverseName sees v4" {
    var buf: [max_reverse_len]u8 = undefined;
    try testing.expectEqualStrings(
        "10.1.168.192.in-addr.arpa",
        reverseName(try address.NetAddress.parse("::ffff:192.168.1.10"), &buf),
    );
}

test "acceptHostname accepts and rejects ruling 4's table" {
    // 254 bytes, every label within 63: only the total length rejects it.
    const long_name = "a" ** 63 ++ "." ++ "a" ** 63 ++ "." ++ "a" ** 63 ++ "." ++ "a" ** 62;
    try testing.expectEqual(@as(usize, 254), long_name.len);
    const long_label = "a" ** 64;

    const cases = [_]struct { text: []const u8, want: bool }{
        .{ .text = "", .want = false },
        .{ .text = long_name, .want = false },
        .{ .text = long_label, .want = false },
        .{ .text = "a b", .want = false },
        .{ .text = "a\x00b", .want = false },
        .{ .text = "héllo", .want = false },
        .{ .text = "-x", .want = false },
        .{ .text = "x-.y", .want = false },
        .{ .text = "a..b", .want = false },
        .{ .text = ".a", .want = false },
        .{ .text = "a.", .want = false },
        .{ .text = "nas-1.lan", .want = true },
        .{ .text = "my_printer.home", .want = true },
        .{ .text = "x", .want = true },
    };
    for (cases) |case| {
        testing.expectEqual(case.want, acceptHostname(case.text)) catch |err| {
            std.debug.print("acceptHostname(\"{s}\")\n", .{case.text});
            return err;
        };
    }
}

test "a reverse name matches the forward zone declared over its reverse space" {
    const forward_zones = @import("forward_zones.zig");

    var zones = try forward_zones.Zones.build(testing.allocator, &.{
        .{ .zone = "168.192.in-addr.arpa", .resolver = "udp://192.168.1.1:53" },
        .{ .zone = "0.0.d.f.ip6.arpa", .resolver = "udp://[fd00::1]:53" },
    });
    defer zones.deinit(testing.allocator);

    var buf: [max_reverse_len]u8 = undefined;
    const v4 = reverseName(try address.NetAddress.parse("192.168.1.10"), &buf);
    try testing.expectEqualStrings("168.192.in-addr.arpa", zones.match(v4).?.zone);

    var buf6: [max_reverse_len]u8 = undefined;
    const v6 = reverseName(try address.NetAddress.parse("fd00::1"), &buf6);
    try testing.expectEqualStrings("0.0.d.f.ip6.arpa", zones.match(v6).?.zone);

    // An address outside both declared reverse zones matches nothing, which is
    // the `no_zone` outcome: no query is sent to anyone.
    const outside = reverseName(try address.NetAddress.parse("10.0.0.1"), &buf);
    try testing.expectEqual(@as(?*const forward_zones.Zone, null), zones.match(outside));
}

test "acceptHostname takes the boundary lengths and mixed case" {
    try testing.expect(acceptHostname("a" ** 63));
    // 253 bytes, the longest name accepted.
    try testing.expect(acceptHostname("a" ** 63 ++ "." ++ "a" ** 63 ++ "." ++ "a" ** 63 ++ "." ++ "a" ** 61));
    try testing.expect(acceptHostname("NAS-1.LAN"));
    try testing.expect(!acceptHostname("x-"));
    try testing.expect(!acceptHostname("a.-b"));
    try testing.expect(!acceptHostname("a.b-.c"));
}
