//! The Adblock Plus filter syntax, restricted to what a DNS sinkhole can
//! honour: domain anchors and bare names. Pure, `std` only.
//!
//! An exception rule is `.exception` in exactly two spellings, `@@||name^` and
//! `@@||name`, each of which may carry the literal `$important` behind it. Every
//! other `@@` form stays `.unsupported`: a bare `@@name`, a path, a scheme, any
//! other modifier.
//!
//! What makes honouring them safe is where they land, not what they say. A list
//! exception is evaluated below every operator rule (PLAN §3.10), so it can
//! cancel a block another list made and nothing else. No downloaded list can
//! open an allow hole the operator did not open, which is why the allow surface
//! stays the `rules` table — including its `.regex` kind, which is the one
//! regex dialect nxdns evaluates and which no downloaded list can reach.

const std = @import("std");
const parsers = @import("parsers.zig");

/// Tokens that a candidate name may never contain. `^` is a separator token in
/// this syntax and only a trailing one is meaningful for a domain rule.
const rule_tokens = "*^|/$";

/// The one modifier an exception line may carry. AdGuard-authored lists write it
/// on most of their `@@` rules and it changes nothing here: these exceptions
/// already sit below every operator rule, so "important" cannot raise one above
/// the decisions it is not allowed to reach.
const important_modifier = "$important";

/// A candidate the compiler could turn into a name, for the two anchored forms
/// only. `rule_tokens` covers the syntax characters; this also refuses
/// whitespace, which is neither a rule token nor a control byte and so used to
/// survive into a compiled body as an entry only a query carrying the same
/// space could match. That is true of `||name` and `@@||name` because
/// `compiler.zig` hands a `.wildcard` or `.exception` text to `addCandidate`
/// whole. A `.domain` text is tokenized on whitespace first and each field
/// filed separately, so the bare form does not come through here: refusing a
/// space there would drop the hosts-style lines that a mixed list classified
/// `abp` by `detectFormat` still contributes.
fn isNameCandidate(candidate: []const u8) bool {
    if (candidate.len == 0) return false;
    if (std.mem.findAny(u8, candidate, rule_tokens) != null) return false;
    for (candidate) |c| {
        if (std.ascii.isWhitespace(c) or std.ascii.isControl(c)) return false;
    }
    return true;
}

pub fn parseLine(line: []const u8) parsers.Line {
    const text = std.mem.trim(u8, line, &std.ascii.whitespace);
    if (text.len == 0) return .{ .kind = .ignore };
    if (text[0] == '!') return .{ .kind = .ignore };
    if (text[0] == '[') return .{ .kind = .ignore };
    if (parsers.isElementHiding(text)) return .{ .kind = .unsupported };
    if (text[0] == '#') return .{ .kind = .ignore };
    if (std.mem.startsWith(u8, text, "@@")) return parseException(text[2..]);
    if (text[0] == '/') return .{ .kind = .regex };
    if (std.mem.findScalar(u8, text, '$') != null) return .{ .kind = .unsupported };

    if (std.mem.startsWith(u8, text, "||")) {
        var candidate = text[2..];
        if (std.mem.endsWith(u8, candidate, "^")) candidate = candidate[0 .. candidate.len - 1];
        if (!isNameCandidate(candidate)) return .{ .kind = .unsupported };
        // A domain anchor covers the domain itself as well as its subdomains,
        // so the compiler emits an apex entry beside the wildcard one.
        return .{ .kind = .wildcard, .text = candidate, .covers_apex = true };
    }

    if (std.mem.findAny(u8, text, rule_tokens) != null) return .{ .kind = .unsupported };
    return .{ .kind = .domain, .text = text };
}

/// One exception line, past its `@@`. The domain anchor and the trailing `^` get
/// the same treatment they get on a block rule, so `@@||x^` and `||x^` accept
/// and reject the same names.
///
/// `$important` is stripped before the anchor is read, because the `$` would
/// otherwise be a rule token and refuse the whole line.
fn parseException(rest: []const u8) parsers.Line {
    if (!std.mem.startsWith(u8, rest, "||")) return .{ .kind = .unsupported };

    var candidate = rest[2..];
    if (std.mem.endsWith(u8, candidate, important_modifier)) {
        candidate = candidate[0 .. candidate.len - important_modifier.len];
    }
    if (std.mem.endsWith(u8, candidate, "^")) candidate = candidate[0 .. candidate.len - 1];
    if (!isNameCandidate(candidate)) return .{ .kind = .unsupported };
    return .{ .kind = .exception, .text = candidate, .covers_apex = true };
}

const testing = std.testing;

test "a bang comment is ignored" {
    try testing.expectEqual(parsers.Kind.ignore, parseLine("! comment").kind);
}

test "a list header is ignored" {
    try testing.expectEqual(parsers.Kind.ignore, parseLine("[Adblock Plus 2.0]").kind);
}

test "an empty line is ignored" {
    try testing.expectEqual(parsers.Kind.ignore, parseLine("").kind);
}

