//! `GET /api/lookup?domain=&group_id=` — what the pipeline would do with a name
//! (ruling 14).
//!
//! The answer is assembled from the same three sources a query reads, in the
//! same order PLAN §6 gives them: the local records, the forward zones, then
//! the filter snapshot. Nothing is re-implemented here; a divergence between
//! this endpoint and a real query would make the tool that explains blocking
//! the one thing an operator cannot trust.
//!
//! `evaluate` is pure, so the whole decision table is testable against a
//! hand-built snapshot. The handler adds the two things that need the outside
//! world: the snapshot and local-table handles, and the source row that turns a
//! source index into the URL an operator recognises.

const std = @import("std");
const Allocator = std.mem.Allocator;

const forward_zones = @import("../../local/forward_zones.zig");
const http_util = @import("../http_util.zig");
const matcher = @import("../../filter/matcher.zig");
const name_mod = @import("../../dns/name.zig");
const records_mod = @import("../../local/records.zig");
const safesearch = @import("../../filter/safesearch.zig");
const server = @import("../server.zig");
const sources_repo = @import("../../storage/repositories/sources_repo.zig");
const types = @import("../../dns/types.zig");

const log = std.log.scoped(.web_lookup);

pub const Body = struct {
    domain: []const u8,
    /// The `groups` row id the decision was made for, not the snapshot index.
    group_id: i64,
    local_records: bool,
    /// The matching zone, or null when no zone claims the name.
    forward_zone: ?[]const u8,
    blocked: bool,
    reason: []const u8,
    /// The rule or list entry that decided it; "" when nothing matched.
    matched: []const u8,
    /// The list that decided it, which for `blocklist_exception` is the list
    /// whose `@@` rule lifted the block rather than one that made it.
    source_url: ?[]const u8,
    safe_search_rewrite: ?[]const u8,
};

/// The pure part: everything but the source URL, which is a database read.
pub const Result = struct {
    group_id: i64,
    local_records: bool,
    forward_zone: ?[]const u8,
    blocked: bool,
    reason: matcher.Reason,
    matched: []const u8,
    /// `blocklist_sources` row id of the list that matched, whether it blocked
    /// the name or lifted it through an `@@` exception.
    source_id: ?i64,
    safe_search_rewrite: ?[]const u8,
};

/// `domain` must already be normalized. `group` is an index into
/// `snapshot.groups`.
pub fn evaluate(
    snapshot: *const matcher.Snapshot,
    group: u32,
    domain: []const u8,
    records: *const records_mod.Records,
    zones: *const forward_zones.Zones,
) Result {
    const decision = snapshot.evaluate(group, domain);
    const source_id: ?i64 = if (decision.source) |index| snapshot.sources[index].id else null;

    return .{
        .group_id = snapshot.groups[group].id,
        .local_records = records.hasName(domain),
        .forward_zone = if (zones.match(domain)) |zone| zone.zone else null,
        .blocked = decision.blocked,
        .reason = decision.reason,
        .matched = decision.matched,
        .source_id = source_id,
        .safe_search_rewrite = if (snapshot.safeSearch(group)) safesearch.lookup(domain) else null,
    };
}

pub fn body(domain: []const u8, result: Result, source_url: ?[]const u8) Body {
    return .{
        .domain = domain,
        .group_id = result.group_id,
        .local_records = result.local_records,
        .forward_zone = result.forward_zone,
        .blocked = result.blocked,
        .reason = @tagName(result.reason),
        .matched = result.matched,
        .source_url = source_url,
        .safe_search_rewrite = result.safe_search_rewrite,
    };
}

pub fn handle(
    state: *server.WebState,
    io: std.Io,
    request: *http_util.Request,
) http_util.HandlerError!void {
    var raw: [types.max_name_len]u8 = undefined;
    const found = http_util.queryValue(request.query, "domain", &raw) catch
        return http_util.respondError(request, .bad_request, "domain is not a valid name");
    const text = found orelse
        return http_util.respondError(request, .bad_request, "domain is required");
    if (text.len == 0) return http_util.respondError(request, .bad_request, "domain is required");

    // Ruling 29: the same normalization the query path applies, so the answer
    // is about the name the pipeline would actually see.
    var normalized_buf: [types.max_name_len]u8 = undefined;
    const parsed = name_mod.fromText(text) catch
        return http_util.respondError(request, .bad_request, "domain is not a valid name");
    const domain = matcher.normalize(parsed, &normalized_buf);
    if (domain.len == 0) return http_util.respondError(request, .bad_request, "domain is not a valid name");

    const requested_group = http_util.queryInt(i64, request.query, "group_id") catch
        return http_util.respondError(request, .bad_request, "group_id must be a row id");

    const manager = state.manager orelse
        return http_util.respondError(request, .service_unavailable, "no snapshot loaded");
    const acquired = manager.acquire(io) orelse
        return http_util.respondError(request, .service_unavailable, "no snapshot loaded");
    defer acquired.release(io);
    const snapshot = acquired.snapshot;

    const group = if (requested_group) |id|
        snapshot.groupIndexById(id) orelse
            return http_util.respondError(request, .bad_request, "unknown group_id")
    else
        snapshot.default_group;

    const result = if (state.handler) |handler| local: {
        // The local tables are published like the snapshot is, so the reader
        // brackets its lookups the same way (ruling 12).
        if (handler.local_tables) |tables| {
            const held = tables.acquire(io);
            defer held.release(io);
            break :local evaluate(snapshot, group, domain, held.records, held.zones);
        }
        break :local evaluate(snapshot, group, domain, &empty_records, &empty_zones);
    } else evaluate(snapshot, group, domain, &empty_records, &empty_zones);

    return http_util.respondJson(request, .ok, body(domain, result, sourceUrl(state, request.arena, result)), &.{});
}

