//! Performance bench harness (PLAN §18, milestone-12 rulings 1-4).
//!
//! `zig build bench -Doptimize=ReleaseFast -- [filter|cache|compile|all] [flags]`
//!
//! Flags: `--domains=N` (default 1_000_000), `--iters=N` (default 200_000),
//! `--seed=N` (default 0x5eed), `--assert`. The default run is informational;
//! `--assert` exits non-zero when a §18 target is exceeded — meant for the
//! Pi 5 acceptance run, never for CI (required CI stays deterministic).
//!
//! What each suite measures:
//! - `filter`: `matcher.normalize` + `Snapshot.evaluate` per op — the handler's
//!   filtering work — against a snapshot built from `--domains` generated exact
//!   entries, a small wildcard body and `bench_regex_rules` operator regex
//!   rules. Query mix cycles hit, miss and parent-walk. Target p95 < 1 ms;
//!   VmRSS < 100 MiB with the list loaded.
//! - `cache`: `buildKey` + `DnsCache.get` + `packet.setId` — the handler's
//!   cache-hit path, TTL aging included — on a 10k-entry cache prefilled with a
//!   realistic response. Query mix alternates hit and miss. Target p95 < 5 ms.
//! - `compile`: `compiler.compile` over `--domains` generated hosts lines.
//!   Wall time, informational (no §18 target).

const std = @import("std");
const builtin = @import("builtin");
const core = @import("core");
const matcher = core.matcher;
const dns_cache = core.dns_cache;
const compiler = core.compiler;
const model = core.model;
const dns_name = core.dns_name;
const dns_types = core.dns_types;
const packet = core.packet;

const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;

const filter_p95_target_ns: u64 = 1 * std.time.ns_per_ms;
const cache_p95_target_ns: u64 = 5 * std.time.ns_per_ms;
const rss_target_bytes: usize = 100 * 1024 * 1024;

const cache_entries: u32 = 10_000;

/// Operator regex rules the filter snapshot carries. A household writes a
/// handful; 32 is the pessimistic end of plausible, and `max_regex_per_group`
/// allows eight times as many.
const bench_regex_rules: usize = 32;

/// Byte-for-byte copy of `response` in tests/fuzz/corpus.zig (a copy on
/// purpose, same as the corpus itself: a bench input that changes whenever a
/// test fixture is edited is a benchmark that silently shifts). A CNAME to
/// www.example.com (TTL 300) plus its A record (TTL 60) and an OPT record,
/// so `classify` stores it as a positive entry with a 60 s lifetime.
const cached_response =
    "\x12\x34\x81\x80\x00\x01\x00\x02\x00\x00\x00\x01" ++
    "\x07example\x03com\x00\x00\x01\x00\x01" ++
    "\xc0\x0c\x00\x05\x00\x01\x00\x00\x01\x2c\x00\x06\x03www\xc0\x0c" ++
    "\xc0\x29\x00\x01\x00\x01\x00\x00\x00\x3c\x00\x04\x5d\xb8\xd8\x22" ++
    "\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x00";

/// Strictly ascending, so `DomainSet.build` accepts it. Never matched by the
/// generated queries: the wildcards exist to be walked past, the way a real
/// snapshot's wildcard set is on most queries.
const wild_body = "ads.bench.invalid\nmetrics.bench.invalid\ntelemetry.bench.invalid\n";

const usage =
    "usage: zig build bench -Doptimize=ReleaseFast -- " ++
    "[filter|cache|compile|all] [--domains=N] [--iters=N] [--seed=N] [--assert]";

const Suite = enum { filter, cache, compile, all };

const Options = struct {
    suite: Suite = .all,
    domains: u32 = 1_000_000,
    iters: u32 = 200_000,
    seed: u64 = 0x5eed,
    assert: bool = false,
};

