//! The TTL cache (PLAN §8): a bounded, in-memory store of whole DNS response
//! messages keyed by the question they answer.
//!
//! Pure. Every decision that needs a clock takes the current wall-clock second
//! as a parameter, so this file holds no `std.Io`, no clock and no socket. The
//! only allocation happens in `init` and `put`; `get` copies into a caller
//! buffer and allocates nothing.
//!
//! **Not thread-safe.** No lock guards the slots, the index or the counters.
//! The query path owns the lock: `handler.Handler.cache_mutex` is held across
//! every `get`, `put` and `sweep`.
//!
//! The store is a fixed array of slots plus a hash index from key bytes to slot
//! number, both sized at `init` and never resized: a household resolver must
//! not grow its memory because a burst of unique names arrived. Replacement is
//! CLOCK — one reference bit per slot, a hand that clears bits as it looks for a
//! victim — which approximates LRU at the cost of a single bit and no list
//! surgery on the hit path.

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

const Allocator = std.mem.Allocator;

/// Sanity ceiling on a positive TTL. Same value as `max_ttl_seconds` in
/// `validate.zig`, which already caps `blocking.ttl` and
/// `cache.negative_ttl_max`: one bound on how long nxdns may hold a name.
pub const max_ttl_seconds: u32 = 86_400;

/// Longest ECS prefix the key accepts: an IPv6 address plus the two-byte family
/// field, the two prefix-length bytes and slack for the option header.
pub const max_ecs_len = 40;

/// qname (lowercased ASCII, no trailing dot) ++ 0x00 ++ qtype BE ++ qclass BE
/// ++ do byte ++ ecs_len byte ++ ecs bytes.
pub const max_key_len = types.max_name_len + 1 + 2 + 2 + 1 + 1 + max_ecs_len;

/// Writes the cache key into `buf` and returns the written prefix.
///
/// ECS participates only when the caller passes it. `handler.Context.cacheKey`
/// passes the forwarded subnet under `ecs_mode=forward` and nothing else (PLAN §8),
/// so a deployment that does not forward ECS pays no key-space split for it.
///
/// The length bounds are assertions, not errors: both values reach here from
/// the packet parser, which has already rejected anything longer.
///
/// The lowercase-and-strip-the-dot below is deliberately not
/// `dns.name.normalizeText`: that one validates, and this input is already
/// asserted valid, so a key build must never fail.
pub fn buildKey(
    buf: *[max_key_len]u8,
    qname: []const u8,
    qtype: u16,
    qclass: u16,
    do_bit: bool,
    ecs: ?[]const u8,
) []const u8 {
    std.debug.assert(qname.len <= types.max_name_len);
    if (ecs) |bytes| std.debug.assert(bytes.len <= max_ecs_len);

    var name = qname;
    if (name.len > 0 and name[name.len - 1] == '.') name = name[0 .. name.len - 1];

    var len: usize = 0;
    for (name) |c| {
        buf[len] = std.ascii.toLower(c);
        len += 1;
    }

    // The separator keeps a name from running into the fields behind it, so
    // "a" with qtype 0x0101 cannot collide with "a\x01" with qtype 0x0100.
    buf[len] = 0;
    len += 1;

    std.mem.writeInt(u16, buf[len..][0..2], qtype, .big);
    len += 2;
    std.mem.writeInt(u16, buf[len..][0..2], qclass, .big);
    len += 2;
    buf[len] = @intFromBool(do_bit);
    len += 1;

    const ecs_bytes = ecs orelse &[_]u8{};
    buf[len] = @intCast(ecs_bytes.len);
    len += 1;
    @memcpy(buf[len..][0..ecs_bytes.len], ecs_bytes);
    len += ecs_bytes.len;

    return buf[0..len];
}

pub const Class = struct {
    ttl_seconds: u32,
    negative: bool,
};

/// Decides whether a response is cacheable and for how long. Null means do not
/// cache — an unparsable message, an rcode nxdns will not hold, a missing SOA,
/// or a TTL that has already run out.
///
/// The positive TTL is the smallest TTL in the message, which is what
/// `packet.decrementTtls` would report. It is computed by iterating the
/// sections rather than by calling that function, because `decrementTtls`
/// writes to its buffer and a response worth classifying has not been copied
/// yet.
///
/// `negative_ttl_max == 0` disables negative caching outright: the response is
/// not stored at all (milestone-6 ruling 6).
///
/// A TC=1 response is never cached, whatever its rcode. It is a partial message
/// whose tail the sender dropped, and RFC 2181 §9 forbids keeping one: served
/// from the cache the TC bit reaches a client that has no truncation to recover
/// from, and a client that retries over TCP is answered with the same truncated
/// bytes again.
pub fn classify(response: []const u8, negative_ttl_max: u32) ?Class {
    const p = packet.parse(response) catch return null;
    if (p.header.flags.tc) return null;
    const rcode = p.header.flags.rcode;

    if (rcode == .no_error and p.header.ancount > 0) {
        const smallest = (minRecordTtl(p) catch return null) orelse return null;
        const ttl = @min(smallest, max_ttl_seconds);
        if (ttl == 0) return null;
        return .{ .ttl_seconds = ttl, .negative = false };
    }

    // RFC 2308 §2: an empty NOERROR answer is NODATA, a negative answer like
    // NXDOMAIN, and both take their lifetime from the authority SOA.
    if (rcode == .nx_domain or rcode == .no_error) {
        if (negative_ttl_max == 0) return null;
        const soa = soaTtls(p) orelse return null;
        // RFC 2308 §5: the negative lifetime is the smaller of the SOA record's
        // own TTL and its MINIMUM field.
        const from_soa = @min(effectiveTtl(soa.record_ttl), effectiveTtl(soa.minimum));
        const ttl = @min(from_soa, negative_ttl_max);
        if (ttl == 0) return null;
        return .{ .ttl_seconds = ttl, .negative = true };
    }

    return null;
}

