//! The filtering decision (PLAN §3.10, §7.1, §7.2): query-name normalization,
//! the candidate chain, and the immutable snapshot every query is evaluated
//! against.
//!
//! `Snapshot.evaluate` allocates nothing, opens nothing and reads no clock. It
//! is callable from an `std.Io` task holding one stack buffer and the reader
//! lock its caller already took.
//!
//! The decision is keyed on `{domain, group}`. The qtype travels with the query
//! for logging and for response synthesis; no step of §3.10 reads it, so it is
//! not a parameter here.

const std = @import("std");
const Allocator = std.mem.Allocator;

const model = @import("../config/model.zig");
const address = @import("../platform/address.zig");
const name = @import("../dns/name.zig");
const types = @import("../dns/types.zig");
const domain_set = @import("domain_set.zig");
const rules = @import("rules.zig");
const wildcard = @import("wildcard.zig");

pub const Reason = enum {
    none,
    rule_allow_exact,
    rule_block_exact,
    rule_allow_wildcard,
    rule_block_wildcard,
    rule_allow_regex,
    rule_block_regex,
    /// An `@@` exception from a downloaded list. It cancels what another list
    /// blocks and never what a rule decides — see `evaluate`.
    blocklist_exception,
    blocklist_domain,
    blocklist_wildcard,
};

pub const Decision = struct {
    blocked: bool,
    reason: Reason,
    /// The candidate (for the exact, exception and blocklist levels) or the
    /// pattern (for the wildcard and regex levels) that decided it. Borrowed
    /// from the caller's normalized buffer or from the snapshot. "" when
    /// `reason == .none`.
    matched: []const u8,
    /// `.blocklist_*` only: index into `Snapshot.sources`, so the query log and
    /// the UI can name the list that blocked the query — or, for
    /// `.blocklist_exception`, the list that lifted it.
    source: ?u32 = null,
};

/// Lowercase ASCII, trailing dot stripped, written into `buf`. Returns a slice
/// of `buf`. The root name normalizes to "".
pub fn normalize(qname: name.Name, buf: *[types.max_name_len]u8) []const u8 {
    const wire = qname.wire();
    var at: usize = 0;
    var out: usize = 0;
    while (at < wire.len) {
        const label_len = wire[at];
        if (label_len == 0) break;
        if (out != 0) {
            buf[out] = '.';
            out += 1;
        }
        for (wire[at + 1 ..][0..label_len]) |byte| {
            buf[out] = std.ascii.toLower(byte);
            out += 1;
        }
        at += 1 + label_len;
    }
    return buf[0..out];
}

/// The full name, then each parent, ending at the last two-label suffix. The
/// TLD alone is not a candidate: a rule or a list entry on `com` is a
/// configuration mistake that would take the whole internet with it, and
/// refusing to walk that far costs nothing real.
pub const Candidates = struct {
    rest: []const u8,

    pub fn init(domain: []const u8) Candidates {
        return .{ .rest = domain };
    }

    pub fn next(self: *Candidates) ?[]const u8 {
        const dot = std.mem.indexOfScalar(u8, self.rest, '.') orelse {
            self.rest = "";
            return null;
        };
        const current = self.rest;
        self.rest = self.rest[dot + 1 ..];
        return current;
    }
};

pub const SourceSets = struct {
    /// `blocklist_sources` row id, so the manager can map a decision back to
    /// the source row.
    id: i64,
    /// Snapshot-arena-owned; the source's display name for the UI.
    name: []const u8,
    domains: domain_set.DomainSet,
    wildcards: domain_set.DomainSet,
    /// The names this source's `@@` exceptions lift. One entry covers the name
    /// and every subdomain of it, because `evaluate` walks the full name and
    /// each parent against this set.
    exceptions: domain_set.DomainSet,
};

pub const Group = struct {
    id: i64,
    name: []const u8,
    safe_search: bool,
    rules: rules.RuleSet,
    /// Indices into `Snapshot.sources`, ascending, deduplicated.
    sources: []const u32,
};

pub const ClientEntry = struct { key: address.NetAddress.Key, group: u32 };
pub const PrefixEntry = struct { prefix: address.Prefix, group: u32, priority: i32 };

