//! One group's explicit rules (PLAN §3.10 levels 1–4), compiled once into an
//! immutable form the query path can read without allocating.
//!
//! Exact patterns go into a `DomainSet`; wildcard patterns stay a flat, sorted
//! array of strings and are scanned linearly. Operator-authored wildcards are
//! few — `max_wildcards_per_group` caps them at 4096 — and a linear scan over
//! that many short patterns is cheaper than an index that would have to be
//! rebuilt on every snapshot swap.
//!
//! Pure: an allocator and plain values, no `std.Io`, no clock, no entropy
//! source. The hash seed arrives as a parameter.

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

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

pub const Error = error{ OutOfMemory, BadPattern, TooManyWildcards } || domain_set.DomainSet.Error;

/// Both wildcard lists of one group together. The cap exists so a rules table
/// edited into the millions cannot turn every query into a linear scan.
pub const max_wildcards_per_group: usize = 4096;

pub const RuleSet = struct {
    exact_allow: domain_set.DomainSet = .empty,
    exact_block: domain_set.DomainSet = .empty,
    /// Normalized and sorted, so a rebuild of the same rows produces the same
    /// order and the same first match.
    wildcard_allow: []const []const u8 = &.{},
    wildcard_block: []const []const u8 = &.{},
    /// One block holding the bytes of both wildcard lists; freed as a unit.
    wildcard_bytes: []const u8 = &.{},

    pub const empty: RuleSet = .{};

    /// `rows` are one group's rules only; splitting `listRules` output by group
    /// belongs to the caller, which is the only holder of the group table.
    ///
    /// Patterns are normalized (lowercase over ASCII, one trailing dot
    /// stripped) and validated: `.exact` through `dns.name.fromText`,
    /// `.wildcard` through `wildcard.validate`. An invalid pattern is
    /// `error.BadPattern`, not a skipped row — every pattern passed
    /// `config/validate.zig` on the way in, so an invalid one here means the
    /// rows were edited underneath nxdns and a silently dropped allow rule
    /// would block a domain the operator unblocked.
    pub fn build(gpa: Allocator, rows: []const model.Rule, seed: u64) Error!RuleSet {
        if (rows.len == 0) return .empty;

        var scratch: std.ArrayList(u8) = .empty;
        defer scratch.deinit(gpa);
        var spans: [4]std.ArrayList(Span) = .{ .empty, .empty, .empty, .empty };
        defer for (&spans) |*bucket| bucket.deinit(gpa);

        var wildcards: usize = 0;
        var buf: [types.max_name_len]u8 = undefined;
        for (rows) |row| {
            const pattern = normalize(row.pattern, &buf) catch return error.BadPattern;
            switch (row.kind) {
                .exact => _ = name.fromText(pattern) catch return error.BadPattern,
                .wildcard => {
                    wildcard.validate(pattern) catch return error.BadPattern;
                    wildcards += 1;
                    if (wildcards > max_wildcards_per_group) return error.TooManyWildcards;
                },
            }
            const bucket = &spans[bucketOf(row.kind, row.action)];
            try bucket.append(gpa, .{ .offset = scratch.items.len, .len = pattern.len });
            try scratch.appendSlice(gpa, pattern);
        }

        // `scratch` stops growing here, so spans can become slices of it.
        var sorted: [4]std.ArrayList([]const u8) = .{ .empty, .empty, .empty, .empty };
        defer for (&sorted) |*bucket| bucket.deinit(gpa);
        for (&spans, &sorted) |*bucket, *out| {
            try out.ensureTotalCapacityPrecise(gpa, bucket.items.len);
            for (bucket.items) |span| {
                out.appendAssumeCapacity(scratch.items[span.offset..][0..span.len]);
            }
            std.mem.sort([]const u8, out.items, {}, lessThanBytes);
            dedupSorted(out);
        }

        var self: RuleSet = .empty;
        errdefer self.deinit(gpa);

        self.exact_allow = try buildSet(gpa, sorted[bucketOf(.exact, .allow)].items, seed);
        self.exact_block = try buildSet(gpa, sorted[bucketOf(.exact, .block)].items, seed);

        const allow = sorted[bucketOf(.wildcard, .allow)].items;
        const block = sorted[bucketOf(.wildcard, .block)].items;
        var total: usize = 0;
        for (allow) |pattern| total += pattern.len;
        for (block) |pattern| total += pattern.len;

        const bytes = try gpa.alloc(u8, total);
        self.wildcard_bytes = bytes;
        var at: usize = 0;
        self.wildcard_allow = try copyPatterns(gpa, allow, bytes, &at);
        self.wildcard_block = try copyPatterns(gpa, block, bytes, &at);

        return self;
    }

    pub fn deinit(self: *RuleSet, gpa: Allocator) void {
        self.exact_allow.deinit(gpa);
        self.exact_block.deinit(gpa);
        gpa.free(self.wildcard_allow);
        gpa.free(self.wildcard_block);
        gpa.free(self.wildcard_bytes);
        self.* = .empty;
    }

    pub fn memoryBytes(self: *const RuleSet) usize {
        return self.exact_allow.memoryBytes() +
            self.exact_block.memoryBytes() +
            self.wildcard_bytes.len +
            (self.wildcard_allow.len + self.wildcard_block.len) * @sizeOf([]const u8);
    }
};

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

