//! Blocked-response synthesis (PLAN §6.2). Pure: no allocation, no `std.Io`,
//! no clock. The caller supplies the buffer and gets back a prefix of it.
//!
//! No SOA is placed in the authority section. nxdns is not authoritative for a
//! blocked name, and a synthesized SOA would hand resolvers a negative-caching
//! TTL nxdns cannot honour: the operator can unblock the name at any moment,
//! and a client that cached the negative answer for the SOA's MINIMUM would
//! keep failing long after the block was lifted.

const std = @import("std");
const types = @import("../dns/types.zig");
const header = @import("../dns/header.zig");
const question = @import("../dns/question.zig");
const edns = @import("../dns/edns.zig");
const packet = @import("../dns/packet.zig");
const model = @import("../config/model.zig");

pub const Options = struct {
    mode: model.BlockResponse,
    ttl: u32,
};

pub const Error = packet.ResponseBuilder.Error;

const zero_a = [_]u8{0} ** 4;
const zero_aaaa = [_]u8{0} ** 16;

/// Writes a blocked reply for `q` into `buf` and returns a prefix of it.
///
/// `.zero`: A → 0.0.0.0, AAAA → ::, every other qtype → NOERROR with no answer
///   (NODATA). No address exists to synthesize for a qtype that carries none,
///   and answering NXDOMAIN for, say, an MX query would tell the client the
///   name does not exist while an A query for the same name says it does.
/// `.nxdomain`: RCODE = NXDOMAIN, no answer, for every qtype.
///
/// Only class `IN` is answered with addresses; any other class takes the
/// NODATA path, because `0.0.0.0` is an IN-class address and means nothing in
/// CH or HS.
///
/// `request_opt` echoes EDNS exactly as `handler.zig` does: a query that
/// carried an OPT record gets a reply carrying one with the same payload size
/// and the DO bit passed through.
pub fn writeBlocked(
    buf: []u8,
    request: header.Header,
    q: question.Question,
    request_opt: ?edns.OptRecord,
    do_bit: bool,
    options: Options,
) Error![]u8 {
    var b = try packet.ResponseBuilder.init(buf, request, q);

    switch (options.mode) {
        .nxdomain => b.setRcode(.nx_domain),
        .zero => if (q.qclass == .in) switch (q.qtype) {
            .a => try b.addAnswer(q.name, .a, .in, options.ttl, &zero_a),
            .aaaa => try b.addAnswer(q.name, .aaaa, .in, options.ttl, &zero_aaaa),
            else => {},
        },
    }

    if (request_opt) |opt| try b.addOptEcho(opt, do_bit);
    return b.finish();
}

const testing = std.testing;
const name = @import("../dns/name.zig");
const record = @import("../dns/record.zig");

/// A query for example.com A with an EDNS(0) OPT record advertising 4096
/// bytes: id 0x1234, RD set, one question, one additional.
const query_bytes =
    "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01" ++
    "\x07example\x03com\x00\x00\x01\x00\x01" ++
    "\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x00";

const blocked_name = "ads.example.com";
const ttl: u32 = 5;

fn requestHeader() header.Header {
    return (packet.parse(query_bytes) catch unreachable).header;
}

fn requestOpt() edns.OptRecord {
    const p = packet.parse(query_bytes) catch unreachable;
    return edns.parseOpt(query_bytes, packet.findOptRecord(p).?) catch unreachable;
}

fn blockedQuestion(qtype: types.Type, qclass: types.Class) !question.Question {
    return .{ .name = try name.fromText(blocked_name), .qtype = qtype, .qclass = qclass };
}

/// Builds a blocked reply and re-parses it, asserting the parts every case
/// shares: the echoed id, the QR and RA flags, the echoed question, an empty
/// authority section, and an additional section that holds the OPT record only
/// when the query carried one.
fn expectBlocked(
    buf: []u8,
    mode: model.BlockResponse,
    qtype: types.Type,
    qclass: types.Class,
    with_opt: bool,
) !packet.Packet {
    const q = try blockedQuestion(qtype, qclass);
    const bytes = try writeBlocked(
        buf,
        requestHeader(),
        q,
        if (with_opt) requestOpt() else null,
        false,
        .{ .mode = mode, .ttl = ttl },
    );

    const p = try packet.parse(bytes);
    try testing.expectEqual(@as(u16, 0x1234), p.header.id);
    try testing.expect(p.header.flags.qr);
    try testing.expect(p.header.flags.ra);
    try testing.expect(p.header.flags.rd);
    try testing.expectEqual(@as(u16, 1), p.header.qdcount);
    try testing.expectEqual(@as(u16, 0), p.header.nscount);
    try testing.expectEqual(@as(u16, if (with_opt) 1 else 0), p.header.arcount);

    const echoed = packet.firstQuestion(p).?;
    try testing.expectEqualSlices(u8, q.name.wire(), echoed.name.wire());
    try testing.expectEqual(qtype, echoed.qtype);
    try testing.expectEqual(qclass, echoed.qclass);

    if (with_opt) {
        const opt = try edns.parseOpt(bytes, packet.findOptRecord(p).?);
        try testing.expectEqual(@as(u16, 4096), opt.udp_payload_size);
        try testing.expectEqual(false, opt.do_bit);
    } else {
        try testing.expect(packet.findOptRecord(p) == null);
    }
    return p;
}