pub fn main(init: std.process.Init) !u8 {
    const arena = init.arena.allocator();
    const gpa = init.gpa;
    const io = init.io;

    const args = try init.minimal.args.toSlice(arena);
    const opts = parseOptions(args);

    var out_buffer: [4096]u8 = undefined;
    var out = std.Io.File.stdout().writer(io, &out_buffer);
    const w = &out.interface;

    if (builtin.mode == .Debug) {
        try w.print("warning: Debug build; run with -Doptimize=ReleaseFast for meaningful numbers\n", .{});
    }
    try w.print("nxdns bench  suite={t}  domains={d}  iters={d}  seed=0x{x}  optimize={t}\n\n", .{
        opts.suite, opts.domains, opts.iters, opts.seed, builtin.mode,
    });
    try w.print("{s:<9}{s:>10}{s:>12}{s:>12}{s:>12}{s:>12}\n", .{
        "suite", "ops", "p50(us)", "p95(us)", "p99(us)", "max(us)",
    });

    var exceeded: u32 = 0;
    if (opts.suite == .filter or opts.suite == .all) exceeded += try runFilter(io, gpa, opts, w);
    if (opts.suite == .cache or opts.suite == .all) exceeded += try runCache(io, gpa, opts, w);
    if (opts.suite == .compile or opts.suite == .all) try runCompile(io, gpa, opts, w);

    if (exceeded > 0) try w.print("\n{d} target(s) exceeded\n", .{exceeded});
    try w.flush();
    return if (opts.assert and exceeded > 0) 1 else 0;
}

fn parseOptions(args: []const [:0]const u8) Options {
    var opts: Options = .{};
    for (args[1..]) |arg| {
        if (std.mem.eql(u8, arg, "--assert")) {
            opts.assert = true;
        } else if (std.mem.startsWith(u8, arg, "--domains=")) {
            opts.domains = parseNumber(u32, arg, "--domains=");
        } else if (std.mem.startsWith(u8, arg, "--iters=")) {
            opts.iters = parseNumber(u32, arg, "--iters=");
        } else if (std.mem.startsWith(u8, arg, "--seed=")) {
            opts.seed = parseNumber(u64, arg, "--seed=");
        } else if (std.meta.stringToEnum(Suite, arg)) |suite| {
            opts.suite = suite;
        } else {
            std.process.fatal("unknown argument '{s}'\n{s}", .{ arg, usage });
        }
    }
    if (opts.iters == 0) std.process.fatal("--iters must be at least 1", .{});
    if (opts.domains == 0) std.process.fatal("--domains must be at least 1", .{});
    // Seven zero-padded digits keep generation order equal to sorted order;
    // the DomainSet cap is lower anyway.
    if (opts.domains > 4_000_000) std.process.fatal("--domains must be at most 4000000", .{});
    return opts;
}

fn parseNumber(comptime T: type, arg: []const u8, prefix: []const u8) T {
    return std.fmt.parseInt(T, arg[prefix.len..], 10) catch {
        std.process.fatal("bad value in '{s}'\n{s}", .{ arg, usage });
    };
}

// ---------------------------------------------------------------------------
// Suites
// ---------------------------------------------------------------------------

