//! Blocklist line parsers: the shared vocabulary and the format sniffer.
//!
//! These files decide **format**, not validity. Whether a candidate is a usable
//! domain name is the compiler's decision, taken through `dns.name.fromText`.
//! `std` is the only import here and in every sibling parser: this file is the
//! root of a separate fuzz module, and a module root cannot import across its
//! own directory boundary.

const std = @import("std");

pub const hosts = @import("parser_hosts.zig");
pub const domains = @import("parser_domains.zig");
pub const abp = @import("parser_abp.zig");
pub const wildcard = @import("wildcard.zig");

pub const Format = enum { hosts, domains, abp };

pub const Kind = enum {
    /// Nothing on the line, or only a comment.
    ignore,
    /// `text` holds one or more whitespace-separated candidate names.
    domain,
    /// `text` holds one candidate suffix; every proper subdomain of it matches.
    wildcard,
    /// A regex line in a downloaded list. Counted, skipped, never compiled: the
    /// engine exists for rules the operator wrote, not for lists (PLAN §2.2).
    regex,
    /// `text` holds one candidate name an ABP exception rule (`@@||x^`) lifts:
    /// the name itself and every subdomain of it. Only the ABP parser emits it.
    exception,
    /// Syntactically a rule of this format, but one nxdns cannot honour:
    /// an ABP modifier list, an exception form outside `@@||x^`, element
    /// hiding, a scheme anchor.
    unsupported,
};

pub const Line = struct {
    kind: Kind,
    /// Borrowed from the caller's line. Not lowercased, not validated.
    text: []const u8 = "",
    /// `.wildcard` and `.exception` only: the rule covers the anchored name
    /// itself as well as its subdomains, which is what ABP `||x^` and `@@||x^`
    /// mean. The compiler acts on it for a `.wildcard` line, by emitting an
    /// additional `.list` entry; an `.exception` line needs no second entry,
    /// because the allow walk tests the full name as well as its parents.
    covers_apex: bool = false,
};

/// Dispatches to the format's parser. The line must not contain '\n' or '\r';
/// the caller strips them.
pub fn parseLine(format: Format, line: []const u8) Line {
    return switch (format) {
        .hosts => hosts.parseLine(line),
        .domains => domains.parseLine(line),
        .abp => abp.parseLine(line),
    };
}

pub const LineEvent = union(enum) {
    /// One line without its delimiter, borrowed from the reader's buffer and
    /// valid only until the next call. A trailing '\r' is left on: whether it
    /// belongs to the line is the caller's decision.
    line: []const u8,
    /// A line longer than `max_len`. It has already been stepped over.
    long_line,
};

/// One line, or `null` at end of stream. `max_len` bounds a line; anything
/// longer comes back as `.long_line` with the stream positioned on the line
/// after it, so a caller that keeps calling always advances.
///
/// The bound is a parameter because this file may not import `compiler.zig`:
/// the compiler imports this one, and this file is the root of a separate fuzz
/// module. Both callers pass `compiler.max_line_len`.
///
/// The two over-long paths exist because a `Reader` reports an over-long line
/// two different ways. A reader whose buffer is smaller than `max_len` reports
/// `error.StreamTooLong` and — this is the hazard — leaves the stream
/// unmodified (Reader.zig:895-919), so without the discard a caller re-reads
/// the same bytes forever. A reader whose buffer is larger hands the whole line
/// over and the length check catches it.
///
/// An over-long final line with no delimiter ends the stream inside the
/// discard. That still counts as a line, so it comes back as `.long_line`; the
/// discard drained the stream (Reader.zig:1042), so the next call returns
/// `null`.
pub fn nextBoundedLine(r: *std.Io.Reader, max_len: usize) error{ReadFailed}!?LineEvent {
    const raw = r.takeDelimiter('\n') catch |err| switch (err) {
        error.ReadFailed => return error.ReadFailed,
        error.StreamTooLong => {
            _ = r.discardDelimiterInclusive('\n') catch |discard_err| switch (discard_err) {
                error.EndOfStream => return .long_line,
                error.ReadFailed => return error.ReadFailed,
            };
            return .long_line;
        },
    } orelse return null;

    if (raw.len > max_len) return .long_line;
    return .{ .line = raw };
}

pub const sample_lines = 64;

