//! The wire shape of one query's provenance, defined once.
//!
//! Two surfaces answer with it: `GET /api/queries/{id}`, which reads a stored
//! row, and the `event: query` frames of `GET /api/queries/live`, which read a
//! queued entry that has not been written yet. They must describe a query the
//! same way — an operator watching the stream and an operator opening the row
//! afterwards are looking at the same facts — so the live event *is* this DTO
//! and the detail body is this DTO plus the row id.
//!
//! It is nested rather than flat because the six groups answer six different
//! questions, in the order a query meets them: what was asked, which group's
//! policy applied, what that policy decided, what was rewritten on the way,
//! where the answer came from, and what the client got back.
//!
//! The list row (`queries_repo.QueryRow`) stays a separate, flatter summary.
//! A table the operator scans wants columns, not a tree, and the full story is
//! one request away.
//!
//! Every text field follows the repository's convention: a NULL column reads as
//! `""`, and `""` on the wire means "absent". No field is ever written as an
//! empty string that means something else.

const std = @import("std");

const logger = @import("../storage/logger.zig");
const provenance = @import("../storage/provenance.zig");
const queries_repo = @import("../storage/repositories/queries_repo.zig");

/// What the client asked. `time` is unix seconds; `qtype` is null for a
/// question whose type the log never recorded.
pub const Request = struct {
    time: i64,
    domain: []const u8,
    client: []const u8,
    qtype: ?u16,
    qclass: u16,
};

/// The client's filtering group at the time of the query, as a historical fact:
/// the id may name a group that has since been renamed or deleted, which is why
/// the name is stored beside it rather than joined at read time.
pub const Group = struct {
    id: ?i64,
    name: []const u8,
};

/// What the policy decided and what it matched on. `matched` is the rule
/// pattern or list entry that decided; `source_id`/`source_name` name the
/// blocklist it came from, and are absent for a rule.
pub const Policy = struct {
    action: provenance.PolicyAction,
    reason: provenance.PolicyReason,
    matched: []const u8,
    source_id: ?i64,
    source_name: []const u8,
};

/// The two rewrites that can happen between the question and the answer.
/// `cname_target` is set when the decision was made about a CNAME target rather
/// than the queried name.
pub const Rewrites = struct {
    cname_target: []const u8,
    safe_search_target: []const u8,
};

/// Where the answer came from. `upstream` is non-empty only for an attempted
/// upstream or forward-zone exchange, including one that failed, and is already
/// redacted — the path, query and userinfo of a resolver url never reach here.
pub const Route = struct {
    kind: provenance.RouteKind,
    forward_zone: []const u8,
    upstream: []const u8,
};

/// What the client saw. `rcode` is the twelve-bit EDNS extended code, not the
/// four header bits alone.
pub const Response = struct {
    rcode: u16,
    duration_us: ?i64,
};

/// One query, fully explained. The live stream's `data:` payload is exactly
/// this.
pub const Provenance = struct {
    request: Request,
    group: Group,
    policy: Policy,
    rewrites: Rewrites,
    route: Route,
    response: Response,
};

/// `GET /api/queries/{id}`: the same six groups, plus the id the caller asked
/// for. Spelled out rather than composed, because a JSON object is flat at its
/// top level and Zig has no field-splicing; the `comptime` block below is what
/// keeps the two from drifting.
pub const QueryDetail = struct {
    id: i64,
    request: Request,
    group: Group,
    policy: Policy,
    rewrites: Rewrites,
    route: Route,
    response: Response,
};

comptime {
    const detail = @typeInfo(QueryDetail).@"struct".fields;
    const shared = @typeInfo(Provenance).@"struct".fields;
    std.debug.assert(detail.len == shared.len + 1);
    std.debug.assert(std.mem.eql(u8, detail[0].name, "id"));
    for (detail[1..], shared) |a, b| {
        std.debug.assert(std.mem.eql(u8, a.name, b.name));
        std.debug.assert(a.type == b.type);
    }
}

/// A stored row, as the detail endpoint answers it. Borrows `row`'s strings,
/// which the caller's arena owns.
pub fn fromDetail(row: queries_repo.QueryDetail) QueryDetail {
    return .{
        .id = row.id,
        .request = .{
            .time = row.ts,
            .domain = row.domain,
            .client = row.client_ip,
            .qtype = row.qtype,
            .qclass = row.qclass,
        },
        .group = .{ .id = row.group_id, .name = row.group_name },
        .policy = .{
            .action = row.policy_action,
            .reason = row.policy_reason,
            .matched = row.matched,
            .source_id = row.source_id,
            .source_name = row.source_name,
        },
        .rewrites = .{
            .cname_target = row.cname_target,
            .safe_search_target = row.safe_search_target,
        },
        .route = .{
            .kind = row.route_kind,
            .forward_zone = row.forward_zone,
            .upstream = row.upstream,
        },
        .response = .{ .rcode = row.rcode, .duration_us = row.response_time_us },
    };
}

