//! RFC 8484 DNS over HTTPS upstream client.
//!
//! One `DohClient` wraps a caller-owned `std.http.Client`, which owns the
//! connection pool and the CA bundle. Several `DohClient` values can share one
//! `std.http.Client`, so a pool of DoH upstreams keeps one TLS connection per
//! host without this file knowing anything about pooling.
//!
//! HTTP/1.1 only — HTTP/2 is permanently out of scope (PLAN §2.2).
//!
//! Every buffer is caller-owned. Nothing here allocates, so an exchange cannot
//! fail for a reason this file invented.

const std = @import("std");
const transport = @import("transport.zig");

pub const media_type = "application/dns-message";

/// The smallest `request_buf` that can hold a real query: a header, a
/// maximum-length name, qtype/qclass and an OPT record all fit in 512 bytes.
pub const min_request_buf = 512;
pub const min_transfer_buf = 1024;

/// What `nxdns run` gives every DoH client, and what `nxdns check` probes an
/// upstream with. They live here rather than beside either caller because a
/// probe that used a different buffer than the running server would answer a
/// question nobody asked.
pub const default_request_buf_len = 1024;
pub const default_transfer_buf_len = 4096;

pub const DohClient = struct {
    /// Caller-owned; shared across endpoints, pools connections.
    http: *std.http.Client,
    endpoint: transport.Endpoint,
    /// Built once in `init` from `endpoint`.
    uri: std.Uri,
    /// Caller-owned. `Request.sendBodyComplete` takes `[]u8`, so the query has
    /// to be copied out of the caller's const slice before it can be sent.
    request_buf: []u8,
    /// Caller-owned HTTP body transfer buffer.
    transfer_buf: []u8,

    pub const InitError = error{BadUrl};

    /// `endpoint` stays the source of truth for host, port and scheme in logs;
    /// `uri` exists only because `std.http.Client.request` takes one.
    ///
    /// A non-DoH endpoint is `error.BadUrl` rather than an assert: the scheme
    /// comes from configuration, so wiring a `tls://` URL to this client is a
    /// config fault that must surface as a value, not a panic.
    pub fn init(
        http: *std.http.Client,
        endpoint: transport.Endpoint,
        request_buf: []u8,
        transfer_buf: []u8,
    ) InitError!DohClient {
        std.debug.assert(request_buf.len >= min_request_buf);
        std.debug.assert(transfer_buf.len >= min_transfer_buf);
        if (endpoint.scheme != .doh) return error.BadUrl;
        const uri = std.Uri.parse(endpoint.url) catch return error.BadUrl;
        return .{
            .http = http,
            .endpoint = endpoint,
            .uri = uri,
            .request_buf = request_buf,
            .transfer_buf = transfer_buf,
        };
    }

    pub fn client(self: *DohClient) transport.Client {
        return .{ .ptr = self, .exchangeFn = exchangeFn };
    }

    fn exchangeFn(
        ptr: *anyopaque,
        io: std.Io,
        query: []const u8,
        response_buf: []u8,
        selected: *?[]const u8,
    ) transport.ExchangeError![]u8 {
        const self: *DohClient = @ptrCast(@alignCast(ptr));
        // The endpoint outlives the client, so the borrow is safe for the whole
        // query. Set before the attempt: a failure names this resolver too.
        selected.* = self.endpoint.url;
        return self.exchange(io, query, response_buf);
    }

    /// Returns a prefix of `response_buf` holding a message that has already
    /// passed `transport.validateResponse` against `query`.
    ///
    /// The query ID is sent unchanged. RFC 8484 §4.1 suggests ID 0 so that HTTP
    /// caches can share a response; nxdns puts no HTTP cache in this path, and
    /// keeping the ID preserves the request/response binding that
    /// `validateResponse` checks.
    pub fn exchange(
        self: *DohClient,
        io: std.Io,
        query: []const u8,
        response_buf: []u8,
    ) transport.ExchangeError![]u8 {
        // `std.http.Client` carries the `std.Io` it was constructed with and
        // takes none per request, so the interface's `io` is unused here. It
        // stays in the signature because DoT and the pool need it.
        _ = io;

        if (query.len > self.request_buf.len) return error.BufferTooSmall;
        @memcpy(self.request_buf[0..query.len], query);

        var req = self.http.request(.POST, self.uri, .{
            .keep_alive = true,
            .redirect_behavior = .not_allowed,
            .headers = .{
                .content_type = .{ .override = media_type },
                // A compressed body would need the decompressing reader and
                // would stop being byte-exact, which `validateResponse` needs.
                .accept_encoding = .{ .override = "identity" },
            },
            // `Request.Headers` has no `accept` field, so this one goes in by
            // hand.
            .extra_headers = &.{.{ .name = "accept", .value = media_type }},
        }) catch |err| return mapError(err, .connect);
        defer req.deinit();

        req.sendBodyComplete(self.request_buf[0..query.len]) catch |err|
            return mapError(sendCause(&req, err), .send);

        // An empty redirect buffer is legal under `.not_allowed`: a redirect
        // is an error before the location is ever read.
        var resp = req.receiveHead(&.{}) catch |err| return mapError(headCause(&req, err), .receive);

        if (resp.head.status != .ok) return error.HttpStatus;
        // `head.content_type` points into memory that `resp.reader` invalidates,
        // so the check happens before the body stream starts.
        if (!contentTypeOk(resp.head.content_type)) return error.HttpContentType;
        if (resp.head.content_length) |declared| {
            if (declared > response_buf.len) return error.ResponseTooLarge;
        }

        const body = resp.reader(self.transfer_buf);
        var len: usize = 0;
        var ended = false;
        while (len < response_buf.len) {
            const n = body.readSliceShort(response_buf[len..]) catch |err|
                return mapError(bodyCause(&resp, err), .receive);
            len += n;
            if (n == 0) {
                ended = true;
                break;
            }
        }
        if (!ended) {
            // `response_buf` filled exactly. One more read separates a message
            // that fits from one that was cut off.
            var probe: [1]u8 = undefined;
            const n = body.readSliceShort(&probe) catch |err|
                return mapError(bodyCause(&resp, err), .receive);
            if (n != 0) return error.ResponseTooLarge;
        }

        try transport.validateResponse(query, response_buf[0..len]);
        return response_buf[0..len];
    }
};

