//! Compiles a downloaded blocklist into the two bodies nxdns stores on disk:
//! a `.list` body of exact names and a `.wild` body of suffixes.
//!
//! Pure over reader/writer interfaces: an allocator, a `*std.Io.Reader` and two
//! `*std.Io.Writer`. No `std.Io` value, no file, no clock. A compiled body is a
//! pure function of (bytes, format), which is what makes two runs — and two
//! permutations of the same input — byte-identical.
//!
//! Nothing but the sorted, deduplicated names is written: no header, no
//! timestamp, no counts. The header belongs to the caller, and the checksum
//! covers the two bodies only.

const std = @import("std");
const parsers = @import("parsers.zig");
const name = @import("../dns/name.zig");
const types = @import("../dns/types.zig");

const Sha256 = std.crypto.hash.sha2.Sha256;

pub const max_domains: u32 = 2_000_000;
pub const max_line_len: usize = 4096;

pub const Counts = struct {
    domains: u32 = 0,
    wildcards: u32 = 0,
    skipped_regex: u32 = 0,
    skipped_unsupported: u32 = 0,
    /// Not a valid domain name (`dns.name.fromText` rejected it, a non-ASCII
    /// byte, or fewer than two labels).
    invalid: u32 = 0,
    /// Lines longer than `max_line_len`, skipped whole.
    long_lines: u32 = 0,
    /// Duplicates removed by the sort/unique pass.
    duplicates: u32 = 0,
};

pub const Result = struct {
    counts: Counts,
    /// Lowercase hex sha256 over the `.list` body followed by the `.wild` body.
    checksum: [64]u8,
};

pub const Error = error{ OutOfMemory, TooManyDomains, ReadFailed, WriteFailed };

/// Reads `r` to end of stream and writes the two compiled bodies.
///
/// `counts.domains` and `counts.wildcards` are the written, deduplicated
/// counts: they are what `blocklist_sources.domain_count` and `wildcard_count`
/// store and what the UI shows.
pub fn compile(
    gpa: std.mem.Allocator,
    r: *std.Io.Reader,
    format: parsers.Format,
    list_w: *std.Io.Writer,
    wild_w: *std.Io.Writer,
) Error!Result {
    var counts: Counts = .{};

    var list: Entries = .{};
    defer list.deinit(gpa);
    var wild: Entries = .{};
    defer wild.deinit(gpa);

    while (try parsers.nextBoundedLine(r, max_line_len)) |event| {
        const raw = switch (event) {
            .long_line => {
                counts.long_lines += 1;
                continue;
            },
            .line => |line| line,
        };

        var line = raw;
        if (line.len != 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1];

        const parsed = parsers.parseLine(format, line);
        switch (parsed.kind) {
            .ignore => {},
            .regex => counts.skipped_regex += 1,
            .unsupported => counts.skipped_unsupported += 1,
            .domain => {
                var fields = std.mem.tokenizeAny(u8, parsed.text, &std.ascii.whitespace);
                while (fields.next()) |field| {
                    try addCandidate(gpa, field, false, false, &list, &wild, &counts);
                }
            },
            .wildcard => try addCandidate(
                gpa,
                parsed.text,
                true,
                parsed.covers_apex,
                &list,
                &wild,
                &counts,
            ),
        }
    }

    var hasher = Sha256.init(.{});
    counts.domains = try emit(&list, list_w, &hasher, &counts.duplicates);
    counts.wildcards = try emit(&wild, wild_w, &hasher, &counts.duplicates);

    var digest: [Sha256.digest_length]u8 = undefined;
    hasher.final(&digest);
    return .{ .counts = counts, .checksum = std.fmt.bytesToHex(digest, .lower) };
}