/// RFC 2181 §8: a TTL whose top bit is set is read as zero. This cache does not
/// store a zero TTL, so such a response is not cached at all.
/// `packet.decrementTtls` leaves the rule to its caller, and this is the caller.
fn effectiveTtl(ttl: u32) u32 {
    return if (ttl & 0x8000_0000 != 0) 0 else ttl;
}

/// The smallest TTL over every record in the message, OPT excluded — RFC 6891
/// §6.1.3 reuses its TTL field for flags. Null when no record carries a TTL
/// that means anything.
fn minRecordTtl(p: packet.Packet) packet.WalkError!?u32 {
    var smallest: ?u32 = null;
    var iterators = [_]packet.RecordIterator{
        packet.answers(p),
        packet.authorities(p),
        packet.additionals(p),
    };
    for (&iterators) |*it| {
        while (try it.next()) |rec| {
            if (rec.rtype == .opt) continue;
            const ttl = effectiveTtl(rec.ttl);
            smallest = if (smallest) |current| @min(current, ttl) else ttl;
        }
    }
    return smallest;
}

const SoaTtls = struct {
    record_ttl: u32,
    minimum: u32,
};

/// TTL is four bytes and RDLENGTH two, so a record's TTL field starts six bytes
/// before its RDATA.
const ttl_bytes_before_rdata = 6;

/// Lowers the first authority SOA's TTL to `ttl_seconds` in `bytes`.
///
/// RFC 2308 §3 makes that TTL the lifetime a downstream cache keeps the
/// negative answer for, and `classify` may have cut the entry's lifetime below
/// it using the SOA MINIMUM field or `negative_ttl_max`. Without this rewrite a
/// hit would advertise a TTL longer than the entry itself lives.
///
/// It clamps the same SOA that `soaTtls` measured, and it only ever lowers a
/// TTL. Bytes it cannot parse are left alone: `get` refuses an entry that does
/// not survive ageing, so an unparsable clone needs no separate treatment here.
fn clampSoaTtl(bytes: []u8, ttl_seconds: u32) void {
    const p = packet.parse(bytes) catch return;
    var it = packet.authorities(p);
    while (it.next() catch return) |rec| {
        if (rec.rtype != .soa) continue;
        if (rec.ttl <= ttl_seconds) return;
        // The write comes after the last read of the iterator, which walks the
        // same bytes: a record rewritten mid-walk could break the walk.
        const ttl_offset = rec.rdata.offset - ttl_bytes_before_rdata;
        std.mem.writeInt(u32, bytes[ttl_offset..][0..4], ttl_seconds, .big);
        return;
    }
}

/// Lowers every record TTL in `bytes` above `max_ttl_seconds` to that ceiling,
/// OPT excluded — RFC 6891 §6.1.3 reuses its TTL field for flags.
///
/// `classify` caps a positive entry's lifetime at `max_ttl_seconds`, so without
/// this rewrite a hit would advertise a TTL that outlives the entry. A record at
/// or below the ceiling keeps its own TTL, including one longer than the entry's
/// shortest record: per-record lifetimes are what a downstream cache expects.
///
/// A TTL rewritten here may sit under an owner name that a later record
/// compresses against, and the rewrite can leave that name undecodable. The walk
/// stops there, and `get` discards the entry on the first hit — the same fate
/// `packet.decrementTtls` already gives such a message when it ages it.
fn capRecordTtls(bytes: []u8) void {
    const p = packet.parse(bytes) catch return;
    var iterators = [_]packet.RecordIterator{
        packet.answers(p),
        packet.authorities(p),
        packet.additionals(p),
    };
    for (&iterators) |*it| {
        while (it.next() catch return) |rec| {
            if (rec.rtype == .opt) continue;
            if (rec.ttl <= max_ttl_seconds) continue;
            const ttl_offset = rec.rdata.offset - ttl_bytes_before_rdata;
            std.mem.writeInt(u32, bytes[ttl_offset..][0..4], max_ttl_seconds, .big);
        }
    }
}

/// The two lifetime fields of the first authority SOA: the record's own TTL and
/// the MINIMUM field of its RDATA (RFC 2308 §4). A malformed SOA is treated as
/// an absent one: the response is simply not cached.
fn soaTtls(p: packet.Packet) ?SoaTtls {
    var it = packet.authorities(p);
    while (it.next() catch return null) |rec| {
        if (rec.rtype != .soa) continue;
        const minimum = record.rdataSoaMinimumTtl(p.bytes, rec) catch return null;
        return .{ .record_ttl = rec.ttl, .minimum = minimum };
    }
    return null;
}

/// Field-for-field the cache section of the config model. Aliasing it keeps one
/// type for one concept: `size` counts entries, not bytes (ruling 7).
pub const Config = model.Cache;

pub const Stats = struct {
    hits: u64 = 0,
    misses: u64 = 0,
    inserts: u64 = 0,
    evictions: u64 = 0,
    expirations: u64 = 0,
    /// Entries dropped because aging their bytes made them unparsable. A
    /// poisoned entry never serves twice.
    invalid_hits: u64 = 0,
};

/// The key lives inline so that a slot owns no memory besides its response
/// bytes, and so that the index can key on a slice that stays valid for the
/// life of the cache.
const Slot = struct {
    key: [max_key_len]u8,
    key_len: u16,
    bytes: []u8,
    stored_at_s: i64,
    expires_at_s: i64,
    negative: bool,
    referenced: bool,
    occupied: bool,

    fn keySlice(self: *const Slot) []const u8 {
        return self.key[0..self.key_len];
    }
};

/// Key bytes to slot number. The stored key slices point into the slots array,
/// which is allocated once and never moves.
const Index = std.StringHashMapUnmanaged(u32);

