//! Label-pattern wildcards for filtering rules (PLAN §3.9). Pure: no
//! allocation, no `std.Io`, no recursion.
//!
//! A pattern is a domain name in which one or more labels are exactly `*`.
//! Each `*` label matches one or more labels of the queried name. Partial-label
//! globbing (`ad*.example.com`) is deliberately absent: it is regex by another
//! name, and PLAN §2.2 keeps one regex dialect rather than two. An operator who
//! needs one writes a `.regex` rule, which `filter/regex.zig` compiles.

const std = @import("std");

pub const max_labels = 128;

/// The longest domain name is 255 wire bytes, which is 253 bytes of text.
const max_pattern_len = 253;
const max_label_len = 63;

pub const PatternError = error{
    /// No label is exactly "*".
    NoWildcard,
    /// A label contains '*' but is not exactly "*". Partial-label globbing
    /// (`ad*.example.com`) is out of scope: PLAN §3.9 defines the wildcard as a
    /// label pattern, and the `.regex` kind covers what partial globbing was
    /// wanted for.
    PartialWildcardLabel,
    EmptyLabel,
    LabelTooLong,
    PatternTooLong,
    TooManyLabels,
};

/// Syntax only. A valid pattern has at least one label that is exactly "*",
/// every other label is 1–63 bytes with no '*' inside it, and the whole
/// pattern is at most 253 bytes over at most `max_labels` labels.
pub fn validate(pattern: []const u8) PatternError!void {
    // The label count is checked before the byte length so that both bounds
    // stay individually reportable: any pattern with more than `max_labels`
    // labels also exceeds `max_pattern_len`.
    if (std.mem.count(u8, pattern, ".") + 1 > max_labels) return error.TooManyLabels;
    if (pattern.len > max_pattern_len) return error.PatternTooLong;

    var star = false;
    var it = std.mem.splitScalar(u8, pattern, '.');
    while (it.next()) |label| {
        if (label.len == 0) return error.EmptyLabel;
        if (label.len > max_label_len) return error.LabelTooLong;
        if (std.mem.eql(u8, label, "*")) {
            star = true;
        } else if (std.mem.findScalar(u8, label, '*') != null) {
            return error.PartialWildcardLabel;
        }
    }
    if (!star) return error.NoWildcard;
}

/// `domain` is already normalized: lowercase, no trailing dot. `pattern` is
/// lowercase. Each "*" label matches ONE OR MORE labels.
/// Allocation-free; the backtracking is bounded by `max_labels` on both sides.
pub fn matches(pattern: []const u8, domain: []const u8) bool {
    var pattern_labels: [max_labels][]const u8 = undefined;
    var domain_labels: [max_labels][]const u8 = undefined;

    // `validate` rejects a pattern above the label bound and the 253-byte name
    // limit bounds the domain, so neither overflow can reach here from the
    // matcher. Both are re-checked so that unvalidated input still terminates.
    const pattern_len = split(pattern, &pattern_labels) orelse return false;
    const domain_len = split(domain, &domain_labels) orelse return false;

    var d: usize = 0;
    var p: usize = 0;
    var star: ?usize = null;
    var star_end: usize = 0;

    while (d < domain_len) {
        if (p < pattern_len and isStar(pattern_labels[p])) {
            // A '*' takes one label now and grows by one on each backtrack.
            star = p;
            p += 1;
            d += 1;
            star_end = d;
        } else if (p < pattern_len and std.mem.eql(u8, pattern_labels[p], domain_labels[d])) {
            p += 1;
            d += 1;
        } else if (star) |s| {
            p = s + 1;
            star_end += 1;
            d = star_end;
        } else {
            return false;
        }
    }
    // A trailing '*' has already consumed its label; nothing may be left over.
    return p == pattern_len;
}

fn isStar(label: []const u8) bool {
    return label.len == 1 and label[0] == '*';
}

/// Null when `text` holds more than `max_labels` labels.
fn split(text: []const u8, out: *[max_labels][]const u8) ?usize {
    var n: usize = 0;
    var it = std.mem.splitScalar(u8, text, '.');
    while (it.next()) |label| {
        if (n == max_labels) return null;
        out[n] = label;
        n += 1;
    }
    return n;
}

const testing = std.testing;

test "validate accepts a leading wildcard label" {
    try validate("*.doubleclick.net");
}

test "validate accepts an interior wildcard label" {
    try validate("ads.*.example.com");
}

test "validate rejects a pattern with no wildcard label" {
    try testing.expectError(error.NoWildcard, validate("example.com"));
}

test "validate rejects a partial wildcard label" {
    try testing.expectError(error.PartialWildcardLabel, validate("a*b.com"));
}

test "validate rejects an empty label" {
    try testing.expectError(error.EmptyLabel, validate("a..b"));
}

test "validate rejects an oversize label" {
    const pattern = "*." ++ ("a" ** 64);
    try testing.expectError(error.LabelTooLong, validate(pattern));
}

test "validate rejects an oversize pattern" {
    const label = "a" ** 60;
    const pattern = "*." ++ label ++ "." ++ label ++ "." ++ label ++ "." ++ label ++ "." ++ label;
    try testing.expect(pattern.len > 253);
    try testing.expectError(error.PatternTooLong, validate(pattern));
}

test "validate rejects too many labels" {
    const pattern = "*." ++ ("a." ** 199) ++ "com";
    try testing.expectError(error.TooManyLabels, validate(pattern));
}

test "matches one label under a leading wildcard" {
    try testing.expect(matches("*.doubleclick.net", "a.doubleclick.net"));
}

test "matches several labels under a leading wildcard" {
    try testing.expect(matches("*.doubleclick.net", "a.b.doubleclick.net"));
}

test "a leading wildcard does not match the apex" {
    try testing.expect(!matches("*.doubleclick.net", "doubleclick.net"));
}

test "matches one label at an interior wildcard" {
    try testing.expect(matches("ads.*.example.com", "ads.eu.example.com"));
}

test "matches several labels at an interior wildcard" {
    try testing.expect(matches("ads.*.example.com", "ads.eu.west.example.com"));
}

test "an interior wildcard requires at least one label" {
    try testing.expect(!matches("ads.*.example.com", "ads.example.com"));
}

test "a pattern does not match a name that only contains it" {
    try testing.expect(!matches("*.example.com", "example.com.evil.net"));
}

test "a pathological pattern terminates" {
    const pattern = ("*." ** 8) ++ "example.com";
    const domain = ("a." ** 98) ++ "example.net";
    try testing.expect(!matches(pattern, domain));
}
