//! End-to-end test for the whole resolver path: a client socket on the
//! loopback, the real UDP and TCP listeners, the real handler, the real pool,
//! and two in-process fake upstreams.
//!
//! This lives in its own file because it needs `@import("build_options")`, which
//! only exists when the compilation is driven by build.zig. The body is compiled
//! by every `zig build test` run, so it cannot rot, and skips at run time unless
//! `-Dintegration` is passed.
//!
//! Hermetic: every socket is on 127.0.0.1 and the upstreams are structs, so
//! nothing leaves the machine. No stream read in 0.16.0 takes a timeout, so the
//! TCP client side runs as one task raced against a budget and nothing can hang.

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

const handler = @import("handler.zig");
const tcp_server = @import("tcp_server.zig");
const udp_server = @import("udp_server.zig");
const model = @import("../config/model.zig");
const response = @import("../filter/response.zig");
const packet = @import("../dns/packet.zig");
const record = @import("../dns/record.zig");
const types = @import("../dns/types.zig");
const health = @import("../upstream/health.zig");
const pool = @import("../upstream/pool.zig");
const transport = @import("../upstream/transport.zig");
const upstream_owner = @import("../upstream/owner.zig");

const testing = std.testing;

const blocking_defaults: model.Blocking = .{};
const blocking: response.Options = .{
    .mode = blocking_defaults.response,
    .ttl = blocking_defaults.ttl,
};
const forward_timeout: std.Io.Clock.Duration = .{
    .raw = model.readTimeout(.{}),
    .clock = .awake,
};

/// An upstream and nothing else optional: no filtering, no cache, no log. The
/// listeners and the pool are what this test exercises, so the handler is the
/// same bare one its own tests use.
fn bareHandler(up: *upstream_owner.Owner) handler.Handler {
    return .{
        .upstream = up,
        .policy = .{ .blocking = blocking, .forward_read_timeout = forward_timeout },
    };
}

/// Long enough that a loopback round trip cannot lose to scheduling, short
/// enough that a broken server fails the run instead of hanging it.
const budget: std.Io.Timeout = .{ .duration = .{ .raw = .fromSeconds(5), .clock = .awake } };
const budget_duration: std.Io.Clock.Duration = .{ .raw = .fromSeconds(5), .clock = .awake };

/// A query for example.com A: RD set, one question, no OPT. Each step of the
/// test rewrites the ID so a reply can only match the query it belongs to.
const query_bytes =
    "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++
    "\x07example\x03com\x00\x00\x01\x00\x01";

const udp_id: u16 = 0x1234;
const tcp_id: u16 = 0x5678;
const backoff_id: u16 = 0x9abc;

/// The address every fake upstream answers with, and its TTL.
const answer_rdata = [4]u8{ 93, 184, 216, 34 };
const answer_ttl: u32 = 300;

/// One failure is enough to open a backoff window, so the third query has a
/// single deterministic outcome. The window outlives the test many times over.
const test_cfg: health.Config = .{
    .failure_threshold = 1,
    .base_backoff_ms = 60_000,
    .max_backoff_ms = 60_000,
};

/// Nothing in this test is slow, so these budgets only exist to stop a wedged
/// attempt from hanging the run.
const pool_timeouts: pool.Timeouts = .{
    .attempt = .{ .raw = .fromSeconds(10), .clock = .awake },
    .total = .{ .raw = .fromSeconds(30), .clock = .awake },
};

fn queryWithId(buf: *[query_bytes.len]u8, id: u16) []const u8 {
    buf.* = query_bytes.*;
    packet.setId(buf, id);
    return buf;
}

/// Echoes the question and appends one A record. This is the smallest thing a
/// real upstream could return that the handler forwards unchanged, so the
/// assertions below check bytes that travelled the whole path.
fn answerQuery(query: []const u8, response_buf: []u8) transport.ExchangeError![]u8 {
    const request = packet.parse(query) catch return error.BadResponse;
    const q = packet.firstQuestion(request) orelse return error.BadResponse;

    var b = packet.ResponseBuilder.init(response_buf, request.header, q) catch
        return error.ResponseTooLarge;
    b.addAnswer(q.name, .a, .in, answer_ttl, &answer_rdata) catch
        return error.ResponseTooLarge;
    return b.finish();
}

