//! An already-validated host name. A valid host name:
//! * Has length less than or equal to `max_len`.
//! * Is valid UTF-8.
//! * Lacks ASCII characters other than alphanumeric, '-', and '.'.
const HostName = @This();

const builtin = @import("builtin");
const native_os = builtin.os.tag;

const std = @import("../../std.zig");
const Io = std.Io;
const IpAddress = Io.net.IpAddress;
const Ip6Address = Io.net.Ip6Address;
const assert = std.debug.assert;
const Stream = Io.net.Stream;

/// Externally managed memory. Already checked to be valid.
bytes: []const u8,

pub const max_len = 255;

pub const ValidateError = error{
    NameTooLong,
    InvalidHostName,
};

/// Validates a hostname according to [RFC 1123](https://www.rfc-editor.org/rfc/rfc1123)
pub fn validate(bytes: []const u8) ValidateError!void {
    if (bytes.len == 0) return error.InvalidHostName;

    // Ignore trailing dot (FQDN). It doesn't count toward our length.
    const end = if (bytes[bytes.len - 1] == '.') bytes.len - 1 else bytes.len;

    // The accepted maximum length of a hostname, including labels and dots.
    if (end > max_len) return error.NameTooLong;

    // Hostnames are divided into dot-separated "labels", which:
    //
    // - Start with a letter or digit
    // - Can contain letters, digits, or hyphens
    // - Must end with a letter or digit
    // - Have a minimum of 1 character and a maximum of 63
    var label_len: usize = 0;
    for (bytes[0..end], 0..) |c, i| {
        switch (c) {
            '.' => {
                if (label_len == 0 or label_len > 63) return error.InvalidHostName;
                if (!std.ascii.isAlphanumeric(bytes[i - 1])) return error.InvalidHostName;
                label_len = 0;
            },
            '-' => {
                if (label_len == 0) return error.InvalidHostName;
                label_len += 1;
            },
            else => {
                if (!std.ascii.isAlphanumeric(c)) return error.InvalidHostName;
                label_len += 1;
            },
        }
    }

    // Validate the final label
    if (label_len == 0 or label_len > 63) return error.InvalidHostName;
    if (!std.ascii.isAlphanumeric(bytes[end - 1])) return error.InvalidHostName;
}

test validate {
    // Valid hostnames
    try validate("example");
    try validate("example.com");
    try validate("www.example.com");
    try validate("sub.domain.example.com");
    try validate("example.com.");
    try validate("host-name.example.com.");
    try validate("123.example.com.");
    try validate("a-b.com");
    try validate("a.b.c.d.e.f.g");
    try validate("127.0.0.1"); // Also a valid hostname
    try validate("a" ** 63 ++ ".com"); // Label exactly 63 chars (valid)
    try validate("a." ** 127 ++ "a"); // Total length 255 (valid)
    try validate("a." ** 127 ++ "a."); // Total length 255 + trailing dot (valid)

    // Invalid hostnames
    try std.testing.expectError(error.InvalidHostName, validate(""));
    try std.testing.expectError(error.InvalidHostName, validate(".example.com"));
    try std.testing.expectError(error.InvalidHostName, validate("example.com.."));
    try std.testing.expectError(error.InvalidHostName, validate("host..domain"));
    try std.testing.expectError(error.InvalidHostName, validate("-hostname"));
    try std.testing.expectError(error.InvalidHostName, validate("hostname-"));
    try std.testing.expectError(error.InvalidHostName, validate("hostname-.com"));
    try std.testing.expectError(error.InvalidHostName, validate("a.-.b"));
    try std.testing.expectError(error.InvalidHostName, validate("host_name.com"));
    try std.testing.expectError(error.InvalidHostName, validate("."));
    try std.testing.expectError(error.InvalidHostName, validate(".."));
    try std.testing.expectError(error.InvalidHostName, validate("a" ** 64 ++ ".com")); // Label length 64 (too long)
    try std.testing.expectError(error.NameTooLong, validate("a." ** 127 ++ "ab")); // Total length 256 (too long)
    try std.testing.expectError(error.NameTooLong, validate("a." ** 127 ++ "ab.")); // Total length 256 + trailing dot (too long)
}

pub fn init(bytes: []const u8) ValidateError!HostName {
    try validate(bytes);
    return .{ .bytes = bytes };
}

pub fn sameParentDomain(parent_host: HostName, child_host: HostName) bool {
    const parent_bytes = parent_host.bytes;
    const child_bytes = child_host.bytes;
    if (!std.ascii.endsWithIgnoreCase(child_bytes, parent_bytes)) return false;
    if (child_bytes.len == parent_bytes.len) return true;
    if (parent_bytes.len > child_bytes.len) return false;
    return child_bytes[child_bytes.len - parent_bytes.len - 1] == '.';
}