pub const DnsCache = struct {
    gpa: Allocator,
    slots: []Slot,
    index: Index,
    /// The CLOCK hand: the next slot the replacement scan will examine.
    hand: u32,
    occupied: u32,
    stats: Stats,

    /// Allocates the slots and the index up front. `ensureTotalCapacity` at the
    /// entry count means the index never rehashes, so the key slices it stores
    /// stay valid and no insert can allocate.
    pub fn init(gpa: Allocator, config: Config) Allocator.Error!DnsCache {
        const slots = try gpa.alloc(Slot, config.size);
        errdefer gpa.free(slots);
        for (slots) |*slot| {
            slot.* = .{
                .key = undefined,
                .key_len = 0,
                .bytes = &.{},
                .stored_at_s = 0,
                .expires_at_s = 0,
                .negative = false,
                .referenced = false,
                .occupied = false,
            };
        }

        var index: Index = .empty;
        errdefer index.deinit(gpa);
        try index.ensureTotalCapacity(gpa, config.size);

        return .{
            .gpa = gpa,
            .slots = slots,
            .index = index,
            .hand = 0,
            .occupied = 0,
            .stats = .{},
        };
    }

    pub fn deinit(self: *DnsCache) void {
        for (self.slots) |*slot| {
            if (slot.occupied) self.gpa.free(slot.bytes);
        }
        self.gpa.free(self.slots);
        self.index.deinit(self.gpa);
        self.* = undefined;
    }

    /// Stores a clone of `response` under `key`. A key already present is
    /// replaced in place, which keeps the entry count and the CLOCK order
    /// unchanged.
    ///
    /// The clone never advertises a TTL that outlives the entry: a negative
    /// response has its SOA TTL lowered to the entry's lifetime, and a positive
    /// one has every record above `max_ttl_seconds` lowered to that ceiling. The
    /// caller's buffer is not touched.
    ///
    /// A cache configured with zero entries stores nothing. That is caching
    /// switched off, not a failure, so it is not an error.
    pub fn put(
        self: *DnsCache,
        now_s: i64,
        key: []const u8,
        response: []const u8,
        class: Class,
    ) Allocator.Error!void {
        std.debug.assert(key.len <= max_key_len);
        if (self.slots.len == 0) return;

        const bytes = try self.gpa.dupe(u8, response);
        errdefer self.gpa.free(bytes);
        if (class.negative) clampSoaTtl(bytes, class.ttl_seconds) else capRecordTtls(bytes);

        const expires_at_s = now_s +| @as(i64, class.ttl_seconds);

        if (self.index.get(key)) |existing| {
            const slot = &self.slots[existing];
            self.gpa.free(slot.bytes);
            slot.bytes = bytes;
            slot.stored_at_s = now_s;
            slot.expires_at_s = expires_at_s;
            slot.negative = class.negative;
            self.stats.inserts += 1;
            return;
        }

        const index = self.acquireSlot(now_s);
        const slot = &self.slots[index];
        @memcpy(slot.key[0..key.len], key);
        slot.key_len = @intCast(key.len);
        slot.bytes = bytes;
        slot.stored_at_s = now_s;
        slot.expires_at_s = expires_at_s;
        slot.negative = class.negative;
        slot.referenced = false;
        slot.occupied = true;

        const gop = self.index.getOrPutAssumeCapacity(slot.keySlice());
        std.debug.assert(!gop.found_existing);
        gop.key_ptr.* = slot.keySlice();
        gop.value_ptr.* = index;

        self.occupied += 1;
        self.stats.inserts += 1;
    }

    /// Copies the stored response into `out` and ages its TTLs by the time the
    /// entry has been held. Nothing else is rewritten — the caller sets the
    /// transaction ID with `packet.setId`.
    ///
    /// Returns null on a miss, on an expired entry, and on an `out` too small
    /// to hold the response; all three count as `misses`, because from the
    /// caller's side each one means the query must go upstream.
    pub fn get(self: *DnsCache, now_s: i64, key: []const u8, out: []u8) ?[]u8 {
        const index = self.index.get(key) orelse {
            self.stats.misses += 1;
            return null;
        };

        const slot = &self.slots[index];
        if (now_s >= slot.expires_at_s) {
            self.release(index);
            self.stats.expirations += 1;
            self.stats.misses += 1;
            return null;
        }
        if (out.len < slot.bytes.len) {
            self.stats.misses += 1;
            return null;
        }

        const copy = out[0..slot.bytes.len];
        @memcpy(copy, slot.bytes);

        // A clock that stepped backwards ages nothing rather than ageing by a
        // negative amount.
        const elapsed_s = now_s - slot.stored_at_s;
        const elapsed: u32 = if (elapsed_s <= 0)
            0
        else
            @intCast(@min(elapsed_s, std.math.maxInt(u32)));

        _ = packet.decrementTtls(copy, elapsed) catch {
            self.release(index);
            self.stats.invalid_hits += 1;
            return null;
        };

        slot.referenced = true;
        self.stats.hits += 1;
        return copy;
    }

    /// Removes every expired entry and returns how many. `app.maintenanceOnce`
    /// schedules it;
    /// expiry is also enforced on access, so this only reclaims memory held by
    /// names nobody asks for any more.
    pub fn sweep(self: *DnsCache, now_s: i64) u32 {
        var removed: u32 = 0;
        for (self.slots, 0..) |*slot, index| {
            if (!slot.occupied) continue;
            if (now_s < slot.expires_at_s) continue;
            self.release(@intCast(index));
            removed += 1;
        }
        self.stats.expirations += removed;
        return removed;
    }

    pub fn len(self: *const DnsCache) u32 {
        return self.occupied;
    }

    /// Bytes held: the slot array, the cloned responses, and the index arrays.
    /// The index term follows its layout of a key array, a value array and one
    /// metadata byte per bucket.
    pub fn memoryBytes(self: *const DnsCache) usize {
        var total = self.slots.len * @sizeOf(Slot);
        for (self.slots) |*slot| {
            if (slot.occupied) total += slot.bytes.len;
        }
        total += @as(usize, self.index.capacity()) *
            (@sizeOf([]const u8) + @sizeOf(u32) + 1);
        return total;
    }

    /// Returns a slot ready for a new entry, freeing whatever held it.
    ///
    /// An expired slot met on the way is reclaimed as an expiration rather than
    /// an eviction: it was already dead, and counting it as an eviction would
    /// report cache pressure that is not there.
    fn acquireSlot(self: *DnsCache, now_s: i64) u32 {
        const capacity: u32 = @intCast(self.slots.len);
        // Every occupied slot is examined at most twice: the first pass clears
        // the reference bits, the second finds a slot with none set.
        const limit = @as(usize, capacity) * 2;

        var steps: usize = 0;
        while (steps < limit) : (steps += 1) {
            const index = self.hand;
            self.hand = (self.hand + 1) % capacity;
            const slot = &self.slots[index];

            if (!slot.occupied) return index;
            if (now_s >= slot.expires_at_s) {
                self.release(index);
                self.stats.expirations += 1;
                return index;
            }
            if (slot.referenced) {
                slot.referenced = false;
                continue;
            }
            self.release(index);
            self.stats.evictions += 1;
            return index;
        }
        unreachable;
    }

    fn release(self: *DnsCache, index: u32) void {
        const slot = &self.slots[index];
        std.debug.assert(slot.occupied);
        const removed = self.index.remove(slot.keySlice());
        std.debug.assert(removed);
        self.gpa.free(slot.bytes);
        slot.bytes = &.{};
        slot.key_len = 0;
        slot.occupied = false;
        slot.referenced = false;
        self.occupied -= 1;
    }
};

