//! Network-dependent tests for `tls_client.zig`.
//!
//! This lives in its own file because it needs `@import("build_options")`, which only
//! exists when the compilation is driven by build.zig. Keeping it out of
//! `tls_client.zig` lets that file stay compilable with a bare
//! `zig test src/platform/tls_client.zig`.
//!
//! The test is compiled by every `zig build test` run, so it cannot rot, and skips at
//! run time unless `-Dlive` is passed. (`-Dintegration` stays hermetic: loopback only.
//! `-Dlive` is the gate for tests that leave the machine.)

const std = @import("std");
const build_options = @import("build_options");
const tls = std.crypto.tls;
const net = std.Io.net;
const Certificate = std.crypto.Certificate;

const tls_client = @import("tls_client.zig");

const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake };

/// `net.IpAddress.ConnectOptions.timeout` panics with "TODO implement
/// netConnectIpPosix with timeout" on POSIX in 0.16.0, and TLS stream reads take no
/// timeout at all. So the whole exchange runs as one task raced against a sleep, and
/// the loser is canceled.
const Outcome = union(enum) {
    exchange: anyerror!void,
    expiry: std.Io.Cancelable!void,
};

const Params = struct {
    gpa: std.mem.Allocator,
    bundle: *Certificate.Bundle,
    bundle_lock: *std.Io.RwLock,
    stream_read_buffer: []u8,
    stream_write_buffer: []u8,
};

/// Connects to the documented anycast address rather than resolving the name, so the
/// test exercises this module and not the stdlib resolver. `host` still drives SNI and
/// certificate hostname verification against "cloudflare-dns.com".
fn connectAndHandshake(io: std.Io, params: Params) anyerror!void {
    const address: net.IpAddress = try .parse("1.1.1.1", 853);
    var stream = try address.connect(io, .{ .mode = .stream });
    defer stream.close(io);

    var read_buffer: [4096]u8 = undefined;
    var write_buffer: [4096]u8 = undefined;

    var tls_stream: tls_client.TlsStream = undefined;
    try tls_stream.init(io, &stream, params.bundle, params.bundle_lock, params.gpa, .{
        .host = "cloudflare-dns.com",
        .ca = .system,
        .read_buffer = &read_buffer,
        .write_buffer = &write_buffer,
        .stream_read_buffer = params.stream_read_buffer,
        .stream_write_buffer = params.stream_write_buffer,
    });
    tls_stream.close();
}

fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void {
    return duration.sleep(io);
}