/// Picks a format from the first `sample_lines` lines that are not blank and
/// not comments: an ABP marker (`||`, `@@`, `##`, `$`) wins `.abp`; otherwise a
/// majority of lines whose first field looks like an IP literal wins `.hosts`;
/// otherwise `.domains`.
pub fn detectFormat(sample: []const u8) Format {
    var considered: usize = 0;
    var ip_first: usize = 0;

    var it = std.mem.splitScalar(u8, sample, '\n');
    while (it.next()) |raw| {
        if (considered == sample_lines) break;
        const line = std.mem.trim(u8, raw, &std.ascii.whitespace);
        if (line.len == 0) continue;
        if (hasAbpMarker(line)) return .abp;
        if (isComment(line)) continue;
        considered += 1;
        if (looksLikeIpLiteral(firstField(line))) ip_first += 1;
    }

    if (ip_first * 2 > considered) return .hosts;
    return .domains;
}

/// `!` is the ABP comment marker and `#` the hosts one; both appear in every
/// format in the wild. `##`, `#@#` and `#?#` are element-hiding rules, not
/// comments, so they stay visible to `hasAbpMarker`.
pub fn isComment(line: []const u8) bool {
    if (line.len == 0) return false;
    if (line[0] == '!') return true;
    if (line[0] != '#') return false;
    return !isElementHiding(line);
}

/// The element-hiding separators, which may also follow a domain list
/// (`example.com##.ad-banner`).
///
/// Where the separator sits decides, because `#` is also the hosts comment
/// marker and a hosts file banner is drawn out of the same two characters. A
/// `##` counts only where an element-hiding rule can put one: at the start of
/// the line with a selector behind it, or straight after the domain list it
/// applies to. `## Title`, `####` and `see ## below` are therefore text, and a
/// hosts file that opens with a banner keeps sniffing as hosts.
///
/// Guarding this with `isComment` instead would decide nothing: `isComment`
/// asks this function.
pub fn isElementHiding(line: []const u8) bool {
    for ([_][]const u8{ "##", "#@#", "#?#", "#$#", "#%#" }) |marker| {
        var from: usize = 0;
        while (std.mem.find(u8, line[from..], marker)) |offset| {
            const at = from + offset;
            if (separatorStartsRule(line, at, marker.len)) return true;
            from = at + 1;
        }
    }
    return false;
}

/// Whether the separator of `marker_len` bytes at `at` is a rule's separator
/// rather than two characters of prose.
fn separatorStartsRule(line: []const u8, at: usize, marker_len: usize) bool {
    if (at == 0) {
        // A generic rule carries its selector here. A banner carries a space,
        // another `#`, or nothing at all.
        if (line.len == marker_len) return false;
        const after = line[marker_len];
        return after != '#' and !std.ascii.isWhitespace(after);
    }
    // A domain list ends where the separator begins, with no space between.
    const before = line[at - 1];
    return before != '#' and !std.ascii.isWhitespace(before);
}

fn hasAbpMarker(line: []const u8) bool {
    if (std.mem.startsWith(u8, line, "||")) return true;
    if (std.mem.startsWith(u8, line, "@@")) return true;
    if (isElementHiding(line)) return true;
    // A '$' modifier list only counts on a rule line: a hosts file whose
    // comments mention a price must not be sniffed as ABP.
    if (!isComment(line) and std.mem.findScalar(u8, line, '$') != null) return true;
    return false;
}

/// The line up to the first ASCII whitespace byte.
pub fn firstField(line: []const u8) []const u8 {
    const end = std.mem.findAny(u8, line, &std.ascii.whitespace) orelse line.len;
    return line[0..end];
}

/// A sniffing heuristic, not a parser: it recognizes dotted-quad IPv4 and any
/// hex-and-colon IPv6 spelling. `platform/address.zig` holds the real parser and
/// importing it would break this file's std-only constraint.
pub fn looksLikeIpLiteral(field: []const u8) bool {
    if (field.len == 0) return false;

    if (std.mem.findScalar(u8, field, ':') != null) {
        for (field) |c| {
            if (c != ':' and c != '.' and !std.ascii.isHex(c)) return false;
        }
        return true;
    }

    var parts: usize = 0;
    var it = std.mem.splitScalar(u8, field, '.');
    while (it.next()) |part| {
        parts += 1;
        if (part.len == 0 or part.len > 3) return false;
        for (part) |c| {
            if (!std.ascii.isDigit(c)) return false;
        }
    }
    return parts == 4;
}