const testing = std.testing;

/// NXDOMAIN for example.com A with one authority SOA whose MINIMUM is 600 and
/// whose record TTL is 3600, so the two are never confused.
const nxdomain_bytes =
    "\x00\x01\x81\x83\x00\x01\x00\x00\x00\x01\x00\x00" ++
    "\x07example\x03com\x00\x00\x01\x00\x01" ++
    "\xc0\x0c\x00\x06\x00\x01\x00\x00\x0e\x10\x00\x16" ++
    "\x00\x00" ++ // root MNAME, root RNAME
    "\x00\x00\x00\x01\x00\x00\x1c\x20\x00\x00\x0e\x10\x00\x36\xee\x80\x00\x00\x02\x58";

/// The same message with rcode NOERROR: an empty answer section, which RFC 2308
/// §2 calls NODATA and caches negatively too.
const nodata_bytes =
    "\x00\x01\x81\x80\x00\x01\x00\x00\x00\x01\x00\x00" ++
    nxdomain_bytes[12..];

/// SERVFAIL with nothing else in it.
const servfail_bytes = "\x00\x01\x81\x82\x00\x01\x00\x00\x00\x00\x00\x00" ++
    "\x07example\x03com\x00\x00\x01\x00\x01";

/// Builds a NOERROR response for example.com A carrying `ttls.len` answers.
fn buildAnswer(buf: []u8, ttls: []const u32) ![]u8 {
    const query = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++
        "\x07example\x03com\x00\x00\x01\x00\x01";
    const request = try packet.parse(query);
    const q = packet.firstQuestion(request).?;

    var builder = try packet.ResponseBuilder.init(buf, request.header, q);
    for (ttls) |ttl| {
        try builder.addAnswer(q.name, .a, .in, ttl, "\x0a\x00\x00\x01");
    }
    return builder.finish();
}

fn firstAuthoritySoaTtl(bytes: []const u8) !u32 {
    const p = try packet.parse(bytes);
    var it = packet.authorities(p);
    while (try it.next()) |rec| {
        if (rec.rtype == .soa) return rec.ttl;
    }
    return error.NoSoaRecord;
}

fn answerTtls(bytes: []const u8, out: []u32) ![]u32 {
    const p = try packet.parse(bytes);
    var it = packet.answers(p);
    var count: usize = 0;
    while (try it.next()) |rec| {
        out[count] = rec.ttl;
        count += 1;
    }
    return out[0..count];
}

fn firstAnswerTtl(bytes: []const u8) !u32 {
    const p = try packet.parse(bytes);
    var it = packet.answers(p);
    return (try it.next()).?.ttl;
}

test "buildKey lowercases the name and strips one trailing dot" {
    var upper_buf: [max_key_len]u8 = undefined;
    var lower_buf: [max_key_len]u8 = undefined;
    const upper = buildKey(&upper_buf, "EXAMPLE.CoM.", 1, 1, false, null);
    const lower = buildKey(&lower_buf, "example.com", 1, 1, false, null);
    try testing.expectEqualSlices(u8, lower, upper);
    try testing.expectEqualStrings("example.com", upper[0..11]);
    try testing.expectEqual(@as(u8, 0), upper[11]);
    try testing.expectEqual(@as(usize, 11 + 1 + 2 + 2 + 1 + 1), upper.len);

    // Only one dot goes: "a.." is a different name from "a.".
    var two_dots: [max_key_len]u8 = undefined;
    const key = buildKey(&two_dots, "example.com..", 1, 1, false, null);
    try testing.expectEqualStrings("example.com.", key[0..12]);
}

test "buildKey separates every question component" {
    var buf_a: [max_key_len]u8 = undefined;
    var buf_b: [max_key_len]u8 = undefined;
    const base = buildKey(&buf_a, "example.com", 1, 1, false, null);

    const variants = [_][]const u8{
        buildKey(&buf_b, "example.com", 28, 1, false, null),
        buildKey(&buf_b, "example.com", 1, 3, false, null),
        buildKey(&buf_b, "example.com", 1, 1, true, null),
        buildKey(&buf_b, "example.org", 1, 1, false, null),
    };
    for (variants) |variant| {
        try testing.expect(!std.mem.eql(u8, base, variant));
    }

    // A repeated build of the same question is byte-identical.
    try testing.expectEqualSlices(u8, base, buildKey(&buf_b, "example.com", 1, 1, false, null));
}