/// Which call failed. The phase is what decides the peer fault, and only the
/// call site knows it — guessing it from an error name would be wrong the first
/// time two phases shared an error.
const Phase = enum { connect, send, receive };

/// Every TLS failure this client can reach, named rather than matched by
/// prefix. Two groups:
///
/// - `std.http.Client.RequestError` collapses every handshake and bundle fault
///   into `TlsInitializationFailed` / `CertificateBundleLoadFailure`
///   (Client.zig:1470, :1717).
/// - `std.crypto.tls.Client.ReadError` is what `headCause`/`bodyCause` unwrap
///   out of a collapsed `error.ReadFailed`, through
///   `Connection.getReadError` (Client.zig:392), so its record-layer members
///   arrive here as themselves. Without them a decode error or a bad record MAC
///   would be reported as a plain receive failure.
fn mapError(err: anyerror, phase: Phase) transport.ExchangeError {
    if (transport.mapLocal(err)) |local| return local;
    switch (err) {
        error.TlsInitializationFailed,
        error.CertificateBundleLoadFailure,
        error.TlsAlert,
        error.TlsBadLength,
        error.TlsBadRecordMac,
        error.TlsConnectionTruncated,
        error.TlsDecodeError,
        error.TlsRecordOverflow,
        error.TlsUnexpectedMessage,
        error.TlsIllegalParameter,
        error.TlsSequenceOverflow,
        => return error.TlsFailed,
        else => {},
    }
    return switch (phase) {
        .connect => error.ConnectFailed,
        .send => error.SendFailed,
        .receive => error.ReceiveFailed,
    };
}

// `error.X` in an expression names a member into existence rather than
// referring to one, so the switch above would keep compiling — and silently
// stop matching — if std renamed any of these. This block is what fails the
// build instead. Every member of the read-cause set must be classified, and the
// two collapsed names must still exist.
comptime {
    for (@typeInfo(std.crypto.tls.Client.ReadError).error_set.?) |member| {
        const value: anyerror = @field(std.crypto.tls.Client.ReadError, member.name);
        if (mapError(value, .receive) != error.TlsFailed) {
            @compileError("unclassified TLS read cause: " ++ member.name);
        }
    }
    for ([_][]const u8{ "TlsInitializationFailed", "CertificateBundleLoadFailure" }) |name| {
        var found = false;
        for (@typeInfo(std.http.Client.RequestError).error_set.?) |member| {
            if (std.mem.eql(u8, member.name, name)) found = true;
        }
        if (!found) @compileError("std.http.Client.RequestError no longer names " ++ name);
    }
}

const Connection = std.http.Client.Connection;
const Request = std.http.Client.Request;
const Response = std.http.Client.Response;