const testing = std.testing;

test "detectFormat recognizes a hosts file" {
    const sample =
        \\# Title: example
        \\0.0.0.0 ads.example.com
        \\0.0.0.0 track.example.net
        \\127.0.0.1 metrics.example.org
        \\
    ;
    try testing.expectEqual(Format.hosts, detectFormat(sample));
}

test "detectFormat recognizes a domains file" {
    const sample =
        \\# Title: example
        \\ads.example.com
        \\track.example.net
        \\metrics.example.org
        \\
    ;
    try testing.expectEqual(Format.domains, detectFormat(sample));
}

test "detectFormat recognizes an abp file" {
    const sample =
        \\[Adblock Plus 2.0]
        \\! Title: example
        \\||ads.example.com^
        \\||track.example.net^
        \\
    ;
    try testing.expectEqual(Format.abp, detectFormat(sample));
}

test "detectFormat falls back to domains on an all-comment sample" {
    var buffer: [64 * 16]u8 = undefined;
    var w: usize = 0;
    for (0..64) |_| {
        @memcpy(buffer[w..][0..14], "# a comment.\n\n");
        w += 14;
    }
    try testing.expectEqual(Format.domains, detectFormat(buffer[0..w]));
}

test "detectFormat is not fooled by a dollar sign in a comment" {
    const sample =
        \\# donations welcome, $5 covers a month
        \\0.0.0.0 ads.example.com
        \\0.0.0.0 track.example.net
        \\
    ;
    try testing.expectEqual(Format.hosts, detectFormat(sample));
}

/// The banner style a hosts list published for malware URLs opens with: a rule
/// of `#` characters around a titled header block. Every line of it contains
/// `##`, and reading those as element hiding used to sniff the whole file as
/// ABP — which put `0.0.0.0` in the domain set and dropped every hosts line
/// that carried an inline comment.
const urlhaus_banner_sample =
    \\################################################################
    \\# URLhaus Malicious Hosts File                                 #
    \\# Last updated: 2026-08-05 06:05:04 (UTC)                      #
    \\#                                                              #
    \\# Terms Of Use: https://urlhaus.abuse.ch/api/                  #
    \\################################################################
    \\0.0.0.0 bad1.example.com # https://urlhaus.abuse.ch/url/1/
    \\0.0.0.0 bad2.example.net # https://urlhaus.abuse.ch/url/2/
    \\0.0.0.0 bad3.example.org # https://urlhaus.abuse.ch/url/3/
    \\
;

test "detectFormat reads a hosts file behind a hash banner as hosts" {
    try testing.expectEqual(Format.hosts, detectFormat(urlhaus_banner_sample));

    // The lines the banner is made of are comments, so the sample the format
    // is decided from is the three hosts lines alone.
    var it = std.mem.splitScalar(u8, urlhaus_banner_sample, '\n');
    while (it.next()) |line| {
        if (line.len == 0) continue;
        if (line[0] != '#') continue;
        try testing.expect(isComment(line));
        try testing.expect(!isElementHiding(line));
    }
}

test "detectFormat still recognizes a generic element-hiding rule" {
    const sample =
        \\##.ad-banner
        \\example.com
        \\
    ;
    try testing.expectEqual(Format.abp, detectFormat(sample));
    try testing.expect(isElementHiding("##.ad-banner"));
}

test "detectFormat recognizes element hiding after a domain list" {
    const sample =
        \\example.com##.ad
        \\other.example.net
        \\
    ;
    try testing.expectEqual(Format.abp, detectFormat(sample));
    try testing.expect(isElementHiding("example.com##.ad"));
}

test "detectFormat recognizes an exception separator after a domain" {
    const sample =
        \\example.com#@#.sponsored
        \\other.example.net
        \\
    ;
    try testing.expectEqual(Format.abp, detectFormat(sample));
    try testing.expect(isElementHiding("example.com#@#.sponsored"));
}

test "a comment line that mentions a separator stays a comment" {
    const line = "# the ##.ad rules live in the other list";
    try testing.expect(isComment(line));
    try testing.expect(!isElementHiding(line));

    // A bare separator and a rule of hashes are text as well.
    try testing.expect(!isElementHiding("##"));
    try testing.expect(!isElementHiding("####"));
    try testing.expect(!isElementHiding("## Title"));
}