test "buildKey distinguishes absent, empty and present ECS" {
    var buf_a: [max_key_len]u8 = undefined;
    var buf_b: [max_key_len]u8 = undefined;
    var buf_c: [max_key_len]u8 = undefined;

    const absent = buildKey(&buf_a, "example.com", 1, 1, false, null);
    const present = buildKey(&buf_b, "example.com", 1, 1, false, "\x0a\x00\x01");
    const other = buildKey(&buf_c, "example.com", 1, 1, false, "\x0a\x00\x02");

    try testing.expect(!std.mem.eql(u8, absent, present));
    try testing.expect(!std.mem.eql(u8, present, other));
    try testing.expectEqual(absent.len + 3, present.len);

    // An empty ECS slice keys the same as no ECS: both mean no subnet.
    var buf_d: [max_key_len]u8 = undefined;
    try testing.expectEqualSlices(u8, absent, buildKey(&buf_d, "example.com", 1, 1, false, ""));
}

test "classify takes the smallest ttl of a positive answer" {
    var buf: [512]u8 = undefined;
    const response = try buildAnswer(&buf, &.{ 300, 60, 900 });
    const class = classify(response, 3600).?;
    try testing.expectEqual(@as(u32, 60), class.ttl_seconds);
    try testing.expectEqual(false, class.negative);
}

test "classify caps a positive ttl at the sanity ceiling" {
    var buf: [512]u8 = undefined;
    const response = try buildAnswer(&buf, &.{ 1_000_000, 2_000_000 });
    const class = classify(response, 3600).?;
    try testing.expectEqual(max_ttl_seconds, class.ttl_seconds);
}

test "classify refuses a zero ttl and unparsable bytes" {
    var buf: [512]u8 = undefined;
    const response = try buildAnswer(&buf, &.{ 300, 0 });
    try testing.expectEqual(@as(?Class, null), classify(response, 3600));

    try testing.expectEqual(@as(?Class, null), classify("\x00\x01\x81", 3600));
    try testing.expectEqual(@as(?Class, null), classify(response[0 .. response.len - 1], 3600));
}

/// The TC bit is bit 9 of the flags word, which is the second 16-bit word of
/// the header.
fn setTruncated(bytes: []u8) []u8 {
    const flags = std.mem.readInt(u16, bytes[2..4], .big);
    std.mem.writeInt(u16, bytes[2..4], flags | 0x0200, .big);
    return bytes;
}

test "classify refuses a truncated response whatever its rcode" {
    var buf: [512]u8 = undefined;
    const answer = try buildAnswer(&buf, &.{300});
    try testing.expectEqual(@as(u32, 300), classify(answer, 3600).?.ttl_seconds);
    try testing.expectEqual(@as(?Class, null), classify(setTruncated(answer), 3600));

    // The negative path reads the same bit: a truncated NXDOMAIN is a partial
    // message too.
    var nx_buf: [nxdomain_bytes.len]u8 = undefined;
    @memcpy(&nx_buf, nxdomain_bytes);
    try testing.expectEqual(@as(?Class, null), classify(setTruncated(&nx_buf), 3600));
}

test "classify caches NXDOMAIN with the SOA minimum" {
    const class = classify(nxdomain_bytes, 3600).?;
    try testing.expectEqual(@as(u32, 600), class.ttl_seconds);
    try testing.expectEqual(true, class.negative);
}

test "classify caps a negative ttl at negative_ttl_max" {
    const class = classify(nxdomain_bytes, 120).?;
    try testing.expectEqual(@as(u32, 120), class.ttl_seconds);
    try testing.expectEqual(true, class.negative);
}

test "negative_ttl_max of zero disables negative caching" {
    try testing.expectEqual(@as(?Class, null), classify(nxdomain_bytes, 0));
    try testing.expectEqual(@as(?Class, null), classify(nodata_bytes, 0));

    // The switch is negative-only: a positive answer still caches.
    var buf: [512]u8 = undefined;
    const response = try buildAnswer(&buf, &.{60});
    try testing.expectEqual(@as(u32, 60), classify(response, 0).?.ttl_seconds);
}

/// The SOA record's TTL starts after the 12-byte header, the 17-byte question,
/// and the record's compressed name, type and class. MINIMUM is the last field
/// of the RDATA, which ends the message.
const nxdomain_soa_ttl_offset = 12 + 17 + 2 + 2 + 2;
const nxdomain_soa_minimum_offset = nxdomain_bytes.len - 4;

fn nxdomainWithSoaTtls(
    buf: *[nxdomain_bytes.len]u8,
    record_ttl: u32,
    minimum: u32,
) []const u8 {
    @memcpy(buf, nxdomain_bytes);
    std.mem.writeInt(u32, buf[nxdomain_soa_ttl_offset..][0..4], record_ttl, .big);
    std.mem.writeInt(u32, buf[nxdomain_soa_minimum_offset..][0..4], minimum, .big);
    return buf;
}

test "classify takes the smaller of the SOA record ttl and MINIMUM" {
    var buf: [nxdomain_bytes.len]u8 = undefined;

    // The rewrite reproduces the fixture when it writes the fixture's values.
    try testing.expectEqual(
        @as(u32, 600),
        classify(nxdomainWithSoaTtls(&buf, 3600, 600), 3600).?.ttl_seconds,
    );

    // RFC 2308 §5: the record TTL bounds the negative lifetime too.
    try testing.expectEqual(
        @as(u32, 120),
        classify(nxdomainWithSoaTtls(&buf, 120, 600), 3600).?.ttl_seconds,
    );
    try testing.expectEqual(
        @as(u32, 300),
        classify(nxdomainWithSoaTtls(&buf, 3600, 300), 3600).?.ttl_seconds,
    );

    // negative_ttl_max still caps whichever of the two won.
    try testing.expectEqual(
        @as(u32, 60),
        classify(nxdomainWithSoaTtls(&buf, 120, 600), 60).?.ttl_seconds,
    );

    // A zero in either field means do not cache.
    try testing.expectEqual(@as(?Class, null), classify(nxdomainWithSoaTtls(&buf, 0, 600), 3600));
    try testing.expectEqual(@as(?Class, null), classify(nxdomainWithSoaTtls(&buf, 3600, 0), 3600));
}