/// Normalizes one whitespace-separated candidate and files it under `.list`,
/// `.wild`, or neither.
///
/// The normalization below is deliberately not `dns.name.normalizeText`: this
/// one adds the two-label minimum, rejects control bytes, and reports every
/// rejection through `counts.invalid` rather than an error.
fn addCandidate(
    gpa: std.mem.Allocator,
    field: []const u8,
    from_wildcard_line: bool,
    covers_apex: bool,
    list: *Entries,
    wild: *Entries,
    counts: *Counts,
) Error!void {
    var candidate = field;
    var is_wildcard = from_wildcard_line;
    if (std.mem.startsWith(u8, candidate, "*.")) {
        is_wildcard = true;
        candidate = candidate[2..];
    }
    // A '*' anywhere else makes this a pattern, and patterns belong to the
    // `rules` table; a blocklist entry is a name or a suffix.
    if (std.mem.indexOfScalar(u8, candidate, '*') != null) {
        counts.invalid += 1;
        return;
    }

    if (candidate.len != 0 and candidate[candidate.len - 1] == '.') {
        candidate = candidate[0 .. candidate.len - 1];
    }
    if (candidate.len == 0 or candidate.len > types.max_name_len) {
        counts.invalid += 1;
        return;
    }

    var buf: [types.max_name_len]u8 = undefined;
    for (candidate, 0..) |c, i| {
        if (c >= 0x80 or std.ascii.isControl(c)) {
            counts.invalid += 1;
            return;
        }
        buf[i] = std.ascii.toLower(c);
    }
    const normalized = buf[0..candidate.len];

    const parsed_name = name.fromText(normalized) catch {
        counts.invalid += 1;
        return;
    };
    // The two-label minimum is what keeps `localhost`, `local`, `broadcasthost`
    // and the `ip6-*` names that every hosts list carries from black-holing the
    // loopback names of every client on the LAN.
    if (parsed_name.labelCount() < 2) {
        counts.invalid += 1;
        return;
    }

    if (is_wildcard) {
        try wild.append(gpa, normalized);
        // An ABP `||x^` rule covers `x` itself as well as its subdomains.
        if (covers_apex) try list.append(gpa, normalized);
    } else {
        try list.append(gpa, normalized);
    }
}

/// Sorts, deduplicates, writes and hashes one body. Returns the written count.
fn emit(
    entries: *Entries,
    w: *std.Io.Writer,
    hasher: *Sha256,
    duplicates: *u32,
) Error!u32 {
    const offsets = entries.offsets.items;
    if (offsets.len > 1) std.mem.sortUnstableContext(0, offsets.len, SortContext{ .entries = entries });

    var written: u32 = 0;
    var prev: []const u8 = "";
    var first = true;
    for (0..offsets.len) |i| {
        const text = entries.get(i);
        if (!first and std.mem.eql(u8, prev, text)) {
            duplicates.* += 1;
            continue;
        }
        try w.writeAll(text);
        try w.writeByte('\n');
        hasher.update(text);
        hasher.update("\n");
        prev = text;
        first = false;
        written += 1;
    }
    return written;
}

/// Length-prefixed candidate bytes plus the offsets that index them. Sorting
/// permutes the offsets, so the bytes never move.
const Entries = struct {
    bytes: std.ArrayList(u8) = .empty,
    offsets: std.ArrayList(u32) = .empty,

    fn deinit(self: *Entries, gpa: std.mem.Allocator) void {
        self.bytes.deinit(gpa);
        self.offsets.deinit(gpa);
    }

    fn append(self: *Entries, gpa: std.mem.Allocator, text: []const u8) Error!void {
        if (self.offsets.items.len >= max_domains) return error.TooManyDomains;
        const offset = std.math.cast(u32, self.bytes.items.len) orelse return error.TooManyDomains;
        try self.bytes.append(gpa, @intCast(text.len));
        try self.bytes.appendSlice(gpa, text);
        try self.offsets.append(gpa, offset);
    }

    fn get(self: *const Entries, i: usize) []const u8 {
        const offset = self.offsets.items[i];
        const len = self.bytes.items[offset];
        return self.bytes.items[offset + 1 ..][0..len];
    }
};