test sameParentDomain {
    try std.testing.expect(!sameParentDomain(try .init("foo.com"), try .init("bar.com")));
    try std.testing.expect(sameParentDomain(try .init("foo.com"), try .init("foo.com")));
    try std.testing.expect(sameParentDomain(try .init("foo.com"), try .init("bar.foo.com")));
    try std.testing.expect(!sameParentDomain(try .init("bar.foo.com"), try .init("foo.com")));
}

/// Domain names are case-insensitive (RFC 5890, Section 2.3.2.4)
pub fn eql(a: HostName, b: HostName) bool {
    return std.ascii.eqlIgnoreCase(a.bytes, b.bytes);
}

pub const LookupOptions = struct {
    port: u16,
    /// `null` means either.
    canonical_name_buffer: ?*[max_len]u8 = null,
    family: ?IpAddress.Family = null,
};

pub const LookupError = error{
    UnknownHostName,
    ResolvConfParseFailed,
    InvalidDnsARecord,
    InvalidDnsAAAARecord,
    InvalidDnsCnameRecord,
    NameServerFailure,
    NoAddressReturned,
    /// Failed to open or read "/etc/hosts" or "/etc/resolv.conf".
    DetectingNetworkConfigurationFailed,
} || IpAddress.BindError || Io.Cancelable;

pub const LookupResult = union(enum) {
    address: IpAddress,
    canonical_name: HostName,
};

/// Adds any number of `LookupResult.address` into `resolved`, and exactly one
/// `LookupResult.canonical_name`.
///
/// Guaranteed not to block if provided queue has capacity at least 16.
///
/// Closes `resolved` before return, even on error.
///
/// Asserts `resolved` is not closed until this call returns.
pub fn lookup(
    host_name: HostName,
    io: Io,
    resolved: *Io.Queue(LookupResult),
    options: LookupOptions,
) LookupError!void {
    return io.vtable.netLookup(io.userdata, host_name, resolved, options);
}

pub const ExpandError = error{InvalidDnsPacket} || ValidateError;

/// Decompresses a DNS name.
///
/// Returns number of bytes consumed from `packet` starting at `i`,
/// along with the expanded `HostName`.
///
/// Asserts `buffer` is has length at least `max_len`.
pub fn expand(noalias packet: []const u8, start_i: usize, noalias dest_buffer: []u8) ExpandError!struct { usize, HostName } {
    const dest = dest_buffer[0..max_len];

    var i = start_i;
    var dest_i: usize = 0;
    var len: ?usize = null;

    // Detect reference loop using an iteration counter.
    for (0..packet.len / 2) |_| {
        if (i >= packet.len) return error.InvalidDnsPacket;

        const c = packet[i];
        if ((c & 0xc0) != 0) {
            if (i + 1 >= packet.len) return error.InvalidDnsPacket;
            const j: usize = (@as(usize, c & 0x3F) << 8) | packet[i + 1];
            if (j >= packet.len) return error.InvalidDnsPacket;
            if (len == null) len = (i + 2) - start_i;
            i = j;
        } else if (c != 0) {
            if (dest_i != 0) {
                dest[dest_i] = '.';
                dest_i += 1;
            }
            const label_len: usize = c;
            if (i + 1 + label_len > packet.len) return error.InvalidDnsPacket;
            if (dest_i + label_len + 1 > dest.len) return error.InvalidDnsPacket;
            @memcpy(dest[dest_i..][0..label_len], packet[i + 1 ..][0..label_len]);
            dest_i += label_len;
            i += 1 + label_len;
        } else {
            return .{
                len orelse i - start_i + 1,
                try .init(dest[0..dest_i]),
            };
        }
    }
    return error.InvalidDnsPacket;
}

pub const DnsRecord = enum(u8) {
    A = 1,
    CNAME = 5,
    AAAA = 28,
    _,
};