test "a domain anchor is a wildcard that covers its apex" {
    const line = parseLine("||example.com^");
    try testing.expectEqual(parsers.Kind.wildcard, line.kind);
    try testing.expectEqualStrings("example.com", line.text);
    try testing.expect(line.covers_apex);
}

test "a domain anchor without a separator is still a wildcard" {
    const line = parseLine("||example.com");
    try testing.expectEqual(parsers.Kind.wildcard, line.kind);
    try testing.expectEqualStrings("example.com", line.text);
    try testing.expect(line.covers_apex);
}

test "a modifier list is unsupported" {
    try testing.expectEqual(parsers.Kind.unsupported, parseLine("||example.com^$third-party").kind);
}

test "an exception rule is an exception that covers its apex" {
    const line = parseLine("@@||example.com^");
    try testing.expectEqual(parsers.Kind.exception, line.kind);
    try testing.expectEqualStrings("example.com", line.text);
    try testing.expect(line.covers_apex);
}

test "an exception rule without a separator is still an exception" {
    const line = parseLine("@@||example.com");
    try testing.expectEqual(parsers.Kind.exception, line.kind);
    try testing.expectEqualStrings("example.com", line.text);
    try testing.expect(line.covers_apex);
}

test "an exception rule tolerates the important modifier" {
    for ([_][]const u8{ "@@||example.com^$important", "@@||example.com$important" }) |text| {
        const line = parseLine(text);
        try testing.expectEqual(parsers.Kind.exception, line.kind);
        try testing.expectEqualStrings("example.com", line.text);
        try testing.expect(line.covers_apex);
    }
}

test "every exception form outside the two anchored ones is unsupported" {
    for ([_][]const u8{
        // No domain anchor: this is a substring rule in browser syntax, and
        // reading it as a name would allow far more than it says.
        "@@example.com",
        "@@|http://example.com",
        "@@||example.com/path^",
        "@@||example.com^$third-party",
        "@@||example.com^$important$third-party",
        "@@||example.com^$dnstype=A",
        "@@||^",
        "@@||$important",
        "@@",
        "@@||ads*.example.com^",
    }) |text| {
        try testing.expectEqual(parsers.Kind.unsupported, parseLine(text).kind);
    }
}

test "element hiding is unsupported" {
    try testing.expectEqual(parsers.Kind.unsupported, parseLine("##.ad-banner").kind);
    try testing.expectEqual(parsers.Kind.unsupported, parseLine("example.com#@#.ad").kind);
    try testing.expectEqual(parsers.Kind.unsupported, parseLine("example.com#?#.ad").kind);
}

test "a scheme anchor is unsupported" {
    try testing.expectEqual(parsers.Kind.unsupported, parseLine("|http://example.com").kind);
    try testing.expectEqual(parsers.Kind.unsupported, parseLine("|https://example.com").kind);
}

test "a regex rule is recognized" {
    try testing.expectEqual(parsers.Kind.regex, parseLine("/ads[0-9]+/").kind);
}

test "a bare name is a domain" {
    const line = parseLine("example.com");
    try testing.expectEqual(parsers.Kind.domain, line.kind);
    try testing.expectEqualStrings("example.com", line.text);
}

test "a candidate carrying whitespace is unsupported in the anchored forms" {
    // A space is not a rule token and it is not a control byte, so it used to
    // reach the compiler, which lowercases and length-checks but does not
    // reject it. An anchored form's text is filed whole, so the entry it wrote
    // could only ever match a query name carrying the same space.
    for ([_][]const u8{
        "||good.example bad.example^",
        "@@||good.example bad.example^",
        "||good.example\tbad.example",
        "@@||good.example\tbad.example",
    }) |text| {
        try testing.expectEqual(parsers.Kind.unsupported, parseLine(text).kind);
    }
}

test "a bare candidate carrying whitespace stays a domain" {
    // Not the same case: `compiler.zig` tokenizes a `.domain` text on
    // whitespace and files each field. Refusing it here would drop the
    // hosts-style lines of a mixed list, which `detectFormat` classifies `abp`
    // as a whole and which only reach a compiled body through that split.
    //
    // What this test pins is the parser half — the kind and the untouched text.
    // The split itself belongs to the compiler and is asserted there, by
    // "an abp list's hosts-style lines reach the domain body through the split":
    // a tokenizer removed from `compile` would leave every assertion below true.
    for ([_][]const u8{
        "good.example bad.example",
        "0.0.0.0 ads.example",
        "good.example\tbad.example",
    }) |text| {
        const line = parseLine(text);
        try testing.expectEqual(parsers.Kind.domain, line.kind);
        try testing.expectEqualStrings(text, line.text);
    }
}

test "a rule token outside the supported forms is unsupported" {
    try testing.expectEqual(parsers.Kind.unsupported, parseLine("ads*.example.com").kind);
    try testing.expectEqual(parsers.Kind.unsupported, parseLine("example.com^").kind);
    try testing.expectEqual(parsers.Kind.unsupported, parseLine("example.com|").kind);
    try testing.expectEqual(parsers.Kind.unsupported, parseLine("||example.com/path^").kind);
}