const empty_records: records_mod.Records = .empty;
const empty_zones: forward_zones.Zones = .empty;

/// The blocking list's URL, when there is one to read. A source row that cannot
/// be read leaves the field null rather than failing the lookup: the decision
/// is the answer, and the URL is a label on it.
fn sourceUrl(state: *server.WebState, arena: Allocator, result: Result) ?[]const u8 {
    const id = result.source_id orelse return null;
    const database = state.config_db orelse return null;
    const row = sources_repo.getSource(database, arena, id) catch |err| {
        log.warn("lookup could not read source {d}: {s}", .{ id, @errorName(err) });
        return null;
    };
    return if (row) |found| found.url else null;
}

// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------

const model = @import("../../config/model.zig");
const testing = std.testing;

const group_ids = [_]i64{ 10, 20 };

/// `Snapshot.Input` has no defaults on purpose — the compiler is what stops the
/// manager from forgetting a table. A test that cares about one table would
/// still have to spell out the other eight, so they are spelled out once here.
const Fixture = struct {
    groups: []const model.Group,
    group_ids: []const i64,
    group_sources: []const model.GroupSource = &.{},
    sources: []const model.BlocklistSource = &.{},
    source_ids: []const i64 = &.{},
    rules: []const model.Rule = &.{},
    compiled: []const ?matcher.Snapshot.Compiled = &.{},
};

fn buildSnapshot(fixture: Fixture) !matcher.Snapshot {
    return matcher.Snapshot.build(testing.allocator, .{
        .groups = fixture.groups,
        .group_ids = fixture.group_ids,
        .group_sources = fixture.group_sources,
        .sources = fixture.sources,
        .source_ids = fixture.source_ids,
        .rules = fixture.rules,
        .clients = &.{},
        .prefixes = &.{},
        .compiled = fixture.compiled,
        .seed = 1,
        .generation = 1,
    });
}

test "a name nothing matches is allowed, with no reason and no source" {
    const groups = [_]model.Group{.{ .name = "default" }};
    var snapshot = try buildSnapshot(.{ .groups = &groups, .group_ids = group_ids[0..1] });
    defer snapshot.deinit();

    const result = evaluate(&snapshot, snapshot.default_group, "example.com", &empty_records, &empty_zones);
    try testing.expectEqual(@as(i64, 10), result.group_id);
    try testing.expect(!result.blocked);
    try testing.expectEqual(matcher.Reason.none, result.reason);
    try testing.expectEqualStrings("", result.matched);
    try testing.expectEqual(@as(?i64, null), result.source_id);
    try testing.expectEqual(@as(?[]const u8, null), result.forward_zone);
    try testing.expect(!result.local_records);
    try testing.expectEqual(@as(?[]const u8, null), result.safe_search_rewrite);
}

test "a blocking rule names itself and the pattern that matched" {
    const groups = [_]model.Group{.{ .name = "default" }};
    const rules = [_]model.Rule{
        .{ .group = "default", .pattern = "ads.example", .kind = .exact, .action = .block },
    };
    var snapshot = try buildSnapshot(.{
        .groups = &groups,
        .group_ids = group_ids[0..1],
        .rules = &rules,
    });
    defer snapshot.deinit();

    const result = evaluate(&snapshot, 0, "ads.example", &empty_records, &empty_zones);
    try testing.expect(result.blocked);
    try testing.expectEqual(matcher.Reason.rule_block_exact, result.reason);
    try testing.expectEqualStrings("ads.example", result.matched);

    const rendered = body("ads.example", result, "https://lists.test/a");
    try testing.expectEqualStrings("rule_block_exact", rendered.reason);
    try testing.expectEqualStrings("https://lists.test/a", rendered.source_url.?);
}