/// Patterns are recorded as offsets because `scratch` reallocates while it
/// grows; they become slices only after the collecting pass ends.
const Span = struct { offset: usize, len: usize };

fn bucketOf(kind: model.RuleKind, action: model.RuleAction) usize {
    const kind_bit: usize = switch (kind) {
        .exact => 0,
        .wildcard => 2,
    };
    const action_bit: usize = switch (action) {
        .allow => 0,
        .block => 1,
    };
    return kind_bit + action_bit;
}

fn lessThanBytes(_: void, a: []const u8, b: []const u8) bool {
    return std.mem.order(u8, a, b) == .lt;
}

/// Duplicate rows are removed rather than rejected: two identical block rules
/// are not a corrupt database, and `DomainSet.build` requires a strictly
/// ascending body.
fn dedupSorted(list: *std.ArrayList([]const u8)) void {
    var kept: usize = 0;
    for (list.items) |item| {
        if (kept > 0 and std.mem.eql(u8, list.items[kept - 1], item)) continue;
        list.items[kept] = item;
        kept += 1;
    }
    list.shrinkRetainingCapacity(kept);
}

fn buildSet(gpa: Allocator, patterns: []const []const u8, seed: u64) Error!domain_set.DomainSet {
    if (patterns.len == 0) return .empty;

    var body: std.ArrayList(u8) = .empty;
    defer body.deinit(gpa);
    for (patterns) |pattern| {
        try body.appendSlice(gpa, pattern);
        try body.append(gpa, '\n');
    }
    return domain_set.DomainSet.build(gpa, body.items, seed);
}

fn copyPatterns(
    gpa: Allocator,
    patterns: []const []const u8,
    bytes: []u8,
    at: *usize,
) Error![]const []const u8 {
    if (patterns.len == 0) return &.{};
    const out = try gpa.alloc([]const u8, patterns.len);
    for (out, patterns) |*slot, pattern| {
        @memcpy(bytes[at.*..][0..pattern.len], pattern);
        slot.* = bytes[at.*..][0..pattern.len];
        at.* += pattern.len;
    }
    return out;
}

const NameError = error{BadName};

/// Lowercases over ASCII and strips one trailing dot. A byte ≥ 0x80 is
/// rejected: query names reach the matcher ASCII-lowercased, so a pattern
/// carrying a high byte could never match anything.
///
/// Deliberately not `dns.name.normalizeText`: a pattern may hold `*`, which
/// `name.fromText` would reject, so this variant skips that check and rejects
/// control bytes and space instead.
fn normalize(text: []const u8, buf: *[types.max_name_len]u8) NameError![]const u8 {
    var rest = text;
    if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1];
    if (rest.len == 0 or rest.len > types.max_name_len) return error.BadName;

    for (rest, 0..) |byte, i| {
        if (byte >= 0x80 or byte < 0x21) return error.BadName;
        buf[i] = std.ascii.toLower(byte);
    }
    return buf[0..rest.len];
}

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

const testing = std.testing;

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

test "exact rules land in the matching set" {
    const rows = [_]model.Rule{
        rule("ads.example.com", .exact, .block),
        rule("good.example.com", .exact, .allow),
    };
    var set = try RuleSet.build(testing.allocator, &rows, 0x5eed);
    defer set.deinit(testing.allocator);

    try testing.expect(set.exact_block.contains("ads.example.com"));
    try testing.expect(!set.exact_block.contains("good.example.com"));
    try testing.expect(set.exact_allow.contains("good.example.com"));
    try testing.expectEqual(@as(usize, 0), set.wildcard_allow.len);
    try testing.expectEqual(@as(usize, 0), set.wildcard_block.len);
}