/// The healthy upstream. `calls` is atomic because the listener tasks run on
/// other threads than the one asserting.
const GoodUpstream = struct {
    calls: std.atomic.Value(u64) = .init(0),

    fn exchangeFn(
        ptr: *anyopaque,
        io: std.Io,
        query: []const u8,
        response_buf: []u8,
        selected: *?[]const u8,
    ) transport.ExchangeError![]u8 {
        _ = io;
        selected.* = "fake://good-upstream";
        const self: *GoodUpstream = @ptrCast(@alignCast(ptr));
        _ = self.calls.fetchAdd(1, .monotonic);
        return answerQuery(query, response_buf);
    }

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

/// Returns `fault` on the first `fail_first` calls and answers after that.
/// `fail_first` is `maxInt` for an upstream that never recovers.
const FaultyUpstream = struct {
    fault: transport.PeerFault,
    fail_first: u64,
    calls: std.atomic.Value(u64) = .init(0),

    fn exchangeFn(
        ptr: *anyopaque,
        io: std.Io,
        query: []const u8,
        response_buf: []u8,
        selected: *?[]const u8,
    ) transport.ExchangeError![]u8 {
        _ = io;
        selected.* = "fake://faulty-upstream";
        const self: *FaultyUpstream = @ptrCast(@alignCast(ptr));
        const seen = self.calls.fetchAdd(1, .monotonic);
        if (seen < self.fail_first) return self.fault;
        return answerQuery(query, response_buf);
    }

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

/// One-slot entry storage: the slot the entry points at and the recovery
/// counter it borrows, declared by the test so both outlive the pool. These
/// tests drive failover and deadlines, not concurrency within one upstream, so
/// one slot per entry is the whole story here.
const EntryStorage = struct {
    slots: [1]pool.Slot = undefined,
    recoveries: std.atomic.Value(u64) = .init(0),

    fn entry(
        self: *EntryStorage,
        url: []const u8,
        upstream_client: transport.Client,
        priority: i32,
    ) pool.Entry {
        self.slots[0] = .{ .client = upstream_client };
        return .{
            .endpoint = transport.Endpoint.parse(url) catch unreachable,
            .slots = &self.slots,
            .priority = priority,
            .enabled = true,
            .health = .init,
            .admission = .{ .permits = self.slots.len },
            .reuse_recoveries = &self.recoveries,
        };
    }
};

const Outcome = union(enum) {
    work: anyerror!void,
    expiry: std.Io.Cancelable!void,
};

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

/// Runs the client side under a budget so a server that never answers fails the
/// test instead of hanging the run.
fn bounded(io: std.Io, comptime f: anytype, args: std.meta.ArgsTuple(@TypeOf(f))) !void {
    var outcomes: [2]Outcome = undefined;
    var race: std.Io.Select(Outcome) = .init(io, &outcomes);
    defer race.cancelDiscard();

    try race.concurrent(.work, f, args);
    try race.concurrent(.expiry, expire, .{ io, budget_duration });

    switch (try race.await()) {
        .work => |result| return result,
        .expiry => |result| {
            try result;
            return error.TestTimedOut;
        },
    }
}

/// Everything the client can check about one answer: it belongs to the query it
/// was sent for, it succeeded, and it carries the fake upstream's A record.
fn expectAnswer(reply: []const u8, id: u16) !void {
    const p = try packet.parse(reply);
    try testing.expectEqual(id, p.header.id);
    try testing.expectEqual(true, p.header.flags.qr);
    try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
    try testing.expectEqual(@as(u16, 1), p.header.qdcount);
    try testing.expectEqual(@as(u16, 1), p.header.ancount);

    const echoed = packet.firstQuestion(p) orelse return error.TestMissingQuestion;
    const asked = packet.firstQuestion(try packet.parse(query_bytes)).?;
    try testing.expectEqualSlices(u8, asked.name.wire(), echoed.name.wire());
    try testing.expectEqual(types.Type.a, echoed.qtype);

    var it = packet.answers(p);
    const rec = (try it.next()) orelse return error.TestMissingAnswer;
    try testing.expectEqual(types.Type.a, rec.rtype);
    try testing.expectEqual(answer_ttl, rec.ttl);
    try testing.expectEqual(answer_rdata, try record.rdataA(reply, rec));
}

/// One length-prefixed query and its answer on a fresh connection
/// (RFC 1035 §4.2.2).
fn tcpQuery(io: std.Io, address: net.IpAddress, id: u16) anyerror!void {
    var stream = try address.connect(io, .{ .mode = .stream });
    defer stream.close(io);

    var read_buf: [1024]u8 = undefined;
    var write_buf: [1024]u8 = undefined;
    var reader = stream.reader(io, &read_buf);
    var writer = stream.writer(io, &write_buf);

    var query_buf: [query_bytes.len]u8 = undefined;
    const query = queryWithId(&query_buf, id);

    try writer.interface.writeAll(&transport.framePrefix(@intCast(query.len)));
    try writer.interface.writeAll(query);
    try writer.interface.flush();

    const len = transport.parsePrefix((try reader.interface.takeArray(transport.prefix_len)).*);
    try expectAnswer(try reader.interface.take(len), id);
}

test "the whole resolver answers over udp and tcp and fails over to a healthy upstream" {
    if (!build_options.integration) return error.SkipZigTest;

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

    var bad: FaultyUpstream = .{
        .fault = error.ConnectFailed,
        .fail_first = std.math.maxInt(u64),
    };
    var good: GoodUpstream = .{};
    var bad_storage: EntryStorage = .{};
    var good_storage: EntryStorage = .{};
    var entries = [_]pool.Entry{
        bad_storage.entry("https://bad.example/dns-query", bad.client(), 10),
        good_storage.entry("tls://good.example", good.client(), 20),
    };
    var upstreams: pool.Pool = .init(&entries, test_cfg, pool_timeouts, 1);

    var h_owner: upstream_owner.Borrowed = .{};

    var h = bareHandler(h_owner.client(upstreams.client()));

    const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
    var udp = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
    var tcp = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
    const udp_address = udp.boundAddress();
    const tcp_address = tcp.boundAddress();

    var group: std.Io.Group = .init;
    try group.concurrent(io, udp_server.UdpServer.serve, .{ &udp, io });
    try group.concurrent(io, tcp_server.TcpServer.serve, .{ &tcp, io });

    const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
    var client = try client_address.bind(io, .{ .mode = .dgram });
    defer client.close(io);

    {
        var query_buf: [query_bytes.len]u8 = undefined;
        const query = queryWithId(&query_buf, udp_id);
        try client.send(io, &udp_address, query);

        var buf: [udp_server.max_datagram]u8 = undefined;
        const msg = try client.receiveTimeout(io, &buf, budget);
        try expectAnswer(msg.data, udp_id);
    }

    try bounded(io, tcpQuery, .{ io, tcp_address, tcp_id });

    try testing.expectEqual(@as(u64, 2), h.stats.queries.load(.monotonic));
    try testing.expectEqual(@as(u64, 0), h.stats.servfail.load(.monotonic));

    var snapshots: [2]pool.Snapshot = undefined;
    try testing.expectEqual(@as(usize, 2), try upstreams.snapshot(io, &snapshots));

    try testing.expectEqualStrings("https://bad.example/dns-query", snapshots[0].url);
    try testing.expect(snapshots[0].consecutive_failures >= 1);
    try testing.expect(snapshots[0].backoff_until != null);
    try testing.expect(!snapshots[0].available);

    try testing.expectEqualStrings("tls://good.example", snapshots[1].url);
    try testing.expect(snapshots[1].total_successes >= 2);

    // The failing entry is in backoff, so the third query must reach the
    // healthy entry without touching it.
    const bad_calls = bad.calls.load(.monotonic);
    {
        var query_buf: [query_bytes.len]u8 = undefined;
        const query = queryWithId(&query_buf, backoff_id);
        try client.send(io, &udp_address, query);

        var buf: [udp_server.max_datagram]u8 = undefined;
        const msg = try client.receiveTimeout(io, &buf, budget);
        try expectAnswer(msg.data, backoff_id);
    }
    try testing.expectEqual(bad_calls, bad.calls.load(.monotonic));
    try testing.expectEqual(@as(u64, 3), good.calls.load(.monotonic));

    udp.deinit(gpa, io);
    tcp.deinit(io);
    group.cancel(io);
}