pub const DnsResponse = struct {
    bytes: []const u8,
    bytes_index: u32,
    answers_remaining: u16,

    pub const Answer = struct {
        rr: DnsRecord,
        packet: []const u8,
        data_off: u32,
        data_len: u16,
    };

    pub const Error = error{InvalidDnsPacket};

    pub fn init(r: []const u8) Error!DnsResponse {
        if (r.len < 12) return error.InvalidDnsPacket;
        if ((r[3] & 15) != 0) return .{ .bytes = r, .bytes_index = 3, .answers_remaining = 0 };
        var i: u32 = 12;
        var query_count = std.mem.readInt(u16, r[4..6], .big);
        while (query_count != 0) : (query_count -= 1) {
            while (i < r.len and r[i] -% 1 < 127) i += 1;
            if (r.len - i < 6) return error.InvalidDnsPacket;
            i = i + 5 + @intFromBool(r[i] != 0);
        }
        return .{
            .bytes = r,
            .bytes_index = i,
            .answers_remaining = std.mem.readInt(u16, r[6..8], .big),
        };
    }

    pub fn next(dr: *DnsResponse) Error!?Answer {
        if (dr.answers_remaining == 0) return null;
        dr.answers_remaining -= 1;
        const r = dr.bytes;
        var i = dr.bytes_index;
        while (i < r.len and r[i] -% 1 < 127) i += 1;
        if (r.len - i < 12) return error.InvalidDnsPacket;
        i = i + 1 + @intFromBool(r[i] != 0);
        const len = std.mem.readInt(u16, r[i + 8 ..][0..2], .big);
        if (i + 10 + len > r.len) return error.InvalidDnsPacket;
        defer dr.bytes_index = i + 10 + len;
        return .{
            .rr = @enumFromInt(r[i + 1]),
            .packet = r,
            .data_off = i + 10,
            .data_len = len,
        };
    }
};

pub const ConnectError = LookupError || IpAddress.ConnectError;

pub fn connect(
    host_name: HostName,
    io: Io,
    port: u16,
    options: IpAddress.ConnectOptions,
) ConnectError!Stream {
    var connect_many_buffer: [32]IpAddress.ConnectError!Stream = undefined;
    var connect_many_queue: Io.Queue(IpAddress.ConnectError!Stream) = .init(&connect_many_buffer);

    var connect_many = io.async(connectMany, .{ host_name, io, port, &connect_many_queue, options });
    defer {
        connect_many.cancel(io) catch {};
        while (connect_many_queue.getOneUncancelable(io)) |loser| {
            if (loser) |s| s.close(io) else |_| {}
        } else |err| switch (err) {
            error.Closed => {},
        }
    }

    var ip_connect_error: ?IpAddress.ConnectError = null;

    while (connect_many_queue.getOne(io)) |result| {
        if (result) |stream| {
            return stream;
        } else |err| switch (err) {
            error.Canceled => unreachable,

            error.SystemResources,
            error.OptionUnsupported,
            error.ProcessFdQuotaExceeded,
            error.SystemFdQuotaExceeded,
            => |e| return e,

            error.WouldBlock => return error.Unexpected,

            else => |e| ip_connect_error = e,
        }
    } else |err| switch (err) {
        error.Canceled => |e| return e,
        error.Closed => {
            // There was no successful connection attempt. If there was a lookup error, return that.
            try connect_many.await(io);
            // Otherwise, return the error from a failed IP connection attempt.
            return ip_connect_error orelse
                return error.UnknownHostName;
        },
    }
}

/// Asynchronously establishes a connection to all IP addresses associated with
/// a host name, adding them to a results queue upon completion.
///
/// `error.Canceled` will never be added to the queue, but other errors may be.
///
/// Closes `results` before return, even on error.
///
/// Asserts `results` is not closed until this call returns.
pub fn connectMany(
    host_name: HostName,
    io: Io,
    port: u16,
    results: *Io.Queue(IpAddress.ConnectError!Stream),
    options: IpAddress.ConnectOptions,
) LookupError!void {
    defer results.close(io);

    var canonical_name_buffer: [max_len]u8 = undefined;
    var lookup_buffer: [32]HostName.LookupResult = undefined;
    var lookup_queue: Io.Queue(LookupResult) = .init(&lookup_buffer);
    var lookup_future = io.async(lookup, .{ host_name, io, &lookup_queue, .{
        .port = port,
        .canonical_name_buffer = &canonical_name_buffer,
    } });
    defer lookup_future.cancel(io) catch {};

    var group: Io.Group = .init;
    defer group.cancel(io);

    while (lookup_queue.getOne(io)) |dns_result| switch (dns_result) {
        .address => |address| group.async(io, enqueueConnection, .{ address, io, results, options }),
        .canonical_name => continue,
    } else |err| switch (err) {
        error.Canceled => |e| return e,
        error.Closed => {
            try group.await(io);
            return lookup_future.await(io);
        },
    }
}
fn enqueueConnection(
    address: IpAddress,
    io: Io,
    queue: *Io.Queue(IpAddress.ConnectError!Stream),
    options: IpAddress.ConnectOptions,
) Io.Cancelable!void {
    const result = address.connect(io, options) catch |err| switch (err) {
        error.Canceled => |e| return e,
        else => |e| e, // other errors go in the result queue
    };
    errdefer if (result) |s| s.close(io) else |_| {};
    queue.putOne(io, result) catch |err| switch (err) {
        error.Canceled => |e| return e,
        error.Closed => unreachable, // `queue` must not be closed
    };
}