/// `std.Io.Writer` collapses every send failure to `error.WriteFailed` and
/// stashes the cause on the connection's socket writer. Unwrapping it is what
/// keeps `error.Canceled` and the local resource errors out of the peer fault
/// group, exactly as `concreteWrite` does for DoT.
fn sendCause(req: *const Request, err: anyerror) anyerror {
    if (err != error.WriteFailed) return err;
    const connection = req.connection orelse return err;
    return connection.stream_writer.err orelse err;
}

/// `receiveHead` collapses a transport failure to `error.ReadFailed` and names
/// `Connection.getReadError` as the accessor for the concrete cause.
fn headCause(req: *const Request, err: anyerror) anyerror {
    if (err != error.ReadFailed) return err;
    const connection = req.connection orelse return err;
    return readCause(connection, err);
}

/// A body read reports two different kinds of failure through the same
/// `error.ReadFailed`. An HTTP framing fault lands on the response, and only a
/// read that never reached the framing leaves the connection's cause, so the
/// response is consulted first.
fn bodyCause(resp: *const Response, err: anyerror) anyerror {
    if (err != error.ReadFailed) return err;
    if (resp.bodyErr()) |cause| return cause;
    const connection = resp.request.connection orelse return err;
    return readCause(connection, err);
}

/// `Connection.getReadError` reads the socket reader's stashed cause with `.?`.
/// On a plain connection that is its only source, so calling it with nothing
/// stashed would panic rather than return null; the guard keeps this unwrap
/// total on the path this client can reach without TLS.
fn readCause(connection: *const Connection, err: anyerror) anyerror {
    if (connection.protocol == .plain and connection.stream_reader.err == null) return err;
    return connection.getReadError() orelse err;
}

/// RFC 8484 §6: the response media type is `application/dns-message`. The
/// header may carry parameters (`; charset=…`) and the type is case-insensitive
/// per RFC 9110 §8.3.1.
pub fn contentTypeOk(value: ?[]const u8) bool {
    const raw = value orelse return false;
    const without_params = if (std.mem.findScalar(u8, raw, ';')) |semi| raw[0..semi] else raw;
    return std.ascii.eqlIgnoreCase(std.mem.trim(u8, without_params, " \t"), media_type);
}

const testing = std.testing;

test "init rejects a DoT endpoint before any io" {
    var http: std.http.Client = undefined;
    var request_buf: [min_request_buf]u8 = undefined;
    var transfer_buf: [min_transfer_buf]u8 = undefined;
    const endpoint = try transport.Endpoint.parse("tls://dns.google:853");
    try testing.expectError(
        error.BadUrl,
        DohClient.init(&http, endpoint, &request_buf, &transfer_buf),
    );
}

test "init builds a uri from the endpoint url" {
    var http: std.http.Client = undefined;
    var request_buf: [min_request_buf]u8 = undefined;
    var transfer_buf: [min_transfer_buf]u8 = undefined;
    const endpoint = try transport.Endpoint.parse("https://dns.example/dns-query");
    const doh = try DohClient.init(&http, endpoint, &request_buf, &transfer_buf);
    var component_buf: [256]u8 = undefined;
    try testing.expectEqualStrings("https", doh.uri.scheme);
    try testing.expectEqualStrings("dns.example", try doh.uri.host.?.toRaw(&component_buf));
    try testing.expectEqualStrings("/dns-query", try doh.uri.path.toRaw(&component_buf));
    try testing.expectEqualStrings("dns.example", doh.endpoint.host);
}

test "DohClient satisfies the transport.Client interface" {
    var http: std.http.Client = undefined;
    var request_buf: [min_request_buf]u8 = undefined;
    var transfer_buf: [min_transfer_buf]u8 = undefined;
    const endpoint = try transport.Endpoint.parse("https://dns.example/dns-query");
    var doh = try DohClient.init(&http, endpoint, &request_buf, &transfer_buf);

    // Instantiation is the check: the vtable is built from `exchangeFn`, so a
    // signature drift is a compile error here. The `std.http.Client` above is
    // never driven, and no exchange runs.
    const c: transport.Client = doh.client();
    try testing.expectEqual(@as(*anyopaque, @ptrCast(&doh)), c.ptr);
    try testing.expectEqual(
        @as(@TypeOf(c.exchangeFn), DohClient.exchangeFn),
        c.exchangeFn,
    );
}

