//! Fuzz targets for the blocklist line parsers and the wildcard matcher
//! (`src/filter/parsers.zig` and its siblings).
//!
//! Every target holds the same contract: any byte string is a legal blocklist
//! line, so a parser may classify it however it likes but may not panic, may
//! not read out of bounds and may not fail to return. Where a classification
//! succeeds the target then checks the invariant the compiler is entitled to
//! rely on:
//!
//!   - `Line.text` is always a slice of the caller's line, never a copy and
//!     never a dangling pointer into a temporary;
//!   - `covers_apex` is set only on a `.wildcard` or an `.exception` line,
//!     because those are the two anchored forms it describes; the compiler acts
//!     on it for `.wildcard`, where it emits the apex entry beside the suffix
//!     one;
//!   - `wildcard.matches` terminates for any pattern, validated or not, and a
//!     match implies the domain has at least as many labels as the pattern,
//!     since every pattern label consumes at least one domain label.
//!
//! `parsers.parseLine` documents that its line carries no `\n` and no `\r`, so
//! each target cuts the fuzzer's bytes at the first one rather than handing the
//! parser input the compiler could never produce.
//!
//! This file is the root of its own test artifact and reaches the parsers
//! through the `parsers` module, which is why those five files import nothing
//! outside `src/filter/`.
//!
//! Runner semantics: under a plain `zig build test` each target runs once per
//! corpus entry plus once on empty input, which makes the corpus a regression
//! suite. `zig build test --fuzz=<n>` gives each target `n` generated inputs.

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

const sliceInput = smith_encode.sliceInput;
const pairInput = smith_encode.pairInput;
const wildcard = parsers.wildcard;
const Smith = std.testing.Smith;

/// Long enough to hold a line past `compiler.max_line_len`, which is the
/// longest line the compiler ever hands a parser.
const max_input = 8192;

/// `Smith` entity ids. The two-slice target needs stable, distinct ids for its
/// pattern and its domain; the single-slice targets take the first.
const pattern_hash: u32 = 1;
const domain_hash: u32 = 2;

const fuzz_options: std.testing.FuzzInputOptions = .{ .corpus = &corpus };

test "fuzz parser_hosts.parseLine" {
    try std.testing.fuzz(parsers.Format.hosts, formatTarget, fuzz_options);
}

test "fuzz parser_domains.parseLine" {
    try std.testing.fuzz(parsers.Format.domains, formatTarget, fuzz_options);
}

test "fuzz parser_abp.parseLine" {
    try std.testing.fuzz(parsers.Format.abp, formatTarget, fuzz_options);
}

test "fuzz parsers.detectFormat" {
    try std.testing.fuzz({}, detectTarget, fuzz_options);
}

test "fuzz wildcard.validate and wildcard.matches" {
    try std.testing.fuzz({}, wildcardTarget, fuzz_options);
}

/// One format's parser, reached through the dispatcher the compiler uses.
fn formatTarget(format: parsers.Format, smith: *Smith) anyerror!void {
    var buf: [max_input]u8 = undefined;
    const input = buf[0..smith.sliceWithHash(&buf, pattern_hash)];
    const line = upToNewline(input);

    const parsed = parsers.parseLine(format, line);
    try expectBorrowed(parsed, line);

    // Exception syntax belongs to the ABP parser alone. A hosts or domains line
    // that produced one would open an allow hole in a format that has no way to
    // write one.
    if (format != .abp) try std.testing.expect(parsed.kind != .exception);
}

/// The sniffer reads whole files, so this one keeps the line breaks.
fn detectTarget(_: void, smith: *Smith) anyerror!void {
    var buf: [max_input]u8 = undefined;
    const input = buf[0..smith.sliceWithHash(&buf, pattern_hash)];

    const format = parsers.detectFormat(input);

    // Whatever the sniffer decides, every line of the same bytes must survive
    // that format's parser: this is the pairing the manager performs.
    var it = std.mem.splitScalar(u8, input, '\n');
    while (it.next()) |raw| {
        const line = upToNewline(raw);
        try expectBorrowed(parsers.parseLine(format, line), line);
    }
}