/// Returns how many §18 targets the suite exceeded.
fn runFilter(io: std.Io, gpa: Allocator, opts: Options, w: *Writer) !u32 {
    var body: std.ArrayList(u8) = .empty;
    errdefer body.deinit(gpa);
    try body.ensureTotalCapacity(gpa, @as(usize, opts.domains) * 20);
    var line: [64]u8 = undefined;
    for (0..opts.domains) |i| {
        const text = std.fmt.bufPrint(&line, "d{d:0>7}.example.com\n", .{i}) catch unreachable;
        try body.appendSlice(gpa, text);
    }

    // None of these matches the generated query mix, which is the expensive
    // case rather than the cheap one: the regex levels sit below every hash and
    // wildcard level, so a query that no regex matches is the query that runs
    // all of them to their end. Every op in this suite pays that.
    var patterns: std.ArrayList([]u8) = .empty;
    defer {
        for (patterns.items) |pattern| gpa.free(pattern);
        patterns.deinit(gpa);
    }
    var regex_rules: [bench_regex_rules]model.Rule = undefined;
    for (&regex_rules, 0..) |*row, i| {
        const pattern = try std.fmt.allocPrint(gpa, "^r{d}-[0-9]+\\.(ads|track)\\.invalid$", .{i});
        errdefer gpa.free(pattern);
        try patterns.append(gpa, pattern);
        row.* = .{ .group = "default", .pattern = pattern, .kind = .regex, .action = .block };
    }

    const sources = [_]model.BlocklistSource{.{ .url = "bench://list", .name = "bench" }};
    const links = [_]model.GroupSource{.{ .group = "default", .source_url = "bench://list" }};
    var snapshot = try matcher.Snapshot.build(gpa, .{
        .groups = &.{.{ .name = "default" }},
        .group_ids = &.{1},
        .group_sources = &links,
        .sources = &sources,
        .source_ids = &.{1},
        .rules = &regex_rules,
        .clients = &.{},
        .prefixes = &.{},
        .compiled = &.{.{ .list_body = body.items, .wild_body = wild_body }},
        .seed = opts.seed,
        .generation = 1,
    });
    defer snapshot.deinit();
    // The snapshot copied everything it needs; freeing the source body before
    // the RSS read keeps the memory number about the loaded snapshot.
    // clearAndFree leaves the list valid so the errdefer above stays safe.
    body.clearAndFree(gpa);

    var prng = std.Random.DefaultPrng.init(opts.seed);
    const random = prng.random();
    const pool = try gpa.alloc(dns_name.Name, 4096);
    defer gpa.free(pool);
    for (pool, 0..) |*entry, i| {
        const r = random.uintLessThan(u32, opts.domains);
        const text = switch (i % 3) {
            0 => std.fmt.bufPrint(&line, "d{d:0>7}.example.com", .{r}),
            1 => std.fmt.bufPrint(&line, "m{d:0>7}.example.org", .{r}),
            else => std.fmt.bufPrint(&line, "a.b.d{d:0>7}.example.com", .{r}),
        } catch unreachable;
        entry.* = dns_name.fromText(text) catch unreachable;
    }

    const samples = try gpa.alloc(u64, opts.iters);
    defer gpa.free(samples);

    var buf: [dns_types.max_name_len]u8 = undefined;
    for (pool) |qname| {
        std.mem.doNotOptimizeAway(snapshot.evaluate(0, matcher.normalize(qname, &buf)).blocked);
    }

    var blocked: u64 = 0;
    for (samples, 0..) |*sample, i| {
        const qname = pool[i % pool.len];
        const t0 = std.Io.Clock.awake.now(io);
        const domain = matcher.normalize(qname, &buf);
        const decision = snapshot.evaluate(0, domain);
        const t1 = std.Io.Clock.awake.now(io);
        sample.* = @intCast(@max(0, t0.durationTo(t1).toNanoseconds()));
        if (decision.blocked) blocked += 1;
    }
    if (blocked == 0) std.process.fatal("filter bench blocked nothing; the suite is broken", .{});

    const pct = percentiles(samples);
    const rss = vmRssBytes(io);
    try printRow(w, "filter", opts.iters, pct);
    try w.print(
        "  blocked {d}/{d}, {d} regex rules, Snapshot.memoryBytes {d:.1} MiB, VmRSS {d:.1} MiB\n",
        .{ blocked, opts.iters, regex_rules.len, mib(snapshot.memoryBytes()), mib(rss) },
    );

    var exceeded: u32 = 0;
    exceeded += try printTarget(w, "p95 < 1ms", pct.p95 < filter_p95_target_ns);
    exceeded += try printTarget(w, "VmRSS < 100 MiB", rss < rss_target_bytes);
    return exceeded;
}