pub const ResolvConf = struct {
    attempts: u32,
    ndots: u32,
    timeout_seconds: u32,
    nameservers_buffer: [max_nameservers]IpAddress,
    nameservers_len: usize,
    search_buffer: [max_len]u8,
    search_len: usize,

    /// According to resolv.conf(5) there is a maximum of 3 nameservers in this
    /// file.
    pub const max_nameservers = 3;

    /// Returns `error.StreamTooLong` if a line is longer than 512 bytes.
    pub fn init(io: Io) !ResolvConf {
        var rc: ResolvConf = .{
            .nameservers_buffer = undefined,
            .nameservers_len = 0,
            .search_buffer = undefined,
            .search_len = 0,
            .ndots = 1,
            .timeout_seconds = 5,
            .attempts = 2,
        };

        const file = Io.Dir.openFileAbsolute(io, "/etc/resolv.conf", .{}) catch |err| switch (err) {
            error.FileNotFound,
            error.NotDir,
            error.AccessDenied,
            => {
                try addNumeric(&rc, io, "127.0.0.1", 53);
                return rc;
            },

            else => |e| return e,
        };
        defer file.close(io);

        var line_buf: [512]u8 = undefined;
        var file_reader = file.reader(io, &line_buf);
        parse(&rc, io, &file_reader.interface) catch |err| switch (err) {
            error.ReadFailed => return file_reader.err.?,
            else => |e| return e,
        };
        return rc;
    }

    const Directive = enum { options, nameserver, domain, search };
    const Option = enum { ndots, attempts, timeout };

    pub fn parse(rc: *ResolvConf, io: Io, reader: *Io.Reader) !void {
        while (reader.takeSentinel('\n')) |line_with_comment| {
            const line = line: {
                var split = std.mem.splitScalar(u8, line_with_comment, '#');
                break :line split.first();
            };
            var line_it = std.mem.tokenizeAny(u8, line, " \t");

            const token = line_it.next() orelse continue;
            switch (std.meta.stringToEnum(Directive, token) orelse continue) {
                .options => while (line_it.next()) |sub_tok| {
                    var colon_it = std.mem.splitScalar(u8, sub_tok, ':');
                    const name = colon_it.first();
                    const value_txt = colon_it.next() orelse continue;
                    const value = std.fmt.parseInt(u8, value_txt, 10) catch |err| switch (err) {
                        error.Overflow => 255,
                        error.InvalidCharacter => continue,
                    };
                    switch (std.meta.stringToEnum(Option, name) orelse continue) {
                        .ndots => rc.ndots = @min(value, 15),
                        .attempts => rc.attempts = @min(value, 10),
                        .timeout => rc.timeout_seconds = @min(value, 60),
                    }
                },
                .nameserver => {
                    const ip_txt = line_it.next() orelse continue;
                    try addNumeric(rc, io, ip_txt, 53);
                },
                .domain, .search => {
                    const rest = line_it.rest();
                    @memcpy(rc.search_buffer[0..rest.len], rest);
                    rc.search_len = rest.len;
                },
            }
        } else |err| switch (err) {
            error.EndOfStream => if (reader.bufferedLen() != 0) return error.EndOfStream,
            else => |e| return e,
        }

        if (rc.nameservers_len == 0) {
            try addNumeric(rc, io, "127.0.0.1", 53);
        }
    }

    fn addNumeric(rc: *ResolvConf, io: Io, name: []const u8, port: u16) !void {
        if (rc.nameservers_len < rc.nameservers_buffer.len) {
            rc.nameservers_buffer[rc.nameservers_len] = try .resolve(io, name, port);
            rc.nameservers_len += 1;
        }
    }

    pub fn nameservers(rc: *const ResolvConf) []const IpAddress {
        return rc.nameservers_buffer[0..rc.nameservers_len];
    }
};

test ResolvConf {
    const input =
        \\# Generated by resolvconf
        \\nameserver 1.0.0.1
        \\nameserver 1.1.1.1
        \\nameserver fe80::e0e:76ff:fed4:cf22
        \\options edns0
        \\
    ;
    var reader: Io.Reader = .fixed(input);

    var rc: ResolvConf = .{
        .nameservers_buffer = undefined,
        .nameservers_len = 0,
        .search_buffer = undefined,
        .search_len = 0,
        .ndots = 1,
        .timeout_seconds = 5,
        .attempts = 2,
    };

    try rc.parse(std.testing.io, &reader);
    try std.testing.expectEqual(3, rc.nameservers().len);
}