test "classify refuses an answer ttl with the top bit set" {
    // RFC 2181 §8 reads such a TTL as zero, and a zero TTL is not cached.
    var buf: [512]u8 = undefined;
    try testing.expectEqual(
        @as(?Class, null),
        classify(try buildAnswer(&buf, &.{0x8000_0001}), 3600),
    );
    try testing.expectEqual(
        @as(?Class, null),
        classify(try buildAnswer(&buf, &.{0xffff_ffff}), 3600),
    );

    // One poisoned record spoils the message even beside an ordinary TTL.
    try testing.expectEqual(
        @as(?Class, null),
        classify(try buildAnswer(&buf, &.{ 300, 0x8000_0000 }), 3600),
    );
}

test "classify refuses an SOA ttl or MINIMUM with the top bit set" {
    var buf: [nxdomain_bytes.len]u8 = undefined;
    try testing.expectEqual(
        @as(?Class, null),
        classify(nxdomainWithSoaTtls(&buf, 0x8000_0001, 600), 3600),
    );
    try testing.expectEqual(
        @as(?Class, null),
        classify(nxdomainWithSoaTtls(&buf, 3600, 0x8000_0001), 3600),
    );
}

test "classify treats an empty NOERROR answer as negative and needs an SOA" {
    const class = classify(nodata_bytes, 3600).?;
    try testing.expectEqual(@as(u32, 600), class.ttl_seconds);
    try testing.expectEqual(true, class.negative);

    // The same NODATA without the authority section caches nothing.
    const no_soa = "\x00\x01\x81\x80\x00\x01\x00\x00\x00\x00\x00\x00" ++
        "\x07example\x03com\x00\x00\x01\x00\x01";
    try testing.expectEqual(@as(?Class, null), classify(no_soa, 3600));
}

test "classify refuses every other rcode" {
    try testing.expectEqual(@as(?Class, null), classify(servfail_bytes, 3600));

    // REFUSED, with an SOA present, is still not cacheable.
    var refused = nxdomain_bytes.*;
    refused[3] = 0x85;
    try testing.expectEqual(@as(?Class, null), classify(&refused, 3600));
}

test "put and get round-trip a response with aged ttls" {
    var cache = try DnsCache.init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
    defer cache.deinit();

    var response_buf: [512]u8 = undefined;
    const response = try buildAnswer(&response_buf, &.{300});
    const class = classify(response, 3600).?;

    var key_buf: [max_key_len]u8 = undefined;
    const key = buildKey(&key_buf, "example.com", 1, 1, false, null);

    try cache.put(1000, key, response, class);
    try testing.expectEqual(@as(u32, 1), cache.len());

    var out: [512]u8 = undefined;
    const hit = cache.get(1030, key, &out).?;
    try testing.expectEqual(response.len, hit.len);
    try testing.expectEqual(@as(u32, 270), try firstAnswerTtl(hit));
    try testing.expectEqual(@as(u64, 1), cache.stats.hits);

    // The stored copy is untouched, so a later hit ages from the same base.
    const second = cache.get(1100, key, &out).?;
    try testing.expectEqual(@as(u32, 200), try firstAnswerTtl(second));

    // A key that was never stored is a miss.
    var other_buf: [max_key_len]u8 = undefined;
    const other = buildKey(&other_buf, "example.org", 1, 1, false, null);
    try testing.expectEqual(@as(?[]u8, null), cache.get(1030, other, &out));
    try testing.expectEqual(@as(u64, 1), cache.stats.misses);
}

test "a cached positive response caps its stored ttls at the sanity ceiling" {
    var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 });
    defer cache.deinit();

    var response_buf: [512]u8 = undefined;
    const response = try buildAnswer(&response_buf, &.{ 1_000_000, 300 });
    const class = classify(response, 3600).?;
    try testing.expectEqual(@as(u32, 300), class.ttl_seconds);

    var key_buf: [max_key_len]u8 = undefined;
    const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
    try cache.put(1000, key, response, class);

    // The over-ceiling record comes back at the ceiling; its shorter sibling is
    // untouched, because a downstream cache holds each record for its own TTL.
    var out: [512]u8 = undefined;
    var ttls: [4]u32 = undefined;
    try testing.expectEqualSlices(
        u32,
        &.{ max_ttl_seconds, 300 },
        try answerTtls(cache.get(1000, key, &out).?, &ttls),
    );

    // Only the cache's clone is rewritten; the caller's bytes are untouched.
    try testing.expectEqualSlices(u32, &.{ 1_000_000, 300 }, try answerTtls(response, &ttls));
}

test "a positive response under the ceiling is stored byte for byte" {
    var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 });
    defer cache.deinit();

    var response_buf: [512]u8 = undefined;
    const response = try buildAnswer(&response_buf, &.{ max_ttl_seconds, 300 });

    var key_buf: [max_key_len]u8 = undefined;
    const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
    try cache.put(1000, key, response, classify(response, 3600).?);

    // A TTL exactly at the ceiling is not rewritten, so a hit at elapsed zero
    // returns the caller's bytes unchanged.
    var out: [512]u8 = undefined;
    try testing.expectEqualSlices(u8, response, cache.get(1000, key, &out).?);
}