fn runCache(io: std.Io, gpa: Allocator, opts: Options, w: *Writer) !u32 {
    var cache = try dns_cache.DnsCache.init(gpa, .{
        .size = cache_entries,
        .negative_ttl_max = 3600,
    });
    defer cache.deinit();

    const class = dns_cache.classify(cached_response, 3600) orelse {
        std.process.fatal("cache bench response is not cacheable; the suite is broken", .{});
    };

    const filled_at: i64 = 1_000_000;
    var key_buf: [dns_cache.max_key_len]u8 = undefined;
    var text_buf: [64]u8 = undefined;
    for (0..cache_entries) |i| {
        const qname = std.fmt.bufPrint(&text_buf, "c{d:0>5}.example.com", .{i}) catch unreachable;
        const key = dns_cache.buildKey(&key_buf, qname, 1, 1, false, null);
        try cache.put(filled_at, key, cached_response, class);
    }

    const samples = try gpa.alloc(u64, opts.iters);
    defer gpa.free(samples);

    var prng = std.Random.DefaultPrng.init(opts.seed);
    const random = prng.random();
    // Inside the entry's 60 s lifetime, far enough in to make `get` age TTLs.
    const queried_at = filled_at + 30;
    var out_buf: [512]u8 = undefined;
    var hits: u64 = 0;
    for (samples, 0..) |*sample, i| {
        const r = random.uintLessThan(u32, cache_entries);
        const qname = if (i % 2 == 0)
            std.fmt.bufPrint(&text_buf, "c{d:0>5}.example.com", .{r}) catch unreachable
        else
            std.fmt.bufPrint(&text_buf, "x{d:0>5}.example.org", .{r}) catch unreachable;

        const t0 = std.Io.Clock.awake.now(io);
        const key = dns_cache.buildKey(&key_buf, qname, 1, 1, false, null);
        const found = cache.get(queried_at, key, &out_buf);
        if (found) |bytes| packet.setId(bytes, @truncate(i));
        const t1 = std.Io.Clock.awake.now(io);
        sample.* = @intCast(@max(0, t0.durationTo(t1).toNanoseconds()));
        if (found != null) hits += 1;
    }
    if (hits == 0) std.process.fatal("cache bench hit nothing; the suite is broken", .{});

    const pct = percentiles(samples);
    const rss = vmRssBytes(io);
    try printRow(w, "cache", opts.iters, pct);
    try w.print("  hits {d}/{d}, DnsCache.memoryBytes {d:.1} MiB, VmRSS {d:.1} MiB\n", .{
        hits, opts.iters, mib(cache.memoryBytes()), mib(rss),
    });
    return try printTarget(w, "p95 < 5ms", pct.p95 < cache_p95_target_ns);
}

fn runCompile(io: std.Io, gpa: Allocator, opts: Options, w: *Writer) !void {
    if (opts.domains > compiler.max_domains) {
        std.process.fatal("compile suite needs --domains <= {d}", .{compiler.max_domains});
    }

    var body: std.ArrayList(u8) = .empty;
    defer body.deinit(gpa);
    try body.ensureTotalCapacity(gpa, @as(usize, opts.domains) * 28);
    var line: [64]u8 = undefined;
    for (0..opts.domains) |i| {
        const text = std.fmt.bufPrint(&line, "0.0.0.0 d{d:0>7}.example.com\n", .{i}) catch unreachable;
        try body.appendSlice(gpa, text);
    }

    var reader = std.Io.Reader.fixed(body.items);
    var list_buf: [4096]u8 = undefined;
    var wild_buf: [4096]u8 = undefined;
    var allow_buf: [4096]u8 = undefined;
    var list_out: Writer.Discarding = .init(&list_buf);
    var wild_out: Writer.Discarding = .init(&wild_buf);
    var allow_out: Writer.Discarding = .init(&allow_buf);

    const t0 = std.Io.Clock.awake.now(io);
    const result = compiler.compile(
        gpa,
        &reader,
        .hosts,
        &list_out.writer,
        &wild_out.writer,
        &allow_out.writer,
    ) catch |err| {
        std.process.fatal("compiler.compile failed: {t}", .{err});
    };
    const t1 = std.Io.Clock.awake.now(io);

    if (result.counts.domains != opts.domains) {
        std.process.fatal("compile kept {d} of {d} domains; the suite is broken", .{
            result.counts.domains, opts.domains,
        });
    }

    const elapsed_ns: u64 = @intCast(@max(1, t0.durationTo(t1).toNanoseconds()));
    const lines_per_s = @as(u64, opts.domains) * std.time.ns_per_s / elapsed_ns;
    try w.print("{s:<9}{d:>10}   wall {f}, {d} lines/s, {d} domains kept (informational)\n", .{
        "compile",             opts.domains, std.Io.Duration.fromNanoseconds(@intCast(elapsed_ns)), lines_per_s,
        result.counts.domains,
    });
}