fn wildcardTarget(_: void, smith: *Smith) anyerror!void {
    var pattern_buf: [max_input]u8 = undefined;
    var domain_buf: [max_input]u8 = undefined;
    const pattern = pattern_buf[0..smith.sliceWithHash(&pattern_buf, pattern_hash)];
    const domain = domain_buf[0..smith.sliceWithHash(&domain_buf, domain_hash)];

    // `matches` is total on unvalidated input by design, so both the accepted
    // and the rejected pattern are fed in. Production only ever reaches it with
    // an accepted one, which is why the accepted case carries the invariant.
    const accepted = if (wildcard.validate(pattern)) |_| true else |_| false;
    const matched = wildcard.matches(pattern, domain);

    if (accepted and matched) {
        try std.testing.expect(labelCount(domain) >= labelCount(pattern));
    }
}

/// The parser contract: `text` is a window into the caller's line, so the
/// compiler may keep it for the length of that line and no longer.
fn expectBorrowed(parsed: parsers.Line, line: []const u8) !void {
    if (parsed.covers_apex) {
        try std.testing.expect(parsed.kind == .wildcard or parsed.kind == .exception);
    }
    // An exception with no name would compile to an empty allow entry, which
    // `addCandidate` would then reject as invalid rather than honour.
    if (parsed.kind == .exception) try std.testing.expect(parsed.text.len != 0);
    if (parsed.text.len == 0) return;

    const start = @intFromPtr(parsed.text.ptr);
    const line_start = @intFromPtr(line.ptr);
    try std.testing.expect(start >= line_start);
    try std.testing.expect(start + parsed.text.len <= line_start + line.len);
}

fn upToNewline(input: []const u8) []const u8 {
    const end = std.mem.findAny(u8, input, "\r\n") orelse input.len;
    return input[0..end];
}

fn labelCount(text: []const u8) usize {
    return std.mem.count(u8, text, ".") + 1;
}

// ---------------------------------------------------------------------------
// corpus
// ---------------------------------------------------------------------------
//
// `Smith` does not consume a corpus entry as raw parser input, so every entry
// below goes through the `smith_encode.zig` encoders. The five targets share one
// corpus: each starts with a slice, and the wildcard target reads a second one
// that falls back to empty when an entry carries only the first.

/// A hosts line with a sink address, two names and a trailing comment.
const hosts_line = "0.0.0.0 ads.example.com tracker.example.com # advertising";

/// An ABP domain rule, which covers the apex as well as the subdomains.
const abp_line = "||ads.example.net^";

/// The exception forms: the two anchored spellings, the one tolerated modifier,
/// and a bare `@@` name, which stays unsupported.
const abp_exception = "@@||good.ads.example.net^";
const abp_exception_important = "@@||good.ads.example.net^$important";
const abp_exception_unanchored = "@@good.ads.example.net";

/// A regex rule, which every parser counts and skips (PLAN §2.2).
const regex_line = "/^ads[0-9]+\\.example\\.org$/";

/// Past `compiler.max_line_len`, so the over-long path is a seed rather than a
/// discovery.
const long_line = "a" ** 5000 ++ ".example.com";

/// An element-hiding rule and a scheme anchor: the two `.unsupported` shapes
/// that carry a domain in front of them.
const element_hiding = "example.com##.ad-banner";
const scheme_anchor = "|https://ads.example.com/track";

const corpus = [_][]const u8{
    sliceInput(hosts_line),
    sliceInput(abp_line),
    sliceInput(abp_exception),
    sliceInput(abp_exception_important),
    sliceInput(abp_exception_unanchored),
    sliceInput(regex_line),
    sliceInput(long_line),
    sliceInput(element_hiding),
    sliceInput(scheme_anchor),
    // A whole small file, so `detectFormat` sees more than one line.
    sliceInput("# a hosts list\n" ++ hosts_line ++ "\n" ++ regex_line ++ "\n"),
    // The two wildcard shapes PLAN §3.9 names, each with a name that matches.
    pairInput("*.doubleclick.net", "a.b.doubleclick.net"),
    pairInput("ads.*.example.com", "ads.eu.west.example.com"),
    // A pattern that no name matches, and one `validate` rejects.
    pairInput("*.example.com", "example.com.evil.net"),
    pairInput("ad*.example.com", "ads.example.com"),
};