test "a cached negative response serves the SOA ttl the entry expires at" {
    var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 });
    defer cache.deinit();

    // The fixture's SOA record TTL is 3600 and its MINIMUM is 600, so the entry
    // outlives its own SOA TTL unless `put` rewrites the clone.
    try testing.expectEqual(@as(u32, 3600), try firstAuthoritySoaTtl(nxdomain_bytes));
    const class = classify(nxdomain_bytes, 3600).?;
    try testing.expectEqual(@as(u32, 600), class.ttl_seconds);

    var key_buf: [max_key_len]u8 = undefined;
    const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
    try cache.put(1000, key, nxdomain_bytes, class);

    var out: [512]u8 = undefined;
    const fresh = cache.get(1000, key, &out).?;
    try testing.expectEqual(@as(u32, 600), try firstAuthoritySoaTtl(fresh));

    // The clamped TTL then ages with the entry and runs out as the entry does.
    const later = cache.get(1500, key, &out).?;
    try testing.expectEqual(@as(u32, 100), try firstAuthoritySoaTtl(later));
    try testing.expectEqual(@as(?[]u8, null), cache.get(1600, key, &out));

    // Only the cache's clone is rewritten; the caller's bytes are untouched.
    try testing.expectEqual(@as(u32, 3600), try firstAuthoritySoaTtl(nxdomain_bytes));
}

test "the stored SOA ttl follows negative_ttl_max and is never raised" {
    var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 120 });
    defer cache.deinit();

    var key_buf: [max_key_len]u8 = undefined;
    const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
    var out: [512]u8 = undefined;

    try cache.put(0, key, nxdomain_bytes, classify(nxdomain_bytes, 120).?);
    try testing.expectEqual(@as(u32, 120), try firstAuthoritySoaTtl(cache.get(0, key, &out).?));

    // An SOA record TTL already below the entry's lifetime keeps its own value.
    var short_buf: [nxdomain_bytes.len]u8 = undefined;
    const short = nxdomainWithSoaTtls(&short_buf, 60, 600);
    try cache.put(0, key, short, classify(short, 3600).?);
    try testing.expectEqual(@as(u32, 60), try firstAuthoritySoaTtl(cache.get(0, key, &out).?));

    // A positive response has no SOA to clamp and keeps every answer TTL.
    var answer_buf: [512]u8 = undefined;
    const answer = try buildAnswer(&answer_buf, &.{ 300, 900 });
    try cache.put(0, key, answer, classify(answer, 3600).?);
    try testing.expectEqual(@as(u32, 300), try firstAnswerTtl(cache.get(0, key, &out).?));
}

test "an out buffer too small for the response is a miss" {
    var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 });
    defer cache.deinit();

    var response_buf: [512]u8 = undefined;
    const response = try buildAnswer(&response_buf, &.{300});
    var key_buf: [max_key_len]u8 = undefined;
    const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
    try cache.put(0, key, response, classify(response, 3600).?);

    var small: [8]u8 = undefined;
    try testing.expectEqual(@as(?[]u8, null), cache.get(0, key, &small));
    try testing.expectEqual(@as(u64, 1), cache.stats.misses);
    // The entry survives a short buffer.
    try testing.expectEqual(@as(u32, 1), cache.len());
}

test "an expired entry is a miss and is freed on access" {
    var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 });
    defer cache.deinit();

    var response_buf: [512]u8 = undefined;
    const response = try buildAnswer(&response_buf, &.{60});
    var key_buf: [max_key_len]u8 = undefined;
    const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
    try cache.put(1000, key, response, classify(response, 3600).?);

    var out: [512]u8 = undefined;
    try testing.expect(cache.get(1059, key, &out) != null);
    // The entry expires at stored_at + ttl, and that second is already too late.
    try testing.expectEqual(@as(?[]u8, null), cache.get(1060, key, &out));
    try testing.expectEqual(@as(u32, 0), cache.len());
    try testing.expectEqual(@as(u64, 1), cache.stats.expirations);
    try testing.expectEqual(@as(u64, 1), cache.stats.misses);
}

test "aging that breaks the packet drops the entry once" {
    // The first record's TTL is 82 176, whose last three bytes read as the name
    // "A." at offset 18, and the second record's owner name points there:
    // ageing that TTL by one second leaves 0xff where the name ended, which is
    // an out-of-range compression pointer. packet.zig covers the same case with
    // a larger TTL; this one stays under `max_ttl_seconds` so that `put` stores
    // it verbatim and only the ageing inside `get` breaks it.
    const poisoned = "\x00\x01\x81\x80\x00\x00\x00\x02\x00\x00\x00\x00" ++
        "\x00\x00\x01\x00\x01\x00\x01\x41\x00\x00\x04\x01\x02\x03\x04" ++
        "\xc0\x12\x00\x01\x00\x01\x00\x00\x00\x64\x00\x04\x05\x06\x07\x08";

    var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 });
    defer cache.deinit();

    var key_buf: [max_key_len]u8 = undefined;
    const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
    try cache.put(0, key, poisoned, classify(poisoned, 3600).?);

    var out: [512]u8 = undefined;
    try testing.expectEqual(@as(?[]u8, null), cache.get(1, key, &out));
    try testing.expectEqual(@as(u64, 1), cache.stats.invalid_hits);
    try testing.expectEqual(@as(u64, 0), cache.stats.hits);
    try testing.expectEqual(@as(u32, 0), cache.len());
}

test "CLOCK evicts the unreferenced entry and keeps the referenced one" {
    var cache = try DnsCache.init(testing.allocator, .{ .size = 2, .negative_ttl_max = 3600 });
    defer cache.deinit();

    var response_buf: [512]u8 = undefined;
    const response = try buildAnswer(&response_buf, &.{3600});
    const class = classify(response, 3600).?;

    var buf_a: [max_key_len]u8 = undefined;
    var buf_b: [max_key_len]u8 = undefined;
    var buf_c: [max_key_len]u8 = undefined;
    const a = buildKey(&buf_a, "a.example.com", 1, 1, false, null);
    const b = buildKey(&buf_b, "b.example.com", 1, 1, false, null);
    const c = buildKey(&buf_c, "c.example.com", 1, 1, false, null);

    try cache.put(0, a, response, class);
    try cache.put(0, b, response, class);

    var out: [512]u8 = undefined;
    try testing.expect(cache.get(0, a, &out) != null);

    try cache.put(0, c, response, class);
    try testing.expectEqual(@as(u32, 2), cache.len());
    try testing.expectEqual(@as(u64, 1), cache.stats.evictions);
    try testing.expect(cache.get(0, a, &out) != null);
    try testing.expect(cache.get(0, c, &out) != null);
    try testing.expectEqual(@as(?[]u8, null), cache.get(0, b, &out));
}