fn expectNodata(mode: model.BlockResponse, qtype: types.Type, qclass: types.Class) !void {
    for ([_]bool{ false, true }) |with_opt| {
        var buf: [512]u8 = undefined;
        const p = try expectBlocked(&buf, mode, qtype, qclass, with_opt);
        try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
        try testing.expectEqual(@as(u16, 0), p.header.ancount);
    }
}

fn expectNxdomain(qtype: types.Type) !void {
    for ([_]bool{ false, true }) |with_opt| {
        var buf: [512]u8 = undefined;
        const p = try expectBlocked(&buf, .nxdomain, qtype, .in, with_opt);
        try testing.expectEqual(types.Rcode.nx_domain, p.header.flags.rcode);
        try testing.expectEqual(@as(u16, 0), p.header.ancount);
    }
}

test "zero mode answers A with 0.0.0.0" {
    for ([_]bool{ false, true }) |with_opt| {
        var buf: [512]u8 = undefined;
        const p = try expectBlocked(&buf, .zero, .a, .in, with_opt);
        try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
        try testing.expectEqual(@as(u16, 1), p.header.ancount);

        var it = packet.answers(p);
        const answer = (try it.next()).?;
        try testing.expectEqual(types.Type.a, answer.rtype);
        try testing.expectEqual(@as(u16, @intFromEnum(types.Class.in)), answer.class);
        try testing.expectEqual(ttl, answer.ttl);
        try testing.expectEqualSlices(
            u8,
            (try name.fromText(blocked_name)).wire(),
            answer.name.wire(),
        );
        try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, try record.rdataA(p.bytes, answer));
        try testing.expect((try it.next()) == null);
    }
}

test "zero mode answers AAAA with ::" {
    for ([_]bool{ false, true }) |with_opt| {
        var buf: [512]u8 = undefined;
        const p = try expectBlocked(&buf, .zero, .aaaa, .in, with_opt);
        try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
        try testing.expectEqual(@as(u16, 1), p.header.ancount);

        var it = packet.answers(p);
        const answer = (try it.next()).?;
        try testing.expectEqual(types.Type.aaaa, answer.rtype);
        try testing.expectEqual(ttl, answer.ttl);
        try testing.expectEqual(zero_aaaa, try record.rdataAaaa(p.bytes, answer));
        try testing.expect((try it.next()) == null);
    }
}

test "zero mode answers MX with NODATA" {
    try expectNodata(.zero, .mx, .in);
}

test "zero mode answers HTTPS with NODATA" {
    try expectNodata(.zero, .https, .in);
}

test "zero mode answers a non-IN class with NODATA" {
    try expectNodata(.zero, .a, .ch);
    try expectNodata(.zero, .aaaa, .any);
}

test "nxdomain mode answers A with NXDOMAIN" {
    try expectNxdomain(.a);
}

test "nxdomain mode answers AAAA with NXDOMAIN" {
    try expectNxdomain(.aaaa);
}

test "nxdomain mode answers MX with NXDOMAIN" {
    try expectNxdomain(.mx);
}

test "nxdomain mode answers HTTPS with NXDOMAIN" {
    try expectNxdomain(.https);
}

test "the DO bit passes through" {
    for ([_]bool{ false, true }) |do_bit| {
        var buf: [512]u8 = undefined;
        const bytes = try writeBlocked(
            &buf,
            requestHeader(),
            try blockedQuestion(.a, .in),
            requestOpt(),
            do_bit,
            .{ .mode = .zero, .ttl = ttl },
        );
        const p = try packet.parse(bytes);
        const opt = try edns.parseOpt(bytes, packet.findOptRecord(p).?);
        try testing.expectEqual(do_bit, opt.do_bit);
    }
}

test "a ttl of zero survives the round trip" {
    var buf: [512]u8 = undefined;
    const bytes = try writeBlocked(
        &buf,
        requestHeader(),
        try blockedQuestion(.a, .in),
        null,
        false,
        .{ .mode = .zero, .ttl = 0 },
    );
    const p = try packet.parse(bytes);
    var it = packet.answers(p);
    try testing.expectEqual(@as(u32, 0), (try it.next()).?.ttl);
}

test "a buffer too small reports a write failure instead of truncating" {
    const q = try blockedQuestion(.a, .in);
    const options: Options = .{ .mode = .zero, .ttl = ttl };

    // Room for the header and the question, but not for the answer record.
    var no_room_for_answer: [40]u8 = undefined;
    try testing.expectError(
        error.WriteFailed,
        writeBlocked(&no_room_for_answer, requestHeader(), q, null, false, options),
    );

    // Room for the header and the question and the answer, but not the OPT.
    var no_room_for_opt: [72]u8 = undefined;
    try testing.expectError(
        error.WriteFailed,
        writeBlocked(&no_room_for_opt, requestHeader(), q, requestOpt(), false, options),
    );

    // Not even room for the header.
    var tiny: [8]u8 = undefined;
    try testing.expectError(
        error.WriteFailed,
        writeBlocked(&tiny, requestHeader(), q, null, false, options),
    );
}