test "parseLine dispatches to the hosts parser" {
    const line = parseLine(.hosts, "0.0.0.0 ads.example.com");
    try testing.expectEqual(Kind.domain, line.kind);
    try testing.expectEqualStrings("ads.example.com", line.text);
}

test "parseLine dispatches to the domains parser" {
    const line = parseLine(.domains, "0.0.0.0 ads.example.com");
    try testing.expectEqual(Kind.unsupported, line.kind);
}

test "parseLine dispatches to the abp parser" {
    const line = parseLine(.abp, "||ads.example.com^");
    try testing.expectEqual(Kind.wildcard, line.kind);
    try testing.expectEqualStrings("ads.example.com", line.text);
    try testing.expect(line.covers_apex);
}

fn expectLine(expected: []const u8, event: ?LineEvent) !void {
    const got = event orelse return error.TestExpectedLine;
    switch (got) {
        .line => |line| try testing.expectEqualStrings(expected, line),
        .long_line => return error.TestExpectedLine,
    }
}

test "nextBoundedLine walks lines and ends at the stream" {
    var r: std.Io.Reader = .fixed("a\nbb\n\nccc");
    try expectLine("a", try nextBoundedLine(&r, 16));
    try expectLine("bb", try nextBoundedLine(&r, 16));
    try expectLine("", try nextBoundedLine(&r, 16));
    // A final line with no delimiter is still a line.
    try expectLine("ccc", try nextBoundedLine(&r, 16));
    try testing.expectEqual(@as(?LineEvent, null), try nextBoundedLine(&r, 16));
}

test "nextBoundedLine reports an over-long line when the reader buffer is large" {
    var r: std.Io.Reader = .fixed("a\nxxxxxxxx\nb\n");
    try expectLine("a", try nextBoundedLine(&r, 4));
    try testing.expectEqual(LineEvent.long_line, (try nextBoundedLine(&r, 4)).?);
    try expectLine("b", try nextBoundedLine(&r, 4));
    try testing.expectEqual(@as(?LineEvent, null), try nextBoundedLine(&r, 4));
}

test "nextBoundedLine steps over a line that does not fit the reader buffer" {
    // A buffer smaller than the long line makes `takeDelimiter` report
    // `error.StreamTooLong` and leave the stream where it was, which is the
    // path that loops forever without the discard.
    var backing: std.Io.Reader = .fixed("a\n" ++ "x" ** 64 ++ "\nb\n");
    var buf: [16]u8 = undefined;
    var limited = backing.limited(.unlimited, &buf);
    const r = &limited.interface;

    try expectLine("a", try nextBoundedLine(r, 16));
    try testing.expectEqual(LineEvent.long_line, (try nextBoundedLine(r, 16)).?);
    try expectLine("b", try nextBoundedLine(r, 16));
    try testing.expectEqual(@as(?LineEvent, null), try nextBoundedLine(r, 16));
}

test "nextBoundedLine reports an over-long final line that ends inside the discard" {
    var backing: std.Io.Reader = .fixed("a\n" ++ "x" ** 64);
    var buf: [16]u8 = undefined;
    var limited = backing.limited(.unlimited, &buf);
    const r = &limited.interface;

    try expectLine("a", try nextBoundedLine(r, 16));
    try testing.expectEqual(LineEvent.long_line, (try nextBoundedLine(r, 16)).?);
    try testing.expectEqual(@as(?LineEvent, null), try nextBoundedLine(r, 16));
}

test "looksLikeIpLiteral separates addresses from names" {
    try testing.expect(looksLikeIpLiteral("0.0.0.0"));
    try testing.expect(looksLikeIpLiteral("127.0.0.1"));
    try testing.expect(looksLikeIpLiteral("::1"));
    try testing.expect(looksLikeIpLiteral("fd00::dead:beef"));
    try testing.expect(!looksLikeIpLiteral("example.com"));
    try testing.expect(!looksLikeIpLiteral("add.face.cafe"));
    try testing.expect(!looksLikeIpLiteral("1.2.3"));
    try testing.expect(!looksLikeIpLiteral(""));
}