/// A queued entry, as the live stream sends it. Borrows the entry's buffers, so
/// the result must not outlive the entry it was taken from — in the stream both
/// live in one loop iteration.
///
/// There is no id: the entry precedes its own insert, so no row id exists yet.
pub fn fromEntry(entry: *const logger.Entry) Provenance {
    return .{
        .request = .{
            .time = entry.timestamp,
            .domain = entry.domain(),
            .client = entry.clientIp(),
            .qtype = entry.qtype,
            .qclass = entry.qclass,
        },
        .group = .{ .id = entry.group_id, .name = entry.groupName() },
        .policy = .{
            .action = entry.policy_action,
            .reason = entry.policy_reason,
            .matched = entry.matched(),
            .source_id = entry.source_id,
            .source_name = entry.sourceName(),
        },
        .rewrites = .{
            .cname_target = entry.cnameTarget(),
            .safe_search_target = entry.safeSearchTarget(),
        },
        .route = .{
            .kind = entry.route_kind,
            .forward_zone = entry.forwardZone(),
            .upstream = entry.upstream(),
        },
        .response = .{ .rcode = entry.rcode, .duration_us = entry.response_time_us },
    };
}

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

const testing = std.testing;

test "a stored row and a queued entry describe the same query identically" {
    const row: queries_repo.QueryDetail = .{
        .id = 7,
        .ts = 1_700_000_000,
        .domain = "ads.example",
        .client_ip = "192.0.2.10",
        .qtype = 1,
        .qclass = 1,
        .rcode = 3,
        .blocked = true,
        .response_time_us = 1234,
        .cache_hit = false,
        .upstream = "https://dns.example",
        .group_id = 2,
        .group_name = "kids",
        .policy_action = .block,
        .policy_reason = .blocklist_domain,
        .matched = "tracker.example",
        .source_id = 5,
        .source_name = "StevenBlack",
        .cname_target = "tracker.example",
        .safe_search_target = "forcesafesearch.example",
        .route_kind = .blocked,
        .forward_zone = "lan",
    };

    const entry: logger.Entry = .init(.{
        .timestamp = row.ts,
        .domain = row.domain,
        .client_ip = row.client_ip,
        .qtype = row.qtype,
        .qclass = row.qclass,
        .rcode = row.rcode,
        .blocked = row.blocked,
        .response_time_us = row.response_time_us,
        .cache_hit = row.cache_hit,
        .upstream = row.upstream,
        .group_id = row.group_id,
        .group_name = row.group_name,
        .policy_action = row.policy_action,
        .policy_reason = row.policy_reason,
        .matched = row.matched,
        .source_id = row.source_id,
        .source_name = row.source_name,
        .cname_target = row.cname_target,
        .safe_search_target = row.safe_search_target,
        .route_kind = row.route_kind,
        .forward_zone = row.forward_zone,
    });

    const from_row = fromDetail(row);
    const from_entry = fromEntry(&entry);

    try testing.expectEqual(@as(i64, 7), from_row.id);
    inline for (@typeInfo(Provenance).@"struct".fields) |field| {
        const a = @field(from_row, field.name);
        const b = @field(from_entry, field.name);
        inline for (@typeInfo(field.type).@"struct".fields) |inner| {
            const left = @field(a, inner.name);
            const right = @field(b, inner.name);
            if (@TypeOf(left) == []const u8) {
                try testing.expectEqualStrings(left, right);
            } else {
                try testing.expectEqual(left, right);
            }
        }
    }
}

test "an unexplained query serializes as nulls and empty strings, not as absent keys" {
    const entry: logger.Entry = .init(.{
        .timestamp = 1,
        .domain = "safe.example",
        .client_ip = "192.0.2.11",
    });

    var allocating: std.Io.Writer.Allocating = .init(testing.allocator);
    defer allocating.deinit();
    try std.json.Stringify.value(fromEntry(&entry), .{}, &allocating.writer);
    const text = allocating.written();

    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"qtype\":null"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"duration_us\":null"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"id\":null"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"upstream\":\"\""));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"action\":\"not_evaluated\""));
}