const SortContext = struct {
    entries: *Entries,

    pub fn lessThan(self: SortContext, a: usize, b: usize) bool {
        return std.mem.order(u8, self.entries.get(a), self.entries.get(b)) == .lt;
    }

    pub fn swap(self: SortContext, a: usize, b: usize) void {
        const offsets = self.entries.offsets.items;
        std.mem.swap(u32, &offsets[a], &offsets[b]);
    }
};

const testing = std.testing;

const Compiled = struct {
    result: Result,
    list_w: std.Io.Writer.Allocating,
    wild_w: std.Io.Writer.Allocating,

    fn deinit(self: *Compiled) void {
        self.list_w.deinit();
        self.wild_w.deinit();
    }

    fn list(self: *Compiled) []const u8 {
        return self.list_w.written();
    }

    fn wild(self: *Compiled) []const u8 {
        return self.wild_w.written();
    }
};

fn compileText(gpa: std.mem.Allocator, text: []const u8, format: parsers.Format) Error!Compiled {
    var r: std.Io.Reader = .fixed(text);
    return compileReader(gpa, &r, format);
}

fn compileReader(gpa: std.mem.Allocator, r: *std.Io.Reader, format: parsers.Format) Error!Compiled {
    var list_w: std.Io.Writer.Allocating = .init(gpa);
    errdefer list_w.deinit();
    var wild_w: std.Io.Writer.Allocating = .init(gpa);
    errdefer wild_w.deinit();
    const result = try compile(gpa, r, format, &list_w.writer, &wild_w.writer);
    return .{ .result = result, .list_w = list_w, .wild_w = wild_w };
}

const hosts_fixture =
    "# a comment\n" ++
    "0.0.0.0 ads.example.com\n" ++
    "0.0.0.0 ads.example.com\n" ++
    "127.0.0.1 localhost\n" ++
    "0.0.0.0 tracker.example.org # tracker\n" ++
    "/ads\\d+/\n" ++
    "\n" ++
    "0.0.0.0 EXAMPLE.com.\n";

test "hosts fixture compiles to a sorted deduplicated body" {
    var c = try compileText(testing.allocator, hosts_fixture, .hosts);
    defer c.deinit();

    try testing.expectEqualStrings(
        "ads.example.com\nexample.com\ntracker.example.org\n",
        c.list(),
    );
    try testing.expectEqualStrings("", c.wild());
    try testing.expectEqual(@as(u32, 3), c.result.counts.domains);
    try testing.expectEqual(@as(u32, 0), c.result.counts.wildcards);
    try testing.expectEqual(@as(u32, 1), c.result.counts.skipped_regex);
    try testing.expectEqual(@as(u32, 0), c.result.counts.skipped_unsupported);
    try testing.expectEqual(@as(u32, 1), c.result.counts.invalid);
    try testing.expectEqual(@as(u32, 0), c.result.counts.long_lines);
    try testing.expectEqual(@as(u32, 1), c.result.counts.duplicates);
}

test "domains fixture compiles" {
    const fixture =
        "# a comment\n" ++
        "b.example.com\n" ++
        "! a bang comment\n" ++
        "a.example.com\n" ++
        "/re/\n" ++
        "0.0.0.0 two.fields.com\n" ++
        "localhost\n";

    var c = try compileText(testing.allocator, fixture, .domains);
    defer c.deinit();

    try testing.expectEqualStrings("a.example.com\nb.example.com\n", c.list());
    try testing.expectEqualStrings("", c.wild());
    try testing.expectEqual(@as(u32, 2), c.result.counts.domains);
    try testing.expectEqual(@as(u32, 1), c.result.counts.skipped_regex);
    try testing.expectEqual(@as(u32, 1), c.result.counts.skipped_unsupported);
    try testing.expectEqual(@as(u32, 1), c.result.counts.invalid);
}