test "wildcard rules land in the matching list, sorted" {
    const rows = [_]model.Rule{
        rule("*.z.example.com", .wildcard, .block),
        rule("*.a.example.com", .wildcard, .block),
        rule("*.allowed.example.com", .wildcard, .allow),
    };
    var set = try RuleSet.build(testing.allocator, &rows, 0x5eed);
    defer set.deinit(testing.allocator);

    try testing.expectEqual(@as(usize, 2), set.wildcard_block.len);
    try testing.expectEqualStrings("*.a.example.com", set.wildcard_block[0]);
    try testing.expectEqualStrings("*.z.example.com", set.wildcard_block[1]);
    try testing.expectEqual(@as(usize, 1), set.wildcard_allow.len);
    try testing.expectEqualStrings("*.allowed.example.com", set.wildcard_allow[0]);
}

test "patterns are normalized to lowercase without a trailing dot" {
    const rows = [_]model.Rule{
        rule("ADS.Example.COM.", .exact, .block),
        rule("*.Tracker.NET.", .wildcard, .block),
    };
    var set = try RuleSet.build(testing.allocator, &rows, 0);
    defer set.deinit(testing.allocator);

    try testing.expect(set.exact_block.contains("ads.example.com"));
    try testing.expectEqualStrings("*.tracker.net", set.wildcard_block[0]);
}

test "duplicate rows collapse to one entry" {
    const rows = [_]model.Rule{
        rule("ads.example.com", .exact, .block),
        rule("ads.example.com.", .exact, .block),
        rule("*.x.example.com", .wildcard, .block),
        rule("*.x.example.com", .wildcard, .block),
    };
    var set = try RuleSet.build(testing.allocator, &rows, 0);
    defer set.deinit(testing.allocator);

    try testing.expectEqual(@as(u32, 1), set.exact_block.count);
    try testing.expectEqual(@as(usize, 1), set.wildcard_block.len);
}

test "an invalid exact pattern is an error" {
    for ([_][]const u8{ "", ".", "a..b", "ads example.com", "ads\u{00e9}.example.com" }) |pattern| {
        const rows = [_]model.Rule{rule(pattern, .exact, .block)};
        try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, &rows, 0));
    }
}

test "an invalid wildcard pattern is an error" {
    for ([_][]const u8{ "example.com", "ad*.example.com", "*..com" }) |pattern| {
        const rows = [_]model.Rule{rule(pattern, .wildcard, .block)};
        try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, &rows, 0));
    }
}

test "too many wildcards is an error" {
    const gpa = testing.allocator;
    const rows = try gpa.alloc(model.Rule, max_wildcards_per_group + 1);
    defer gpa.free(rows);

    var patterns: std.ArrayList([]u8) = .empty;
    defer {
        for (patterns.items) |p| gpa.free(p);
        patterns.deinit(gpa);
    }
    for (rows, 0..) |*row, i| {
        const pattern = try std.fmt.allocPrint(gpa, "*.n{d}.example.com", .{i});
        try patterns.append(gpa, pattern);
        row.* = rule(pattern, .wildcard, .block);
    }

    try testing.expectError(error.TooManyWildcards, RuleSet.build(gpa, rows, 0));
}

test "an empty rule list builds the empty set" {
    var set = try RuleSet.build(testing.allocator, &[_]model.Rule{}, 0);
    defer set.deinit(testing.allocator);

    try testing.expect(!set.exact_block.contains("ads.example.com"));
    try testing.expectEqual(@as(usize, 0), set.memoryBytes());
}

test "the empty rule set owns nothing" {
    var set: RuleSet = .empty;
    try testing.expect(!set.exact_allow.contains("x.example.com"));
    try testing.expectEqual(@as(usize, 0), set.memoryBytes());
    set.deinit(testing.allocator);
}

test "memoryBytes counts every part" {
    const rows = [_]model.Rule{
        rule("ads.example.com", .exact, .block),
        rule("*.tracker.net", .wildcard, .block),
    };
    var set = try RuleSet.build(testing.allocator, &rows, 0);
    defer set.deinit(testing.allocator);

    try testing.expect(set.memoryBytes() > set.exact_block.memoryBytes());
    try testing.expect(set.memoryBytes() >= "*.tracker.net".len);
}

fn buildUnderFailure(gpa: Allocator) !void {
    const rows = [_]model.Rule{
        rule("ads.example.com", .exact, .block),
        rule("good.example.com", .exact, .allow),
        rule("*.tracker.net", .wildcard, .block),
        rule("*.ok.tracker.net", .wildcard, .allow),
    };
    var set = try RuleSet.build(gpa, &rows, 0x5eed);
    defer set.deinit(gpa);
    try testing.expect(set.exact_block.contains("ads.example.com"));
    try testing.expectEqualStrings("*.tracker.net", set.wildcard_block[0]);
}

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