test "replacing a key in place does not grow the cache" {
    var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 });
    defer cache.deinit();

    var first_buf: [512]u8 = undefined;
    var second_buf: [512]u8 = undefined;
    const first = try buildAnswer(&first_buf, &.{300});
    const second = try buildAnswer(&second_buf, &.{ 900, 900 });

    var key_buf: [max_key_len]u8 = undefined;
    const key = buildKey(&key_buf, "example.com", 1, 1, false, null);

    try cache.put(1000, key, first, classify(first, 3600).?);
    try cache.put(2000, key, second, classify(second, 3600).?);

    try testing.expectEqual(@as(u32, 1), cache.len());
    try testing.expectEqual(@as(u64, 2), cache.stats.inserts);
    try testing.expectEqual(@as(u64, 0), cache.stats.evictions);

    // The second response replaced the first, and its age counts from 2000.
    var out: [512]u8 = undefined;
    const hit = cache.get(2100, key, &out).?;
    try testing.expectEqual(second.len, hit.len);
    try testing.expectEqual(@as(u32, 800), try firstAnswerTtl(hit));
}

test "sweep removes only the expired entries" {
    var cache = try DnsCache.init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
    defer cache.deinit();

    var short_buf: [512]u8 = undefined;
    var long_buf: [512]u8 = undefined;
    const short = try buildAnswer(&short_buf, &.{60});
    const long = try buildAnswer(&long_buf, &.{3600});

    var buf_a: [max_key_len]u8 = undefined;
    var buf_b: [max_key_len]u8 = undefined;
    const a = buildKey(&buf_a, "short.example.com", 1, 1, false, null);
    const b = buildKey(&buf_b, "long.example.com", 1, 1, false, null);

    try cache.put(0, a, short, classify(short, 3600).?);
    try cache.put(0, b, long, classify(long, 3600).?);

    try testing.expectEqual(@as(u32, 0), cache.sweep(59));
    try testing.expectEqual(@as(u32, 2), cache.len());

    try testing.expectEqual(@as(u32, 1), cache.sweep(60));
    try testing.expectEqual(@as(u32, 1), cache.len());
    try testing.expectEqual(@as(u64, 1), cache.stats.expirations);

    var out: [512]u8 = undefined;
    try testing.expect(cache.get(60, b, &out) != null);
    // The survivor expires at stored_at + 3600, so that second sweeps it too.
    try testing.expectEqual(@as(u32, 1), cache.sweep(3600));
    try testing.expectEqual(@as(u32, 0), cache.len());
    try testing.expectEqual(@as(u64, 2), cache.stats.expirations);
}

test "a slot freed by eviction is reusable many times over" {
    // The index deletes and reinserts on every eviction; this fails loudly if
    // deletion ever stopped returning capacity to it.
    var cache = try DnsCache.init(testing.allocator, .{ .size = 2, .negative_ttl_max = 3600 });
    defer cache.deinit();

    var response_buf: [512]u8 = undefined;
    const response = try buildAnswer(&response_buf, &.{3600});
    const class = classify(response, 3600).?;

    var name_buf: [64]u8 = undefined;
    var key_buf: [max_key_len]u8 = undefined;
    for (0..500) |i| {
        const qname = try std.fmt.bufPrint(&name_buf, "host{d}.example.com", .{i});
        try cache.put(0, buildKey(&key_buf, qname, 1, 1, false, null), response, class);
        try testing.expect(cache.len() <= 2);
    }
    try testing.expectEqual(@as(u32, 2), cache.len());
    try testing.expectEqual(@as(u64, 498), cache.stats.evictions);
}

test "a cache of zero entries stores nothing" {
    var cache = try DnsCache.init(testing.allocator, .{ .size = 0, .negative_ttl_max = 3600 });
    defer cache.deinit();

    var response_buf: [512]u8 = undefined;
    const response = try buildAnswer(&response_buf, &.{300});
    var key_buf: [max_key_len]u8 = undefined;
    const key = buildKey(&key_buf, "example.com", 1, 1, false, null);

    try cache.put(0, key, response, classify(response, 3600).?);
    try testing.expectEqual(@as(u32, 0), cache.len());

    var out: [512]u8 = undefined;
    try testing.expectEqual(@as(?[]u8, null), cache.get(0, key, &out));
}

test "memoryBytes covers the slots, the responses and the index" {
    var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 });
    defer cache.deinit();

    const empty_bytes = cache.memoryBytes();
    try testing.expect(empty_bytes >= 4 * @sizeOf(Slot));

    var response_buf: [512]u8 = undefined;
    const response = try buildAnswer(&response_buf, &.{300});
    var key_buf: [max_key_len]u8 = undefined;
    const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
    try cache.put(0, key, response, classify(response, 3600).?);

    try testing.expectEqual(empty_bytes + response.len, cache.memoryBytes());
}

fn initAndPut(gpa: Allocator, response: []const u8) !void {
    var cache = try DnsCache.init(gpa, .{ .size = 16, .negative_ttl_max = 3600 });
    defer cache.deinit();

    var key_buf: [max_key_len]u8 = undefined;
    const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
    try cache.put(0, key, response, classify(response, 3600).?);
    try cache.put(0, key, response, classify(response, 3600).?);
}

test "init and put survive every allocation failure" {
    var response_buf: [512]u8 = undefined;
    const response = try buildAnswer(&response_buf, &.{300});
    try testing.checkAllAllocationFailures(testing.allocator, initAndPut, .{response});
}