test "live DoT handshake against cloudflare-dns.com" {
    if (!build_options.live) return error.SkipZigTest;

    const gpa = std.testing.allocator;

    var threaded: std.Io.Threaded = .init(gpa, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var bundle: Certificate.Bundle = .empty;
    defer bundle.deinit(gpa);
    var bundle_lock: std.Io.RwLock = .init;

    const stream_read_buffer = try gpa.alloc(u8, tls.Client.min_buffer_len);
    defer gpa.free(stream_read_buffer);
    const stream_write_buffer = try gpa.alloc(u8, tls.Client.min_buffer_len);
    defer gpa.free(stream_write_buffer);

    var outcomes: [2]Outcome = undefined;
    var race: std.Io.Select(Outcome) = .init(io, &outcomes);
    defer race.cancelDiscard();

    try race.concurrent(.exchange, connectAndHandshake, .{ io, Params{
        .gpa = gpa,
        .bundle = &bundle,
        .bundle_lock = &bundle_lock,
        .stream_read_buffer = stream_read_buffer,
        .stream_write_buffer = stream_write_buffer,
    } });
    try race.concurrent(.expiry, expire, .{ io, budget });

    switch (try race.await()) {
        .exchange => |result| result catch |err| {
            std.debug.print("DoT handshake failed: {s} (class: {t})\n", .{
                @errorName(err),
                tls_client.classify(err),
            });
            return err;
        },
        .expiry => |result| {
            try result;
            return error.DotExchangeTimedOut;
        },
    }
}

// ---------------------------------------------------------------------------
// Hermetic: loopback only, no name resolution, no external host.
// ---------------------------------------------------------------------------

const tls_server = @import("tls_server.zig");

/// The fixture certificate carries `DNS:localhost`, so this is the only name a
/// `TlsStream` can verify against it.
const fixture_host = "localhost";

const echo_message = "nxdns tls flush probe";

/// Reads one message and echoes it back.
fn echoOnce(
    gpa: std.mem.Allocator,
    ctx: *tls_server.ServerContext,
    io: std.Io,
    server: *net.Server,
) anyerror!void {
    var stream = try server.accept(io);
    defer stream.close(io);

    var read_buffer: [4096]u8 = undefined;
    var write_buffer: [4096]u8 = undefined;
    var server_stream: tls_server.ServerStream = undefined;
    try server_stream.accept(gpa, ctx, io, &stream, &read_buffer, &write_buffer);
    defer server_stream.close(gpa);

    var received: [echo_message.len]u8 = undefined;
    try server_stream.reader().readSliceAll(&received);
    try server_stream.writer().writeAll(&received);
    try server_stream.writer().flush();
}

/// Writes through `TlsStream.flush` and waits for the echo. With only
/// `client.writer.flush()` the record would never leave this process and this
/// read would block until the budget expired.
fn flushAndEcho(io: std.Io, gpa: std.mem.Allocator, address: net.IpAddress) anyerror!void {
    var stream = try address.connect(io, .{ .mode = .stream });
    defer stream.close(io);

    var bundle: Certificate.Bundle = .empty;
    defer bundle.deinit(gpa);
    var bundle_lock: std.Io.RwLock = .init;

    var read_buffer: [4096]u8 = undefined;
    var write_buffer: [4096]u8 = undefined;
    var stream_read_buffer: [tls.Client.min_buffer_len]u8 = undefined;
    var stream_write_buffer: [tls.Client.min_buffer_len]u8 = undefined;

    var client: tls_client.TlsStream = undefined;
    // The fixture certificate is self-signed, so the chain cannot verify; the
    // host name still must match, which is what this test needs it to do.
    try client.init(io, &stream, &bundle, &bundle_lock, gpa, .{
        .host = fixture_host,
        .ca = .insecure_skip_verify,
        .read_buffer = &read_buffer,
        .write_buffer = &write_buffer,
        .stream_read_buffer = &stream_read_buffer,
        .stream_write_buffer = &stream_write_buffer,
    });
    defer client.close();

    try client.writer().writeAll(echo_message);
    try client.flush();

    var echoed: [echo_message.len]u8 = undefined;
    try client.reader().readSliceAll(&echoed);
    try std.testing.expectEqualStrings(echo_message, &echoed);
}

test "TlsStream.flush puts the record on the wire" {
    if (!build_options.integration) return error.SkipZigTest;

    const fixtures = @import("test_fixtures");
    const gpa = std.testing.allocator;

    var threaded: std.Io.Threaded = .init(gpa, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var ctx = try tls_server.ServerContext.init(gpa, fixtures.cert_pem, fixtures.key_pem, null);
    defer ctx.deinit(gpa);

    const listen_address: net.IpAddress = .{ .ip4 = .loopback(0) };
    var server = try listen_address.listen(io, .{ .reuse_address = true });
    defer server.deinit(io);

    var server_task = try io.concurrent(echoOnce, .{ gpa, &ctx, io, &server });

    // Raced against the budget: the failure this test guards against is a
    // record that never leaves the process, which without a deadline would
    // hang the run instead of failing it.
    var outcomes: [2]Outcome = undefined;
    var race: std.Io.Select(Outcome) = .init(io, &outcomes);
    defer race.cancelDiscard();
    try race.concurrent(.exchange, flushAndEcho, .{ io, gpa, server.socket.address });
    try race.concurrent(.expiry, expire, .{ io, budget });

    const client_result: anyerror!void = switch (try race.await()) {
        .exchange => |result| result,
        .expiry => |result| blk: {
            try result;
            break :blk error.TlsEchoTimedOut;
        },
    };

    // A client that never connects would leave the server blocked in `accept`.
    const server_result = if (client_result) |_|
        server_task.await(io)
    else |_|
        server_task.cancel(io);

    try client_result;
    try server_result;
}