test "a blocklist hit carries the source row id the URL is read from" {
    const groups = [_]model.Group{.{ .name = "default" }};
    const sources = [_]model.BlocklistSource{.{ .url = "https://lists.test/a", .name = "list a" }};
    const group_sources = [_]model.GroupSource{
        .{ .group = "default", .source_url = "https://lists.test/a" },
    };
    var snapshot = try buildSnapshot(.{
        .groups = &groups,
        .group_ids = group_ids[0..1],
        .group_sources = &group_sources,
        .sources = &sources,
        .source_ids = &.{77},
        .compiled = &.{.{ .list_body = "blocked.example\n", .wild_body = "" }},
    });
    defer snapshot.deinit();

    const result = evaluate(&snapshot, 0, "blocked.example", &empty_records, &empty_zones);
    try testing.expect(result.blocked);
    try testing.expectEqual(matcher.Reason.blocklist_domain, result.reason);
    try testing.expectEqual(@as(?i64, 77), result.source_id);
}

test "local records and forward zones are reported beside the decision" {
    const groups = [_]model.Group{.{ .name = "default" }};
    var snapshot = try buildSnapshot(.{ .groups = &groups, .group_ids = group_ids[0..1] });
    defer snapshot.deinit();

    var records = try records_mod.Records.build(testing.allocator, &.{
        .{ .name = "nas.lan.home", .rtype = .a, .value = "192.168.1.10" },
    });
    defer records.deinit(testing.allocator);

    var zones = try forward_zones.Zones.build(testing.allocator, &.{
        .{ .zone = "lan.home", .resolver = "udp://192.168.1.1:53" },
    });
    defer zones.deinit(testing.allocator);

    const local = evaluate(&snapshot, 0, "nas.lan.home", &records, &zones);
    try testing.expect(local.local_records);
    try testing.expectEqualStrings("lan.home", local.forward_zone.?);

    const zone_only = evaluate(&snapshot, 0, "printer.lan.home", &records, &zones);
    try testing.expect(!zone_only.local_records);
    try testing.expectEqualStrings("lan.home", zone_only.forward_zone.?);

    const neither = evaluate(&snapshot, 0, "example.com", &records, &zones);
    try testing.expect(!neither.local_records);
    try testing.expectEqual(@as(?[]const u8, null), neither.forward_zone);
}

test "safe search is reported only for a group that has it on" {
    const groups = [_]model.Group{
        .{ .name = "default" },
        .{ .name = "kids", .safe_search = true },
    };
    var snapshot = try buildSnapshot(.{ .groups = &groups, .group_ids = &group_ids });
    defer snapshot.deinit();

    const off = evaluate(&snapshot, 0, "www.google.com", &empty_records, &empty_zones);
    try testing.expectEqual(@as(?[]const u8, null), off.safe_search_rewrite);

    const on = evaluate(&snapshot, 1, "www.google.com", &empty_records, &empty_zones);
    try testing.expectEqualStrings(safesearch.lookup("www.google.com").?, on.safe_search_rewrite.?);
    try testing.expectEqual(@as(i64, 20), on.group_id);

    // A name safe search says nothing about stays null even in that group.
    const unrelated = evaluate(&snapshot, 1, "example.com", &empty_records, &empty_zones);
    try testing.expectEqual(@as(?[]const u8, null), unrelated.safe_search_rewrite);
}

test "a requested group is resolved by row id, not by index" {
    const groups = [_]model.Group{
        .{ .name = "default" },
        .{ .name = "kids", .safe_search = true },
    };
    var snapshot = try buildSnapshot(.{ .groups = &groups, .group_ids = &group_ids });
    defer snapshot.deinit();

    try testing.expectEqual(@as(?u32, 1), snapshot.groupIndexById(20));
    try testing.expectEqual(@as(?u32, null), snapshot.groupIndexById(999));
}

test "the body serializes with the fields ruling 14 names" {
    const groups = [_]model.Group{.{ .name = "default" }};
    var snapshot = try buildSnapshot(.{ .groups = &groups, .group_ids = group_ids[0..1] });
    defer snapshot.deinit();

    const result = evaluate(&snapshot, 0, "example.com", &empty_records, &empty_zones);

    var allocating: std.Io.Writer.Allocating = .init(testing.allocator);
    defer allocating.deinit();
    try std.json.Stringify.value(body("example.com", result, null), .{}, &allocating.writer);
    const text = allocating.written();

    for ([_][]const u8{
        "\"domain\":\"example.com\"",
        "\"group_id\":10",
        "\"local_records\":false",
        "\"forward_zone\":null",
        "\"blocked\":false",
        "\"reason\":\"none\"",
        "\"matched\":\"\"",
        "\"source_url\":null",
        "\"safe_search_rewrite\":null",
    }) |fragment| {
        try testing.expect(std.mem.containsAtLeast(u8, text, 1, fragment));
    }
}