test "abp apex rule lands in both bodies" {
    const fixture =
        "! Title: test\n" ++
        "||x.com^\n" ++
        "||y.com^$third-party\n" ++
        "@@||z.com^\n" ++
        "/re/\n" ++
        "bare.com\n";

    var c = try compileText(testing.allocator, fixture, .abp);
    defer c.deinit();

    try testing.expectEqualStrings("bare.com\nx.com\n", c.list());
    try testing.expectEqualStrings("x.com\n", c.wild());
    try testing.expectEqual(@as(u32, 2), c.result.counts.domains);
    try testing.expectEqual(@as(u32, 1), c.result.counts.wildcards);
    try testing.expectEqual(@as(u32, 1), c.result.counts.skipped_regex);
    try testing.expectEqual(@as(u32, 2), c.result.counts.skipped_unsupported);
}

test "two runs of the same input are byte-identical" {
    var a = try compileText(testing.allocator, hosts_fixture, .hosts);
    defer a.deinit();
    var b = try compileText(testing.allocator, hosts_fixture, .hosts);
    defer b.deinit();

    try testing.expectEqualStrings(a.list(), b.list());
    try testing.expectEqualStrings(a.wild(), b.wild());
    try testing.expectEqualSlices(u8, &a.result.checksum, &b.result.checksum);
}

test "a permutation of the input compiles to the same bodies" {
    const shuffled =
        "0.0.0.0 EXAMPLE.com.\n" ++
        "/ads\\d+/\n" ++
        "0.0.0.0 tracker.example.org # tracker\n" ++
        "\n" ++
        "0.0.0.0 ads.example.com\n" ++
        "127.0.0.1 localhost\n" ++
        "# a comment\n" ++
        "0.0.0.0 ads.example.com\n";

    var a = try compileText(testing.allocator, hosts_fixture, .hosts);
    defer a.deinit();
    var b = try compileText(testing.allocator, shuffled, .hosts);
    defer b.deinit();

    try testing.expectEqualStrings(a.list(), b.list());
    try testing.expectEqualStrings(a.wild(), b.wild());
    try testing.expectEqualSlices(u8, &a.result.checksum, &b.result.checksum);
}

test "uppercase and trailing dot normalize" {
    var c = try compileText(testing.allocator, "AdS.Example.COM.\n", .domains);
    defer c.deinit();
    try testing.expectEqualStrings("ads.example.com\n", c.list());
}

test "invalid candidates are counted and written nowhere" {
    const fixture =
        "caf\xc3\xa9.example.com\n" ++
        "localhost\n" ++
        "a*b.com\n";

    var c = try compileText(testing.allocator, fixture, .domains);
    defer c.deinit();

    try testing.expectEqual(@as(u32, 3), c.result.counts.invalid);
    try testing.expectEqual(@as(u32, 0), c.result.counts.domains);
    try testing.expectEqualStrings("", c.list());
    try testing.expectEqualStrings("", c.wild());
}

test "a leading star label becomes a wildcard entry" {
    var c = try compileText(testing.allocator, "*.ads.example.com\n", .domains);
    defer c.deinit();
    try testing.expectEqualStrings("", c.list());
    try testing.expectEqualStrings("ads.example.com\n", c.wild());
    try testing.expectEqual(@as(u32, 1), c.result.counts.wildcards);
}