test "exchange rejects a query larger than the request buffer" {
    var http: std.http.Client = undefined;
    var request_buf: [min_request_buf]u8 = undefined;
    var transfer_buf: [min_transfer_buf]u8 = undefined;
    const endpoint = try transport.Endpoint.parse("https://dns.example/dns-query");
    var doh = try DohClient.init(&http, endpoint, &request_buf, &transfer_buf);

    const oversized: [min_request_buf + 1]u8 = @splat(0);
    var response_buf: [512]u8 = undefined;
    // The size check precedes every use of `http`, so nothing is driven.
    try testing.expectError(
        error.BufferTooSmall,
        doh.exchange(undefined, &oversized, &response_buf),
    );
}

test "contentTypeOk accepts the RFC 8484 media type" {
    try testing.expect(contentTypeOk("application/dns-message"));
    try testing.expect(contentTypeOk("Application/DNS-Message"));
    try testing.expect(contentTypeOk("application/dns-message; charset=utf-8"));
    try testing.expect(contentTypeOk("application/dns-message ; charset=utf-8"));
    try testing.expect(contentTypeOk(" application/dns-message "));
}

test "contentTypeOk rejects anything else" {
    try testing.expect(!contentTypeOk(null));
    try testing.expect(!contentTypeOk("text/html"));
    try testing.expect(!contentTypeOk("application/json"));
    try testing.expect(!contentTypeOk(""));
    try testing.expect(!contentTypeOk("application/dns-message-extra"));
}

test "mapError maps local errors before phase errors" {
    try testing.expectEqual(error.OutOfMemory, mapError(error.OutOfMemory, .connect));
    try testing.expectEqual(error.Canceled, mapError(error.Canceled, .receive));
    try testing.expectEqual(error.Unexpected, mapError(error.Unexpected, .send));
}

test "mapError maps the collapsed tls errors regardless of phase" {
    try testing.expectEqual(error.TlsFailed, mapError(error.TlsInitializationFailed, .connect));
    try testing.expectEqual(error.TlsFailed, mapError(error.TlsInitializationFailed, .receive));
    try testing.expectEqual(error.TlsFailed, mapError(error.CertificateBundleLoadFailure, .connect));
}

test "mapError maps every unwrapped record-layer cause to TlsFailed" {
    // The set is the one `Connection.getReadError` can hand back, so the loop
    // fails the day std adds a member the switch does not name.
    inline for (@typeInfo(std.crypto.tls.Client.ReadError).error_set.?) |member| {
        try testing.expectEqual(
            transport.ExchangeError.TlsFailed,
            mapError(@field(std.crypto.tls.Client.ReadError, member.name), .receive),
        );
    }
}

test "mapError maps remaining errors by phase" {
    try testing.expectEqual(error.ConnectFailed, mapError(error.ConnectionRefused, .connect));
    try testing.expectEqual(error.SendFailed, mapError(error.WriteFailed, .send));
    try testing.expectEqual(error.ReceiveFailed, mapError(error.ReadFailed, .receive));
    try testing.expectEqual(error.ReceiveFailed, mapError(error.HttpHeadersInvalid, .receive));
}

/// Only the fields the unwrap helpers read are set. The rest of a `Connection`
/// is two buffered streams, a host name and a pool node, none of which the
/// helpers touch.
///
/// `.plain` on purpose: `Connection.getReadError` reaches a TLS connection's
/// stashed cause through `@fieldParentPtr`, which on a stub would read memory
/// that was never a `Tls`. What the test is about — that the accessor is
/// consulted at all — is the same on both protocols.
fn stubConnection(
    read_err: ?std.Io.net.Stream.Reader.Error,
    write_err: ?std.Io.net.Stream.Writer.Error,
) Connection {
    var connection: Connection = undefined;
    connection.protocol = .plain;
    connection.stream_reader.err = read_err;
    connection.stream_writer.err = write_err;
    return connection;
}

fn stubRequest(connection: *Connection, body_err: ?std.http.Reader.BodyError) Request {
    var req: Request = undefined;
    req.connection = connection;
    req.reader.body_err = body_err;
    return req;
}

test "the send unwrap keeps a cancelled write out of the peer fault group" {
    var connection = stubConnection(null, error.Canceled);
    var req = stubRequest(&connection, null);
    const mapped = mapError(sendCause(&req, error.WriteFailed), .send);
    try testing.expectEqual(transport.ExchangeError.Canceled, mapped);
    try testing.expectEqual(transport.Group.cancellation, transport.group(mapped));
}

