//! Per-group safe search (PLAN §7.4): the table of search and video domains
//! whose queries are rewritten to a provider-operated restricted hostname.
//! Pure: no allocation, no `std.Io`, no clock.
//!
//! Every target is a name the operator's upstream resolves. nxdns never
//! hardcodes an address for one: the providers move these hosts, and a stale
//! literal would send a household's search traffic to somebody else's server.
//!
//! Google's country domains (`google.de`, `google.co.uk`, …) are deliberately
//! not enumerated. The list is unbounded, it goes stale, and
//! `forcesafesearch.google.com` is the documented target for every one of them.
//! An operator who needs a country domain adds a rule.

const std = @import("std");
const name = @import("../dns/name.zig");

pub const Entry = struct { domain: []const u8, target: []const u8 };

/// Sorted ascending by `domain`, so the table is searchable and diffable. The
/// order is a correctness precondition of `lookup`, so it is asserted below at
/// comptime rather than left to review.
pub const table = [_]Entry{
    .{ .domain = "bing.com", .target = "strict.bing.com" },
    .{ .domain = "duckduckgo.com", .target = "safe.duckduckgo.com" },
    .{ .domain = "google.com", .target = "forcesafesearch.google.com" },
    .{ .domain = "m.youtube.com", .target = "restrictmoderate.youtube.com" },
    .{ .domain = "pixabay.com", .target = "safesearch.pixabay.com" },
    .{ .domain = "www.bing.com", .target = "strict.bing.com" },
    .{ .domain = "www.duckduckgo.com", .target = "safe.duckduckgo.com" },
    .{ .domain = "www.google.com", .target = "forcesafesearch.google.com" },
    .{ .domain = "www.youtube-nocookie.com", .target = "restrictmoderate.youtube.com" },
    .{ .domain = "www.youtube.com", .target = "restrictmoderate.youtube.com" },
    .{ .domain = "youtube.com", .target = "restrictmoderate.youtube.com" },
    .{ .domain = "youtube.googleapis.com", .target = "restrictmoderate.youtube.com" },
    .{ .domain = "youtubei.googleapis.com", .target = "restrictmoderate.youtube.com" },
};

comptime {
    for (table[1..], table[0 .. table.len - 1]) |entry, previous| {
        if (std.mem.order(u8, previous.domain, entry.domain) != .lt) {
            @compileError("safesearch.table must be sorted ascending by domain and duplicate-free");
        }
    }
}

fn orderDomain(query: []const u8, entry: Entry) std.math.Order {
    return std.mem.order(u8, query, entry.domain);
}

/// Exact match. `domain` must be normalized: lowercase, no trailing dot.
pub fn lookup(domain: []const u8) ?[]const u8 {
    const index = std.sort.binarySearch(Entry, &table, domain, orderDomain) orelse return null;
    return table[index].target;
}

/// The rewritten question name for a matched query. Applying it — sending the
/// rewritten question upstream and prefixing the answer with a CNAME from the
/// original name to the target — is `server/handler.zig`'s job. Nothing here
/// builds a response.
pub fn rewrite(domain: []const u8) ?name.Name {
    const target = lookup(domain) orelse return null;
    // Every target in `table` is a syntactically valid name, which the
    // "every table entry is a valid domain name" test below asserts.
    return name.fromText(target) catch unreachable;
}

const testing = std.testing;

test "every table entry is a valid domain name" {
    for (table) |entry| {
        _ = try name.fromText(entry.domain);
        _ = try name.fromText(entry.target);
    }
}

test "lookup finds every table entry" {
    for (table) |entry| {
        try testing.expectEqualStrings(entry.target, lookup(entry.domain).?);
    }
}

test "lookup matches exactly and nothing else" {
    try testing.expect(lookup("example.com") == null);
    try testing.expect(lookup("") == null);
    try testing.expect(lookup("com") == null);
    // A subdomain of a listed name is not listed: the table is exact.
    try testing.expect(lookup("images.google.com") == null);
    // Nor is a parent, nor a name a listed one is a prefix of.
    try testing.expect(lookup("google.com.evil.net") == null);
    // The caller normalizes; an uppercase spelling is a miss, not a hit.
    try testing.expect(lookup("GOOGLE.COM") == null);
    // A trailing dot is not stripped here either.
    try testing.expect(lookup("google.com.") == null);
    // A country domain is deliberately absent.
    try testing.expect(lookup("google.de") == null);
}

test "lookup maps the youtube family to one target" {
    const youtube = "restrictmoderate.youtube.com";
    for ([_][]const u8{
        "m.youtube.com",
        "www.youtube-nocookie.com",
        "www.youtube.com",
        "youtube.com",
        "youtube.googleapis.com",
        "youtubei.googleapis.com",
    }) |domain| {
        try testing.expectEqualStrings(youtube, lookup(domain).?);
    }
}

test "rewrite returns the target as a wire name" {
    const rewritten = rewrite("www.google.com").?;
    try testing.expectEqualSlices(
        u8,
        (try name.fromText("forcesafesearch.google.com")).wire(),
        rewritten.wire(),
    );
    try testing.expect(rewrite("example.com") == null);
}