pub const Snapshot = struct {
    arena: std.heap.ArenaAllocator,
    groups: []Group,
    sources: []SourceSets,
    clients: []ClientEntry,
    prefixes: []PrefixEntry,
    /// Index into `groups` of the group named "default". Always valid: `build`
    /// returns `error.MissingDefaultGroup` otherwise.
    default_group: u32,
    /// Monotonic, assigned by the manager. Logged on every swap so an operator
    /// can tell which generation answered a query.
    generation: u64,

    /// The compiled bodies of one source. `allow_body` defaults to empty
    /// because a source compiled before exceptions were honoured has no
    /// `.allow` file at all. A source refreshed since then always has one,
    /// empty when its list carries no `@@` line.
    pub const Compiled = struct {
        list_body: []const u8,
        wild_body: []const u8,
        allow_body: []const u8 = "",
    };

    pub const Input = struct {
        groups: []const model.Group,
        /// One `groups` row id per `groups[i]`, in the same order. The config
        /// model carries no ids, and a decision has to be mappable back to a
        /// database row, so the caller — which read both — supplies them.
        group_ids: []const i64,
        group_sources: []const model.GroupSource,
        sources: []const model.BlocklistSource,
        /// One `blocklist_sources` row id per `sources[i]`, in the same order.
        source_ids: []const i64,
        rules: []const model.Rule,
        clients: []const model.Client,
        prefixes: []const model.ClientPrefix,
        /// One entry per `sources[i]`, in the same order: the compiled bodies
        /// already read from disk with their headers stripped. `null` means the
        /// files were absent or unreadable; for an enabled source that is
        /// `error.MissingCompiledSource`, because a silently unenforced
        /// blocklist is exactly the failure PLAN §1.3 exists to prevent. A
        /// disabled source needs no entry read.
        compiled: []const ?Compiled,
        seed: u64,
        generation: u64,
    };

    pub const Error = error{
        OutOfMemory,
        MissingDefaultGroup,
        UnknownGroup,
        UnknownSource,
        MissingCompiledSource,
        BadClientIp,
        BadClientPrefix,
    } || rules.Error;

    /// Builds an immutable snapshot. Every string is copied into the arena, so
    /// the caller may free the repository lists immediately afterwards.
    /// Disabled sources are skipped entirely — they cost no memory.
    pub fn build(gpa: Allocator, input: Input) Error!Snapshot {
        std.debug.assert(input.group_ids.len == input.groups.len);
        std.debug.assert(input.source_ids.len == input.sources.len);

        var arena_state: std.heap.ArenaAllocator = .init(gpa);
        errdefer arena_state.deinit();
        const arena = arena_state.allocator();

        // `position[i]` is where `input.sources[i]` landed in `sources`, or
        // null when the source is disabled and therefore not loaded.
        const position = try arena.alloc(?u32, input.sources.len);
        var enabled: u32 = 0;
        for (input.sources, position) |row, *slot| {
            if (!row.enabled) {
                slot.* = null;
                continue;
            }
            slot.* = enabled;
            enabled += 1;
        }

        const sources = try arena.alloc(SourceSets, enabled);
        for (input.sources, input.source_ids, position, 0..) |row, id, slot, i| {
            const at = slot orelse continue;
            if (i >= input.compiled.len) return error.MissingCompiledSource;
            const bodies = input.compiled[i] orelse return error.MissingCompiledSource;
            sources[at] = .{
                .id = id,
                .name = try arena.dupe(u8, row.name),
                .domains = try domain_set.DomainSet.build(arena, bodies.list_body, input.seed),
                .wildcards = try domain_set.DomainSet.build(arena, bodies.wild_body, input.seed),
                .exceptions = try domain_set.DomainSet.build(arena, bodies.allow_body, input.seed),
            };
        }

        const groups = try arena.alloc(Group, input.groups.len);
        var group_rules: std.ArrayList(model.Rule) = .empty;
        defer group_rules.deinit(gpa);
        var group_sources: std.ArrayList(u32) = .empty;
        defer group_sources.deinit(gpa);

        for (groups, input.groups, input.group_ids) |*group, row, id| {
            group_rules.clearRetainingCapacity();
            for (input.rules) |rule_row| {
                if (std.mem.eql(u8, rule_row.group, row.name)) try group_rules.append(gpa, rule_row);
            }

            group_sources.clearRetainingCapacity();
            for (input.group_sources) |link| {
                if (!std.mem.eql(u8, link.group, row.name)) continue;
                const at = try findSource(input.sources, position, link.source_url);
                // A link to a disabled source is not an error: the source
                // exists, it is simply not loaded.
                if (at) |index| try group_sources.append(gpa, index);
            }
            std.mem.sort(u32, group_sources.items, {}, std.sort.asc(u32));

            group.* = .{
                .id = id,
                .name = try arena.dupe(u8, row.name),
                .safe_search = row.safe_search,
                // `arena` retains, `gpa` scratches: an arena reclaims only its
                // most recent allocation, so a rule build's temporaries taken
                // from it would outlive the build and go unreported by
                // `memoryBytes`.
                .rules = try rules.RuleSet.build(arena, gpa, group_rules.items, input.seed),
                .sources = try arena.dupe(u32, dedupSorted(group_sources.items)),
            };
        }

        // Every `group_sources` row must name a group that exists, whether or
        // not that group also owns rules.
        for (input.group_sources) |link| {
            if (indexOfGroup(groups, link.group) == null) return error.UnknownGroup;
        }

        const clients = try arena.alloc(ClientEntry, input.clients.len);
        for (clients, input.clients) |*entry, row| {
            const addr = address.NetAddress.parse(row.ip) catch return error.BadClientIp;
            entry.* = .{
                .key = addr.key(),
                .group = indexOfGroup(groups, row.group) orelse return error.UnknownGroup,
            };
        }

        const prefixes = try arena.alloc(PrefixEntry, input.prefixes.len);
        for (prefixes, input.prefixes) |*entry, row| {
            entry.* = .{
                .prefix = address.Prefix.parse(row.prefix) catch return error.BadClientPrefix,
                .group = indexOfGroup(groups, row.group) orelse return error.UnknownGroup,
                .priority = row.priority,
            };
        }

        return .{
            .arena = arena_state,
            .groups = groups,
            .sources = sources,
            .clients = clients,
            .prefixes = prefixes,
            .default_group = indexOfGroup(groups, "default") orelse return error.MissingDefaultGroup,
            .generation = input.generation,
        };
    }

    pub fn deinit(self: *Snapshot) void {
        self.arena.deinit();
        self.* = undefined;
    }

    /// PLAN §3.10 precedence, allow winning at equal specificity:
    ///   1. exact/parent allow rules   2. exact/parent block rules
    ///   3. wildcard allow rules       4. wildcard block rules
    ///   5. regex allow rules          6. regex block rules
    ///   7. blocklist exceptions
    ///   8. blocklist domains          9. blocklist wildcards
    ///
    /// The order is level-by-level over the whole candidate chain, not
    /// candidate-by-candidate over the levels: level 1 is checked against every
    /// candidate before level 2 is checked against any. That is what makes an
    /// allow rule on the parent beat a block rule on the child, which is the
    /// behaviour an allow list is written for.
    ///
    /// Levels 5 and 6 are last among the operator rules because they are the
    /// only ones that cost more than a hash lookup or a label walk: a regex is
    /// reached only once every set-shaped level has missed. They are matched
    /// against the full name alone — a pattern that should cover subdomains
    /// says so, which is what an unanchored regex already does.
    ///
    /// Level 7 is where a downloaded list's `@@` exceptions are honoured, and
    /// its position is the whole safety argument: every operator rule has
    /// already returned by the time it runs, so an exception can cancel a block
    /// levels 8 and 9 would have made and nothing else. No downloaded list can
    /// open an allow hole the operator did not open. It walks the full name and
    /// every parent, because one `@@||x^` entry lifts `x` together with its
    /// subdomains.
    ///
    /// Level 8 tests only the full name and level 9 tests only proper parents:
    /// a `.list` entry is the domain itself, a `.wild` entry is what `*.x.y`
    /// means. All three list levels walk the group's sources in ascending index
    /// order, so the reported source is stable for a given snapshot.
    ///
    /// `domain` is normalized (`normalize`). No allocation, no lock, no clock.
    pub fn evaluate(self: *const Snapshot, group: u32, domain: []const u8) Decision {
        const g = &self.groups[group];

        var level1: Candidates = .init(domain);
        while (level1.next()) |candidate| {
            if (g.rules.exact_allow.contains(candidate)) {
                return .{ .blocked = false, .reason = .rule_allow_exact, .matched = candidate };
            }
        }

        var level2: Candidates = .init(domain);
        while (level2.next()) |candidate| {
            if (g.rules.exact_block.contains(candidate)) {
                return .{ .blocked = true, .reason = .rule_block_exact, .matched = candidate };
            }
        }

        if (matchWildcard(g.rules.wildcard_allow, domain)) |pattern| {
            return .{ .blocked = false, .reason = .rule_allow_wildcard, .matched = pattern };
        }
        if (matchWildcard(g.rules.wildcard_block, domain)) |pattern| {
            return .{ .blocked = true, .reason = .rule_block_wildcard, .matched = pattern };
        }

        if (rules.matchRegex(g.rules.regex_allow, domain)) |pattern| {
            return .{ .blocked = false, .reason = .rule_allow_regex, .matched = pattern };
        }
        if (rules.matchRegex(g.rules.regex_block, domain)) |pattern| {
            return .{ .blocked = true, .reason = .rule_block_regex, .matched = pattern };
        }

        var exceptions: Candidates = .init(domain);
        while (exceptions.next()) |candidate| {
            for (g.sources) |index| {
                if (self.sources[index].exceptions.contains(candidate)) {
                    return .{
                        .blocked = false,
                        .reason = .blocklist_exception,
                        .matched = candidate,
                        .source = index,
                    };
                }
            }
        }

        for (g.sources) |index| {
            if (self.sources[index].domains.contains(domain)) {
                return .{
                    .blocked = true,
                    .reason = .blocklist_domain,
                    .matched = domain,
                    .source = index,
                };
            }
        }

        var parents: Candidates = .init(domain);
        // The full name is not a proper parent of itself.
        _ = parents.next();
        while (parents.next()) |parent| {
            for (g.sources) |index| {
                if (self.sources[index].wildcards.contains(parent)) {
                    return .{
                        .blocked = true,
                        .reason = .blocklist_wildcard,
                        .matched = parent,
                        .source = index,
                    };
                }
            }
        }

        return .{ .blocked = false, .reason = .none, .matched = "" };
    }

    /// PLAN §7.2 matching half: exact client row, else longest-prefix match
    /// (ties broken by longer prefix, then by the preferred `priority`, which
    /// is the lower number — the convention `upstreams.priority` already uses),
    /// else the default group. Inserting the unseen client row needs a clock
    /// and a database write on the query path and belongs to the handler.
    pub fn groupForClient(self: *const Snapshot, addr: address.NetAddress) u32 {
        const key = addr.key();
        for (self.clients) |entry| {
            if (std.mem.eql(u8, &entry.key, &key)) return entry.group;
        }
        if (address.matchLongest(PrefixEntry, self.prefixes, addr)) |entry| return entry.group;
        return self.default_group;
    }

    pub fn groupIndexById(self: *const Snapshot, id: i64) ?u32 {
        for (self.groups, 0..) |group, i| {
            if (group.id == id) return @intCast(i);
        }
        return null;
    }

    pub fn groupIndexByName(self: *const Snapshot, name_text: []const u8) ?u32 {
        return indexOfGroup(self.groups, name_text);
    }

    pub fn safeSearch(self: *const Snapshot, group: u32) bool {
        return self.groups[group].safe_search;
    }

    /// The structures this snapshot holds. The arena's own slack is excluded:
    /// this number is the regression guard on the compiled data, not a report
    /// on the allocator.
    pub fn memoryBytes(self: *const Snapshot) usize {
        var total: usize = 0;
        for (self.sources) |*source| {
            total += @sizeOf(SourceSets) + source.name.len +
                source.domains.memoryBytes() + source.wildcards.memoryBytes() +
                source.exceptions.memoryBytes();
        }
        for (self.groups) |*group| {
            total += @sizeOf(Group) + group.name.len +
                group.rules.memoryBytes() + group.sources.len * @sizeOf(u32);
        }
        total += self.clients.len * @sizeOf(ClientEntry);
        total += self.prefixes.len * @sizeOf(PrefixEntry);
        return total;
    }
};

// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------

fn matchWildcard(patterns: []const []const u8, domain: []const u8) ?[]const u8 {
    var it: Candidates = .init(domain);
    while (it.next()) |candidate| {
        for (patterns) |pattern| {
            if (wildcard.matches(pattern, candidate)) return pattern;
        }
    }
    return null;
}

fn indexOfGroup(groups: []const Group, group_name: []const u8) ?u32 {
    for (groups, 0..) |group, i| {
        if (std.mem.eql(u8, group.name, group_name)) return @intCast(i);
    }
    return null;
}

/// The position in the loaded `sources` array, or null when the named source
/// exists but is disabled. A URL naming no configured source is an error: the
/// group would silently enforce one list fewer than the operator configured.
fn findSource(
    rows: []const model.BlocklistSource,
    position: []const ?u32,
    url: []const u8,
) error{UnknownSource}!?u32 {
    for (rows, position) |row, slot| {
        if (std.mem.eql(u8, row.url, url)) return slot;
    }
    return error.UnknownSource;
}

fn dedupSorted(items: []u32) []const u32 {
    var kept: usize = 0;
    for (items) |item| {
        if (kept > 0 and items[kept - 1] == item) continue;
        items[kept] = item;
        kept += 1;
    }
    return items[0..kept];
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

const testing = std.testing;

const Fixture = struct {
    groups: []const model.Group = &.{.{ .name = "default" }},
    group_ids: []const i64 = &.{1},
    group_sources: []const model.GroupSource = &.{},
    sources: []const model.BlocklistSource = &.{},
    source_ids: []const i64 = &.{},
    compiled: []const ?Snapshot.Compiled = &.{},
    rules: []const model.Rule = &.{},
    clients: []const model.Client = &.{},
    prefixes: []const model.ClientPrefix = &.{},
    seed: u64 = 0x5eed,
};

fn build(gpa: Allocator, fixture: Fixture) Snapshot.Error!Snapshot {
    return Snapshot.build(gpa, .{
        .groups = fixture.groups,
        .group_ids = fixture.group_ids,
        .group_sources = fixture.group_sources,
        .sources = fixture.sources,
        .source_ids = fixture.source_ids,
        .rules = fixture.rules,
        .clients = fixture.clients,
        .prefixes = fixture.prefixes,
        .compiled = fixture.compiled,
        .seed = fixture.seed,
        .generation = 7,
    });
}

fn rule(pattern: []const u8, kind: model.RuleKind, action: model.RuleAction) model.Rule {
    return .{ .group = "default", .pattern = pattern, .kind = kind, .action = action };
}

// --- normalization and the candidate chain ---------------------------------

test "normalize lowercases, strips the trailing dot and empties the root" {
    var buf: [types.max_name_len]u8 = undefined;

    try testing.expectEqualStrings(
        "ads.example.com",
        normalize(try name.fromText("ADS.Example.COM."), &buf),
    );
    try testing.expectEqualStrings("", normalize(try name.fromText("."), &buf));
    try testing.expectEqualStrings("com", normalize(try name.fromText("com"), &buf));
}

test "the candidate chain stops before the TLD" {
    var it: Candidates = .init("a.b.example.com");
    try testing.expectEqualStrings("a.b.example.com", it.next().?);
    try testing.expectEqualStrings("b.example.com", it.next().?);
    try testing.expectEqualStrings("example.com", it.next().?);
    try testing.expect(it.next() == null);

    var single: Candidates = .init("com");
    try testing.expect(single.next() == null);

    var root: Candidates = .init("");
    try testing.expect(root.next() == null);
}

// --- precedence table (PLAN §3.10) -----------------------------------------

test "precedence: an exact block rule blocks its own name" {
    const rows = [_]model.Rule{rule("ads.example.com", .exact, .block)};
    var snapshot = try build(testing.allocator, .{ .rules = &rows });
    defer snapshot.deinit();

    const decision = snapshot.evaluate(0, "ads.example.com");
    try testing.expect(decision.blocked);
    try testing.expectEqual(Reason.rule_block_exact, decision.reason);
    try testing.expectEqualStrings("ads.example.com", decision.matched);
}

test "precedence: an exact block rule on a parent blocks the child" {
    const rows = [_]model.Rule{rule("example.com", .exact, .block)};
    var snapshot = try build(testing.allocator, .{ .rules = &rows });
    defer snapshot.deinit();

    const decision = snapshot.evaluate(0, "ads.example.com");
    try testing.expect(decision.blocked);
    try testing.expectEqual(Reason.rule_block_exact, decision.reason);
    try testing.expectEqualStrings("example.com", decision.matched);
}

test "precedence: an allow rule on the child beats a block rule on the parent" {
    const rows = [_]model.Rule{
        rule("example.com", .exact, .block),
        rule("ads.example.com", .exact, .allow),
    };
    var snapshot = try build(testing.allocator, .{ .rules = &rows });
    defer snapshot.deinit();

    const decision = snapshot.evaluate(0, "ads.example.com");
    try testing.expect(!decision.blocked);
    try testing.expectEqual(Reason.rule_allow_exact, decision.reason);
    try testing.expectEqualStrings("ads.example.com", decision.matched);
}

test "precedence: an allow rule on the parent beats a block rule on the child" {
    const rows = [_]model.Rule{
        rule("ads.example.com", .exact, .block),
        rule("example.com", .exact, .allow),
    };
    var snapshot = try build(testing.allocator, .{ .rules = &rows });
    defer snapshot.deinit();

    const decision = snapshot.evaluate(0, "ads.example.com");
    try testing.expect(!decision.blocked);
    try testing.expectEqual(Reason.rule_allow_exact, decision.reason);
    try testing.expectEqualStrings("example.com", decision.matched);
}

test "precedence: an exact block rule beats a wildcard allow rule" {
    const rows = [_]model.Rule{
        rule("*.example.com", .wildcard, .allow),
        rule("ads.example.com", .exact, .block),
    };
    var snapshot = try build(testing.allocator, .{ .rules = &rows });
    defer snapshot.deinit();

    const decision = snapshot.evaluate(0, "ads.example.com");
    try testing.expect(decision.blocked);
    try testing.expectEqual(Reason.rule_block_exact, decision.reason);
}

test "precedence: an allow wildcard beats an identical block wildcard" {
    const rows = [_]model.Rule{
        rule("*.example.com", .wildcard, .allow),
        rule("*.example.com", .wildcard, .block),
    };
    var snapshot = try build(testing.allocator, .{ .rules = &rows });
    defer snapshot.deinit();

    const decision = snapshot.evaluate(0, "a.example.com");
    try testing.expect(!decision.blocked);
    try testing.expectEqual(Reason.rule_allow_wildcard, decision.reason);
    try testing.expectEqualStrings("*.example.com", decision.matched);
}

test "precedence: a block wildcard with no allow blocks" {
    const rows = [_]model.Rule{rule("*.example.com", .wildcard, .block)};
    var snapshot = try build(testing.allocator, .{ .rules = &rows });
    defer snapshot.deinit();

    const decision = snapshot.evaluate(0, "a.example.com");
    try testing.expect(decision.blocked);
    try testing.expectEqual(Reason.rule_block_wildcard, decision.reason);
    try testing.expectEqualStrings("*.example.com", decision.matched);
}

test "precedence: a block regex with no allow blocks, and reports its pattern" {
    const rows = [_]model.Rule{rule("^ad[0-9]+-", .regex, .block)};
    var snapshot = try build(testing.allocator, .{ .rules = &rows });
    defer snapshot.deinit();

    const decision = snapshot.evaluate(0, "ad42-tracker.example.com");
    try testing.expect(decision.blocked);
    try testing.expectEqual(Reason.rule_block_regex, decision.reason);
    // `matched` is the pattern the operator wrote, which is what the query log
    // has to name for the block to be explicable.
    try testing.expectEqualStrings("^ad[0-9]+-", decision.matched);

    // Unanchored at the tail, anchored at the head: the digits must lead.
    try testing.expect(!snapshot.evaluate(0, "x.ad42-tracker.example.com").blocked);
    try testing.expect(!snapshot.evaluate(0, "ads.example.com").blocked);
}

test "precedence: an allow regex beats a block regex that matches the same name" {
    const rows = [_]model.Rule{
        rule("tracker", .regex, .block),
        rule("^good\\.", .regex, .allow),
    };
    var snapshot = try build(testing.allocator, .{ .rules = &rows });
    defer snapshot.deinit();

    const decision = snapshot.evaluate(0, "good.tracker.example.com");
    try testing.expect(!decision.blocked);
    try testing.expectEqual(Reason.rule_allow_regex, decision.reason);
    try testing.expectEqualStrings("^good\\.", decision.matched);

    try testing.expect(snapshot.evaluate(0, "bad.tracker.example.com").blocked);
}

test "precedence: both wildcard levels beat an allow regex that matches" {
    // The adjacent pair either side of the wildcard/regex boundary. A regex is
    // the most expensive level and therefore the last operator level, so a
    // wildcard decides first whichever way it decides.
    for ([_]model.Rule{
        rule("*.example.com", .wildcard, .allow),
        rule("*.example.com", .wildcard, .block),
    }) |wild| {
        const rows = [_]model.Rule{ wild, rule("example", .regex, .allow) };
        var snapshot = try build(testing.allocator, .{ .rules = &rows });
        defer snapshot.deinit();

        const decision = snapshot.evaluate(0, "a.example.com");
        try testing.expectEqual(wild.action == .block, decision.blocked);
        try testing.expect(decision.reason == .rule_allow_wildcard or
            decision.reason == .rule_block_wildcard);
    }
}

test "precedence: an exact allow rule beats a block regex" {
    const rows = [_]model.Rule{
        rule("tracker", .regex, .block),
        rule("good.tracker.example.com", .exact, .allow),
    };
    var snapshot = try build(testing.allocator, .{ .rules = &rows });
    defer snapshot.deinit();

    const decision = snapshot.evaluate(0, "good.tracker.example.com");
    try testing.expect(!decision.blocked);
    try testing.expectEqual(Reason.rule_allow_exact, decision.reason);
}

const one_source = [_]model.BlocklistSource{.{ .url = "https://lists.test/a", .name = "list a" }};
const one_source_id = [_]i64{11};
const one_link = [_]model.GroupSource{
    .{ .group = "default", .source_url = "https://lists.test/a" },
};

/// Holds the `compiled` array itself: a `Fixture` borrows it, so it has to
/// outlive the `build` call rather than live in a helper's frame.
const Lists = struct {
    compiled: [1]?Snapshot.Compiled,

    fn init(list_body: []const u8, wild_body: []const u8) Lists {
        return .{ .compiled = .{.{ .list_body = list_body, .wild_body = wild_body }} };
    }

    fn initWithExceptions(list_body: []const u8, wild_body: []const u8, allow_body: []const u8) Lists {
        return .{ .compiled = .{.{
            .list_body = list_body,
            .wild_body = wild_body,
            .allow_body = allow_body,
        }} };
    }

    fn fixture(self: *const Lists) Fixture {
        return .{
            .sources = &one_source,
            .source_ids = &one_source_id,
            .group_sources = &one_link,
            .compiled = &self.compiled,
        };
    }
};

test "precedence: a list entry blocks its own name" {
    const lists: Lists = .init("tracker.net\n", "");
    var snapshot = try build(testing.allocator, lists.fixture());
    defer snapshot.deinit();

    const decision = snapshot.evaluate(0, "tracker.net");
    try testing.expect(decision.blocked);
    try testing.expectEqual(Reason.blocklist_domain, decision.reason);
    try testing.expectEqualStrings("tracker.net", decision.matched);
    try testing.expectEqual(@as(?u32, 0), decision.source);
}

test "precedence: a list entry does not block a subdomain" {
    const lists: Lists = .init("tracker.net\n", "");
    var snapshot = try build(testing.allocator, lists.fixture());
    defer snapshot.deinit();

    const decision = snapshot.evaluate(0, "sub.tracker.net");
    try testing.expect(!decision.blocked);
    try testing.expectEqual(Reason.none, decision.reason);
}

test "precedence: a wild entry blocks a subdomain" {
    const lists: Lists = .init("", "tracker.net\n");
    var snapshot = try build(testing.allocator, lists.fixture());
    defer snapshot.deinit();

    const decision = snapshot.evaluate(0, "sub.tracker.net");
    try testing.expect(decision.blocked);
    try testing.expectEqual(Reason.blocklist_wildcard, decision.reason);
    try testing.expectEqualStrings("tracker.net", decision.matched);
    try testing.expectEqual(@as(?u32, 0), decision.source);
}

test "precedence: a wild entry does not block the apex" {
    const lists: Lists = .init("", "tracker.net\n");
    var snapshot = try build(testing.allocator, lists.fixture());
    defer snapshot.deinit();

    const decision = snapshot.evaluate(0, "tracker.net");
    try testing.expect(!decision.blocked);
    try testing.expectEqual(Reason.none, decision.reason);
}

test "precedence: an allow rule beats a wild entry" {
    const lists: Lists = .init("", "tracker.net\n");
    var fixture = lists.fixture();
    const rows = [_]model.Rule{rule("sub.tracker.net", .exact, .allow)};
    fixture.rules = &rows;

    var snapshot = try build(testing.allocator, fixture);
    defer snapshot.deinit();

    const decision = snapshot.evaluate(0, "sub.tracker.net");
    try testing.expect(!decision.blocked);
    try testing.expectEqual(Reason.rule_allow_exact, decision.reason);
}

// --- list exceptions (milestone 21 ruling 2) --------------------------------

/// The fixture ruling 2 is written against: one list that blocks `ads.example`
/// and its subdomains, and lifts `good.ads.example` back out.
const exception_lists: Lists = .initWithExceptions(
    "ads.example\n",
    "ads.example\n",
    "good.ads.example\n",
);

test "precedence: a list exception beats a list domain entry" {
    var snapshot = try build(testing.allocator, exception_lists.fixture());
    defer snapshot.deinit();

    // The apex is blocked by the `.list` entry; the excepted name is not, even
    // though the same source blocks it through `.wild`.
    try testing.expect(snapshot.evaluate(0, "ads.example").blocked);

    const decision = snapshot.evaluate(0, "good.ads.example");
    try testing.expect(!decision.blocked);
    try testing.expectEqual(Reason.blocklist_exception, decision.reason);
    try testing.expectEqualStrings("good.ads.example", decision.matched);
    try testing.expectEqual(@as(?u32, 0), decision.source);
}

test "precedence: a list exception beats a list domain entry on the same name" {
    // The boundary the fixture above cannot pin: `good.ads.example` is not in
    // its `.list` body, so that test compares the exception against the
    // wildcard level. Here one name is carried by both `.allow` and `.list`,
    // which is the only way level 7 and level 8 are reached by one query.
    const lists: Lists = .initWithExceptions(
        "good.ads.example\n",
        "",
        "good.ads.example\n",
    );
    var snapshot = try build(testing.allocator, lists.fixture());
    defer snapshot.deinit();

    const decision = snapshot.evaluate(0, "good.ads.example");
    try testing.expect(!decision.blocked);
    try testing.expectEqual(Reason.blocklist_exception, decision.reason);
    try testing.expectEqualStrings("good.ads.example", decision.matched);
    try testing.expectEqual(@as(?u32, 0), decision.source);
}

test "precedence: a list domain entry beats a list wildcard entry" {
    // Level 8 over level 9: the name is its own `.list` entry and a subdomain
    // of a `.wild` entry, so both would block and the reported reason is what
    // separates them.
    const lists: Lists = .init("x.ads.example\n", "ads.example\n");
    var snapshot = try build(testing.allocator, lists.fixture());
    defer snapshot.deinit();

    const decision = snapshot.evaluate(0, "x.ads.example");
    try testing.expect(decision.blocked);
    try testing.expectEqual(Reason.blocklist_domain, decision.reason);
    try testing.expectEqualStrings("x.ads.example", decision.matched);
    try testing.expectEqual(@as(?u32, 0), decision.source);
}

test "precedence: a list exception beats a list wildcard entry" {
    var snapshot = try build(testing.allocator, exception_lists.fixture());
    defer snapshot.deinit();

    try testing.expect(snapshot.evaluate(0, "x.ads.example").blocked);

    // The parent walk: one `@@||good.ads.example^` entry covers the subdomains
    // of the excepted name as well as the name itself.
    const decision = snapshot.evaluate(0, "y.good.ads.example");
    try testing.expect(!decision.blocked);
    try testing.expectEqual(Reason.blocklist_exception, decision.reason);
    try testing.expectEqualStrings("good.ads.example", decision.matched);
    try testing.expectEqual(@as(?u32, 0), decision.source);
}

test "precedence: an operator block rule beats a list exception" {
    // The property the exception level's position exists for: a downloaded list
    // may cancel what another list blocks and may never cancel what the
    // operator decided. All three operator block levels are checked, because
    // all three sit above the exception level.
    for ([_]model.Rule{
        rule("good.ads.example", .exact, .block),
        rule("*.ads.example", .wildcard, .block),
        rule("^good\\.ads\\.example$", .regex, .block),
    }) |blocking| {
        var fixture = exception_lists.fixture();
        const rows = [_]model.Rule{blocking};
        fixture.rules = &rows;

        var snapshot = try build(testing.allocator, fixture);
        defer snapshot.deinit();

        const decision = snapshot.evaluate(0, "good.ads.example");
        try testing.expect(decision.blocked);
        try testing.expect(decision.reason == .rule_block_exact or
            decision.reason == .rule_block_wildcard or
            decision.reason == .rule_block_regex);
    }
}

test "the regex reasons render as the wire strings the API and the query log carry" {
    // `web/handlers/lookup.zig` renders a reason as `@tagName`, and
    // `storage/logger.zig` stores one in a 32-byte `max_reason_len` buffer.
    // Neither can be reached from this file — pure core imports no web and no
    // storage — so the tag names and their length are pinned here.
    try testing.expectEqualStrings("rule_allow_regex", @tagName(Reason.rule_allow_regex));
    try testing.expectEqualStrings("rule_block_regex", @tagName(Reason.rule_block_regex));
    inline for (@typeInfo(Reason).@"enum".fields) |field| {
        try testing.expect(field.name.len <= 32);
    }
}

test "precedence: an allow regex beats every list level" {
    // The other side of the same boundary: an operator allow rule lifts a list
    // block, whichever of the three list levels made it.
    const rows = [_]model.Rule{rule("ads\\.example$", .regex, .allow)};
    var fixture = exception_lists.fixture();
    fixture.rules = &rows;

    var snapshot = try build(testing.allocator, fixture);
    defer snapshot.deinit();

    // `.list` blocks the apex and `.wild` blocks the subdomains; the regex
    // covers both, and answers before either is consulted.
    for ([_][]const u8{ "ads.example", "x.ads.example" }) |domain| {
        const decision = snapshot.evaluate(0, domain);
        try testing.expect(!decision.blocked);
        try testing.expectEqual(Reason.rule_allow_regex, decision.reason);
        try testing.expectEqual(@as(?u32, null), decision.source);
    }
}

test "precedence: a block regex blocks a name no list carries" {
    const rows = [_]model.Rule{rule("^ad[0-9]+-", .regex, .block)};
    var fixture = exception_lists.fixture();
    fixture.rules = &rows;

    var snapshot = try build(testing.allocator, fixture);
    defer snapshot.deinit();

    const decision = snapshot.evaluate(0, "ad7-cdn.other.example");
    try testing.expect(decision.blocked);
    try testing.expectEqual(Reason.rule_block_regex, decision.reason);
    try testing.expectEqual(@as(?u32, null), decision.source);
}

test "precedence: a list exception is scoped to the groups the source is in" {
    const groups = [_]model.Group{ .{ .name = "default" }, .{ .name = "kids" } };
    const ids = [_]i64{ 1, 2 };
    var snapshot = try build(testing.allocator, .{
        .groups = &groups,
        .group_ids = &ids,
        .sources = &one_source,
        .source_ids = &one_source_id,
        .group_sources = &one_link,
        .compiled = &exception_lists.compiled,
    });
    defer snapshot.deinit();

    const kids = snapshot.groupIndexByName("kids").?;
    try testing.expectEqual(
        Reason.blocklist_exception,
        snapshot.evaluate(snapshot.default_group, "good.ads.example").reason,
    );
    // `kids` is linked to no source, so neither the block nor the exception
    // reaches it.
    try testing.expectEqual(Reason.none, snapshot.evaluate(kids, "good.ads.example").reason);
}

test "precedence: an exception in one list lifts the block another list made" {
    const sources = [_]model.BlocklistSource{
        .{ .url = "https://lists.test/a", .name = "list a" },
        .{ .url = "https://lists.test/b", .name = "list b" },
    };
    const source_ids = [_]i64{ 11, 12 };
    const links = [_]model.GroupSource{
        .{ .group = "default", .source_url = "https://lists.test/a" },
        .{ .group = "default", .source_url = "https://lists.test/b" },
    };
    const compiled = [_]?Snapshot.Compiled{
        .{ .list_body = "ads.example\n", .wild_body = "ads.example\n" },
        .{ .list_body = "", .wild_body = "", .allow_body = "good.ads.example\n" },
    };

    var snapshot = try build(testing.allocator, .{
        .sources = &sources,
        .source_ids = &source_ids,
        .group_sources = &links,
        .compiled = &compiled,
    });
    defer snapshot.deinit();

    try testing.expect(snapshot.evaluate(0, "ads.example").blocked);

    const decision = snapshot.evaluate(0, "good.ads.example");
    try testing.expect(!decision.blocked);
    try testing.expectEqual(Reason.blocklist_exception, decision.reason);
    // The source reported is the one that lifted the block, not the one that
    // made it.
    try testing.expectEqual(@as(?u32, 1), decision.source);
}

test "precedence: nothing configured allows with reason none" {
    var snapshot = try build(testing.allocator, .{});
    defer snapshot.deinit();

    const decision = snapshot.evaluate(0, "example.com");
    try testing.expect(!decision.blocked);
    try testing.expectEqual(Reason.none, decision.reason);
    try testing.expectEqualStrings("", decision.matched);
    try testing.expect(decision.source == null);
}

test "precedence: a source assigned to one group does not filter another" {
    const groups = [_]model.Group{ .{ .name = "default" }, .{ .name = "kids" } };
    const ids = [_]i64{ 1, 2 };
    const links = [_]model.GroupSource{
        .{ .group = "kids", .source_url = "https://lists.test/a" },
    };
    var snapshot = try build(testing.allocator, .{
        .groups = &groups,
        .group_ids = &ids,
        .sources = &one_source,
        .source_ids = &one_source_id,
        .group_sources = &links,
        .compiled = &.{.{ .list_body = "tracker.net\n", .wild_body = "" }},
    });
    defer snapshot.deinit();

    const kids = snapshot.groupIndexByName("kids").?;
    try testing.expect(snapshot.evaluate(kids, "tracker.net").blocked);
    try testing.expect(!snapshot.evaluate(snapshot.default_group, "tracker.net").blocked);
}

test "precedence: a disabled source filters nothing" {
    const sources = [_]model.BlocklistSource{
        .{ .url = "https://lists.test/a", .name = "list a", .enabled = false },
    };
    var snapshot = try build(testing.allocator, .{
        .sources = &sources,
        .source_ids = &one_source_id,
        .group_sources = &one_link,
        .compiled = &.{null},
    });
    defer snapshot.deinit();

    try testing.expectEqual(@as(usize, 0), snapshot.sources.len);
    try testing.expect(!snapshot.evaluate(0, "tracker.net").blocked);
}

// --- group assignment ------------------------------------------------------

const two_groups = [_]model.Group{ .{ .name = "default" }, .{ .name = "kids" } };
const two_group_ids = [_]i64{ 1, 2 };

test "groupForClient matches an exact IPv4 client" {
    const clients = [_]model.Client{.{ .ip = "192.168.1.10", .group = "kids" }};
    var snapshot = try build(testing.allocator, .{
        .groups = &two_groups,
        .group_ids = &two_group_ids,
        .clients = &clients,
    });
    defer snapshot.deinit();

    const kids = snapshot.groupIndexByName("kids").?;
    try testing.expectEqual(kids, snapshot.groupForClient(try address.NetAddress.parse("192.168.1.10")));
}

test "groupForClient matches an exact IPv6 client through the canonical key" {
    const clients = [_]model.Client{.{ .ip = "fd00:0:0::1", .group = "kids" }};
    var snapshot = try build(testing.allocator, .{
        .groups = &two_groups,
        .group_ids = &two_group_ids,
        .clients = &clients,
    });
    defer snapshot.deinit();

    const kids = snapshot.groupIndexByName("kids").?;
    try testing.expectEqual(kids, snapshot.groupForClient(try address.NetAddress.parse("fd00::1")));
}

test "groupForClient matches a prefix" {
    const prefixes = [_]model.ClientPrefix{.{ .prefix = "192.168.1.0/24", .group = "kids" }};
    var snapshot = try build(testing.allocator, .{
        .groups = &two_groups,
        .group_ids = &two_group_ids,
        .prefixes = &prefixes,
    });
    defer snapshot.deinit();

    const kids = snapshot.groupIndexByName("kids").?;
    try testing.expectEqual(kids, snapshot.groupForClient(try address.NetAddress.parse("192.168.1.7")));
    try testing.expectEqual(
        snapshot.default_group,
        snapshot.groupForClient(try address.NetAddress.parse("192.168.2.7")),
    );
}

test "groupForClient prefers the longer prefix" {
    const prefixes = [_]model.ClientPrefix{
        .{ .prefix = "192.168.0.0/16", .group = "default" },
        .{ .prefix = "192.168.1.0/24", .group = "kids" },
    };
    var snapshot = try build(testing.allocator, .{
        .groups = &two_groups,
        .group_ids = &two_group_ids,
        .prefixes = &prefixes,
    });
    defer snapshot.deinit();

    const kids = snapshot.groupIndexByName("kids").?;
    try testing.expectEqual(kids, snapshot.groupForClient(try address.NetAddress.parse("192.168.1.7")));
}

test "groupForClient breaks a prefix tie by the preferred priority" {
    const prefixes = [_]model.ClientPrefix{
        .{ .prefix = "192.168.1.0/24", .group = "default", .priority = 100 },
        .{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 10 },
    };
    var snapshot = try build(testing.allocator, .{
        .groups = &two_groups,
        .group_ids = &two_group_ids,
        .prefixes = &prefixes,
    });
    defer snapshot.deinit();

    const kids = snapshot.groupIndexByName("kids").?;
    try testing.expectEqual(kids, snapshot.groupForClient(try address.NetAddress.parse("192.168.1.7")));
}

test "groupForClient prefers an exact client row over a prefix" {
    const clients = [_]model.Client{.{ .ip = "192.168.1.7", .group = "default" }};
    const prefixes = [_]model.ClientPrefix{.{ .prefix = "192.168.1.0/24", .group = "kids" }};
    var snapshot = try build(testing.allocator, .{
        .groups = &two_groups,
        .group_ids = &two_group_ids,
        .clients = &clients,
        .prefixes = &prefixes,
    });
    defer snapshot.deinit();

    try testing.expectEqual(
        snapshot.default_group,
        snapshot.groupForClient(try address.NetAddress.parse("192.168.1.7")),
    );
}

test "groupForClient falls back to the default group" {
    var snapshot = try build(testing.allocator, .{});
    defer snapshot.deinit();

    try testing.expectEqual(
        snapshot.default_group,
        snapshot.groupForClient(try address.NetAddress.parse("10.0.0.1")),
    );
}

// --- build errors ----------------------------------------------------------

test "build without a default group is an error" {
    const groups = [_]model.Group{.{ .name = "kids" }};
    const ids = [_]i64{2};
    try testing.expectError(
        error.MissingDefaultGroup,
        build(testing.allocator, .{ .groups = &groups, .group_ids = &ids }),
    );
}

test "build with a link to an unknown source is an error" {
    const links = [_]model.GroupSource{
        .{ .group = "default", .source_url = "https://lists.test/missing" },
    };
    try testing.expectError(error.UnknownSource, build(testing.allocator, .{
        .sources = &one_source,
        .source_ids = &one_source_id,
        .group_sources = &links,
        .compiled = &.{.{ .list_body = "", .wild_body = "" }},
    }));
}

test "build with a link from an unknown group is an error" {
    const links = [_]model.GroupSource{
        .{ .group = "ghosts", .source_url = "https://lists.test/a" },
    };
    try testing.expectError(error.UnknownGroup, build(testing.allocator, .{
        .sources = &one_source,
        .source_ids = &one_source_id,
        .group_sources = &links,
        .compiled = &.{.{ .list_body = "", .wild_body = "" }},
    }));
}

test "build with an enabled source that has no compiled bodies is an error" {
    try testing.expectError(error.MissingCompiledSource, build(testing.allocator, .{
        .sources = &one_source,
        .source_ids = &one_source_id,
        .group_sources = &one_link,
        .compiled = &.{null},
    }));
}

test "build with a bad client address is an error" {
    const clients = [_]model.Client{.{ .ip = "not-an-ip" }};
    try testing.expectError(
        error.BadClientIp,
        build(testing.allocator, .{ .clients = &clients }),
    );
}

test "build with a bad client prefix is an error" {
    const prefixes = [_]model.ClientPrefix{.{ .prefix = "192.168.1.0/33" }};
    try testing.expectError(
        error.BadClientPrefix,
        build(testing.allocator, .{ .prefixes = &prefixes }),
    );
}

test "build with a client in an unknown group is an error" {
    const clients = [_]model.Client{.{ .ip = "192.168.1.1", .group = "ghosts" }};
    try testing.expectError(
        error.UnknownGroup,
        build(testing.allocator, .{ .clients = &clients }),
    );
}

// --- snapshot properties ---------------------------------------------------

test "the snapshot owns copies of every input string" {
    const gpa = testing.allocator;

    const group_name = try gpa.dupe(u8, "default");
    const source_url = try gpa.dupe(u8, "https://lists.test/a");
    const source_name = try gpa.dupe(u8, "list a");
    const pattern = try gpa.dupe(u8, "ads.example.com");
    const body = try gpa.dupe(u8, "tracker.net\n");

    var snapshot = blk: {
        const groups = [_]model.Group{.{ .name = group_name }};
        const sources = [_]model.BlocklistSource{.{ .url = source_url, .name = source_name }};
        const links = [_]model.GroupSource{.{ .group = group_name, .source_url = source_url }};
        const rows = [_]model.Rule{
            .{ .group = group_name, .pattern = pattern, .kind = .exact, .action = .block },
        };
        break :blk try build(gpa, .{
            .groups = &groups,
            .group_ids = &.{1},
            .sources = &sources,
            .source_ids = &one_source_id,
            .group_sources = &links,
            .compiled = &.{.{ .list_body = body, .wild_body = "" }},
            .rules = &rows,
        });
    };
    defer snapshot.deinit();

    for ([_][]u8{ group_name, source_url, source_name, pattern, body }) |owned| {
        @memset(owned, 'x');
        gpa.free(owned);
    }

    try testing.expectEqualStrings("default", snapshot.groups[0].name);
    try testing.expectEqualStrings("list a", snapshot.sources[0].name);
    try testing.expect(snapshot.evaluate(0, "ads.example.com").blocked);
    try testing.expect(snapshot.evaluate(0, "tracker.net").blocked);
}

test "decisions do not depend on the seed" {
    const rows = [_]model.Rule{
        rule("ads.example.com", .exact, .block),
        rule("*.wild.example.com", .wildcard, .block),
        rule("ok.wild.example.com", .exact, .allow),
    };
    const lists: Lists = .init("tracker.net\n", "wildlist.net\n");
    var fixture = lists.fixture();
    fixture.rules = &rows;

    var a = try build(testing.allocator, fixture);
    defer a.deinit();
    fixture.seed = 0xdead_beef_cafe_f00d;
    var b = try build(testing.allocator, fixture);
    defer b.deinit();

    var buf: [64]u8 = undefined;
    var i: usize = 0;
    while (i < 40) : (i += 1) {
        const domain = try std.fmt.bufPrint(&buf, "n{d}.wild.example.com", .{i});
        const da = a.evaluate(0, domain);
        const db = b.evaluate(0, domain);
        try testing.expectEqual(da.blocked, db.blocked);
        try testing.expectEqual(da.reason, db.reason);
        try testing.expectEqualStrings(da.matched, db.matched);
    }
    for ([_][]const u8{ "ads.example.com", "ok.wild.example.com", "tracker.net", "x.wildlist.net", "unrelated.org" }) |domain| {
        const da = a.evaluate(0, domain);
        const db = b.evaluate(0, domain);
        try testing.expectEqual(da.blocked, db.blocked);
        try testing.expectEqual(da.reason, db.reason);
        try testing.expectEqualStrings(da.matched, db.matched);
    }
}

test "group lookup by id and by name" {
    var snapshot = try build(testing.allocator, .{
        .groups = &two_groups,
        .group_ids = &two_group_ids,
    });
    defer snapshot.deinit();

    try testing.expectEqual(@as(?u32, 0), snapshot.groupIndexById(1));
    try testing.expectEqual(@as(?u32, 1), snapshot.groupIndexById(2));
    try testing.expect(snapshot.groupIndexById(99) == null);
    try testing.expectEqual(@as(?u32, 1), snapshot.groupIndexByName("kids"));
    try testing.expect(snapshot.groupIndexByName("ghosts") == null);
    try testing.expectEqual(@as(u64, 7), snapshot.generation);
}

test "safeSearch reports the group setting" {
    const groups = [_]model.Group{
        .{ .name = "default" },
        .{ .name = "kids", .safe_search = true },
    };
    var snapshot = try build(testing.allocator, .{
        .groups = &groups,
        .group_ids = &two_group_ids,
    });
    defer snapshot.deinit();

    try testing.expect(!snapshot.safeSearch(0));
    try testing.expect(snapshot.safeSearch(1));
}

test "memoryBytes stays within the compiled-data bound" {
    const gpa = testing.allocator;
    const count = 10_000;

    var body: std.ArrayList(u8) = .empty;
    defer body.deinit(gpa);
    var line: [64]u8 = undefined;
    var i: usize = 0;
    while (i < count) : (i += 1) {
        try body.appendSlice(gpa, try std.fmt.bufPrint(&line, "d{d:0>5}.example.com\n", .{i}));
    }

    const lists: Lists = .init(body.items, "");
    var snapshot = try build(gpa, lists.fixture());
    defer snapshot.deinit();

    // arena + index, over both bodies, plus a kilobyte of struct overhead.
    const index_bytes = 2 * 16 * 1024 * @sizeOf(u32);
    try testing.expect(snapshot.memoryBytes() < 2 * body.items.len + index_bytes + 1024);
    try testing.expect(snapshot.evaluate(0, "d00042.example.com").blocked);
}

fn buildUnderFailure(gpa: Allocator) !void {
    const rows = [_]model.Rule{
        rule("ads.example.com", .exact, .block),
        rule("*.tracker.net", .wildcard, .block),
    };
    const clients = [_]model.Client{.{ .ip = "192.168.1.10" }};
    const prefixes = [_]model.ClientPrefix{.{ .prefix = "192.168.2.0/24" }};

    const lists: Lists = .init("tracker.net\n", "wildlist.net\n");
    var fixture = lists.fixture();
    fixture.rules = &rows;
    fixture.clients = &clients;
    fixture.prefixes = &prefixes;

    var snapshot = try build(gpa, fixture);
    defer snapshot.deinit();
    try testing.expect(snapshot.evaluate(0, "x.wildlist.net").blocked);
}

test "build leaks nothing under allocation failure" {
    try testing.checkAllAllocationFailures(testing.allocator, buildUnderFailure, .{});
}