test "the send unwrap keeps a local resource write failure out of the peer fault group" {
    var connection = stubConnection(null, error.SystemResources);
    var req = stubRequest(&connection, null);
    const mapped = mapError(sendCause(&req, error.WriteFailed), .send);
    try testing.expectEqual(transport.ExchangeError.SystemResources, mapped);
    try testing.expectEqual(transport.Group.local_resource, transport.group(mapped));
}

test "the send unwrap reports a peer side cause as a send fault" {
    var connection = stubConnection(null, error.ConnectionResetByPeer);
    var req = stubRequest(&connection, null);
    try testing.expectEqual(
        transport.ExchangeError.SendFailed,
        mapError(sendCause(&req, error.WriteFailed), .send),
    );
}

test "the head unwrap keeps a local resource read failure out of the peer fault group" {
    var connection = stubConnection(error.SystemResources, null);
    var req = stubRequest(&connection, null);
    const mapped = mapError(headCause(&req, error.ReadFailed), .receive);
    try testing.expectEqual(transport.ExchangeError.SystemResources, mapped);
    try testing.expectEqual(transport.Group.local_resource, transport.group(mapped));

    var canceled = stubConnection(error.Canceled, null);
    var canceled_req = stubRequest(&canceled, null);
    try testing.expectEqual(
        transport.ExchangeError.Canceled,
        mapError(headCause(&canceled_req, error.ReadFailed), .receive),
    );
}

test "the head unwrap reports a peer side cause as a receive fault" {
    var connection = stubConnection(error.ConnectionResetByPeer, null);
    var req = stubRequest(&connection, null);
    try testing.expectEqual(
        transport.ExchangeError.ReceiveFailed,
        mapError(headCause(&req, error.ReadFailed), .receive),
    );
}

test "the body unwrap keeps a cancelled read out of the peer fault group" {
    var connection = stubConnection(error.Canceled, null);
    var req = stubRequest(&connection, null);
    const resp: Response = .{ .request = &req, .head = undefined };
    const mapped = mapError(bodyCause(&resp, error.ReadFailed), .receive);
    try testing.expectEqual(transport.ExchangeError.Canceled, mapped);
    try testing.expectEqual(transport.Group.cancellation, transport.group(mapped));
}

test "the body unwrap prefers an http framing fault over the connection" {
    // A truncated chunk is the peer's doing and the connection carries no
    // cause at all, so reading the connection first would report the wrong
    // thing on the one path where both could be set.
    var connection = stubConnection(null, null);
    var req = stubRequest(&connection, error.HttpChunkTruncated);
    const resp: Response = .{ .request = &req, .head = undefined };
    try testing.expectEqual(error.HttpChunkTruncated, bodyCause(&resp, error.ReadFailed));
    try testing.expectEqual(
        transport.ExchangeError.ReceiveFailed,
        mapError(bodyCause(&resp, error.ReadFailed), .receive),
    );
}

test "the unwraps report the collapsed error when no cause was stored" {
    var connection = stubConnection(null, null);
    var req = stubRequest(&connection, null);
    const resp: Response = .{ .request = &req, .head = undefined };

    try testing.expectEqual(error.WriteFailed, sendCause(&req, error.WriteFailed));
    try testing.expectEqual(error.ReadFailed, headCause(&req, error.ReadFailed));
    try testing.expectEqual(error.ReadFailed, bodyCause(&resp, error.ReadFailed));

    try testing.expectEqual(
        transport.ExchangeError.SendFailed,
        mapError(sendCause(&req, error.WriteFailed), .send),
    );
    try testing.expectEqual(
        transport.ExchangeError.ReceiveFailed,
        mapError(bodyCause(&resp, error.ReadFailed), .receive),
    );
}

test "the unwraps pass a non-collapsed error through untouched" {
    // A stashed cause belongs to `error.ReadFailed` / `error.WriteFailed`. Any
    // other error already names itself, so the stash must not be read over it.
    var connection = stubConnection(error.Canceled, error.Canceled);
    var req = stubRequest(&connection, error.HttpChunkInvalid);
    const resp: Response = .{ .request = &req, .head = undefined };

    try testing.expectEqual(error.EndOfStream, sendCause(&req, error.EndOfStream));
    try testing.expectEqual(error.HttpHeadersInvalid, headCause(&req, error.HttpHeadersInvalid));
    try testing.expectEqual(error.EndOfStream, bodyCause(&resp, error.EndOfStream));
    try testing.expectEqual(
        transport.ExchangeError.ReceiveFailed,
        mapError(headCause(&req, error.HttpHeadersInvalid), .receive),
    );
}