// ---------------------------------------------------------------------------
// Reporting helpers
// ---------------------------------------------------------------------------

const Percentiles = struct { p50: u64, p95: u64, p99: u64, max: u64 };

/// Nearest-rank percentiles over per-op nanoseconds. Sorts `samples` in place.
fn percentiles(samples: []u64) Percentiles {
    std.debug.assert(samples.len > 0);
    std.mem.sort(u64, samples, {}, std.sort.asc(u64));
    return .{
        .p50 = atRank(samples, 50),
        .p95 = atRank(samples, 95),
        .p99 = atRank(samples, 99),
        .max = samples[samples.len - 1],
    };
}

fn atRank(sorted: []const u64, pct: usize) u64 {
    const rank = (sorted.len * pct + 99) / 100;
    return sorted[@max(rank, 1) - 1];
}

fn printRow(w: *Writer, suite: []const u8, ops: u32, pct: Percentiles) !void {
    try w.print("{s:<9}{d:>10}{d:>12.2}{d:>12.2}{d:>12.2}{d:>12.2}\n", .{
        suite, ops, us(pct.p50), us(pct.p95), us(pct.p99), us(pct.max),
    });
}

fn printTarget(w: *Writer, target: []const u8, ok: bool) !u32 {
    try w.print("  target {s}: {s}\n", .{ target, if (ok) "PASS" else "FAIL" });
    return @intFromBool(!ok);
}

fn us(ns: u64) f64 {
    return @as(f64, @floatFromInt(ns)) / @as(f64, std.time.ns_per_us);
}

fn mib(bytes: usize) f64 {
    return @as(f64, @floatFromInt(bytes)) / (1024.0 * 1024.0);
}

/// The kernel's resident-set figure, since nothing in-repo wraps it. In-repo
/// accounting (`Snapshot.memoryBytes`, `DnsCache.memoryBytes`) is reported
/// alongside; the two bracket the truth from below and above.
fn vmRssBytes(io: std.Io) usize {
    var file = std.Io.Dir.cwd().openFile(io, "/proc/self/status", .{}) catch |err| {
        std.process.fatal("cannot open /proc/self/status: {t}", .{err});
    };
    defer file.close(io);

    // procfs reports a zero size to stat, so the size-aware alloc readers see
    // an instant end-of-stream; a plain streaming read does not.
    var reader_buf: [64]u8 = undefined;
    var reader = file.readerStreaming(io, &reader_buf);
    var status_buf: [8192]u8 = undefined;
    const len = reader.interface.readSliceShort(&status_buf) catch |err| {
        std.process.fatal("cannot read /proc/self/status: {t}", .{err});
    };
    const status = status_buf[0..len];

    var lines = std.mem.splitScalar(u8, status, '\n');
    while (lines.next()) |status_line| {
        if (!std.mem.startsWith(u8, status_line, "VmRSS:")) continue;
        var fields = std.mem.tokenizeAny(u8, status_line["VmRSS:".len..], " \t");
        const kib = fields.next() orelse break;
        return 1024 * (std.fmt.parseInt(usize, kib, 10) catch break);
    }
    std.process.fatal("no VmRSS line in /proc/self/status", .{});
}