test "an over-long line is skipped whole" {
    const gpa = testing.allocator;
    var text: std.ArrayList(u8) = .empty;
    defer text.deinit(gpa);

    var line_buf: [64]u8 = undefined;
    var i: usize = 0;
    while (i < 3_000) : (i += 1) {
        if (i == 1_500) {
            try text.appendNTimes(gpa, 'x', 5_000);
            try text.append(gpa, '\n');
        }
        try text.appendSlice(gpa, try std.fmt.bufPrint(&line_buf, "d{d:0>5}.example.com\n", .{i}));
    }

    // A reader buffer smaller than the long line makes `takeDelimiter` report
    // `error.StreamTooLong`, which is the path that loops forever without the
    // discard.
    var backing: std.Io.Reader = .fixed(text.items);
    var buf: [max_line_len]u8 = undefined;
    var limited = backing.limited(.unlimited, &buf);

    var c = try compileReader(gpa, &limited.interface, .domains);
    defer c.deinit();

    try testing.expectEqual(@as(u32, 1), c.result.counts.long_lines);
    try testing.expectEqual(@as(u32, 3_000), c.result.counts.domains);
    try testing.expect(std.mem.startsWith(u8, c.list(), "d00000.example.com\n"));
    try testing.expect(std.mem.endsWith(u8, c.list(), "d02999.example.com\n"));
    try testing.expect(std.mem.indexOf(u8, c.list(), "d01499.example.com\n") != null);
    try testing.expect(std.mem.indexOf(u8, c.list(), "d01500.example.com\n") != null);
    try testing.expect(std.mem.indexOf(u8, c.list(), "xxxx") == null);
}

test "an over-long line is skipped when the reader buffer is large" {
    const gpa = testing.allocator;
    var text: std.ArrayList(u8) = .empty;
    defer text.deinit(gpa);

    try text.appendSlice(gpa, "a.example.com\n");
    try text.appendNTimes(gpa, 'x', 5_000);
    try text.append(gpa, '\n');
    try text.appendSlice(gpa, "b.example.com\n");

    var c = try compileText(gpa, text.items, .domains);
    defer c.deinit();

    try testing.expectEqual(@as(u32, 1), c.result.counts.long_lines);
    try testing.expectEqualStrings("a.example.com\nb.example.com\n", c.list());
}

test "an over-long final line with no newline ends the stream inside the discard" {
    const gpa = testing.allocator;
    var text: std.ArrayList(u8) = .empty;
    defer text.deinit(gpa);

    try text.appendSlice(gpa, "a.example.com\n");
    try text.appendNTimes(gpa, 'x', 5_000);

    // A reader buffer smaller than the trailing line makes `takeDelimiter`
    // report `error.StreamTooLong`, and the discard that follows then runs out
    // of input because nothing terminates that line. That is the `EndOfStream`
    // arm: the loop must count the line and stop, not treat the exhausted
    // stream as a read failure.
    var backing: std.Io.Reader = .fixed(text.items);
    var buf: [max_line_len]u8 = undefined;
    var limited = backing.limited(.unlimited, &buf);

    var c = try compileReader(gpa, &limited.interface, .domains);
    defer c.deinit();

    try testing.expectEqual(@as(u32, 1), c.result.counts.long_lines);
    try testing.expectEqual(@as(u32, 1), c.result.counts.domains);
    try testing.expectEqualStrings("a.example.com\n", c.list());
    try testing.expect(std.mem.indexOf(u8, c.list(), "xxxx") == null);
}

test "carriage returns are stripped" {
    var c = try compileText(testing.allocator, "b.example.com\r\na.example.com\r\n", .domains);
    defer c.deinit();
    try testing.expectEqualStrings("a.example.com\nb.example.com\n", c.list());
}

test "empty input produces empty bodies and the sha256 of the empty string" {
    var c = try compileText(testing.allocator, "", .domains);
    defer c.deinit();

    try testing.expectEqualStrings("", c.list());
    try testing.expectEqualStrings("", c.wild());
    try testing.expectEqualStrings(
        "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
        &c.result.checksum,
    );
}

fn compileUnderFailure(gpa: std.mem.Allocator) !void {
    var c = compileText(gpa, hosts_fixture, .hosts) catch |err| switch (err) {
        // `Writer.Allocating` reports an exhausted allocator as `WriteFailed`;
        // it has no other failure mode.
        error.WriteFailed => return error.OutOfMemory,
        else => |e| return e,
    };
    defer c.deinit();
    try testing.expectEqual(@as(u32, 3), c.result.counts.domains);
}

test "compile under allocation failure" {
    try testing.checkAllAllocationFailures(testing.allocator, compileUnderFailure, .{});
}
