//! What every `insert*` needs and the config model deliberately omits.
//!
//! `config/model.zig` carries no row ids and no timestamps: ids are not stable
//! across an import, and `first_seen` / `last_seen` / `created_at` are facts a
//! running server produces. Every insert that writes one of those columns reads
//! it from here instead.
//!
//! Building the id maps is the caller's job (S5): only the caller knows the ids
//! of the parent rows it just inserted.

const std = @import("std");

const safe_url = @import("../../safe_url.zig");

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

/// Group name → `groups.id`, or blocklist source URL → `blocklist_sources.id`.
pub const IdMap = std.StringHashMapUnmanaged(i64);

const no_ids: IdMap = .empty;

/// The line either lookup writes when the caller's map lacks a name, as a value
/// rather than a format string at each call site.
///
/// Two reasons, in that order. A blocklist source url is operator-supplied and
/// nothing on the way in stops it carrying a credential in its userinfo, its
/// path or its query, so it reaches the log through `safe_url.redact` and
/// through nothing else. And a `std.log` line is not readable from a unit test
/// under the default test runner, which installs its own `std_options`; the
/// tests below read this value instead of stderr.
const MissingId = union(enum) {
    /// A group name, out of the configuration file or a `groups` row.
    group: []const u8,
    /// A blocklist source url, out of the configuration file or a
    /// `blocklist_sources` row.
    source: []const u8,

    pub fn format(self: MissingId, w: *std.Io.Writer) std.Io.Writer.Error!void {
        switch (self) {
            // A group name holds no credential, so nothing is dropped from it.
            // It goes through `quoteText` for what that does to any
            // operator-supplied string: it delimits a name with a space in it,
            // escapes a `'` that would otherwise close the delimiter, and bounds
            // a name of any length. The quotes are the type's own, so this
            // format string adds none.
            .group => |name| try w.print("no group id for {f}", .{safe_url.quoteText(name)}),
            // Redaction costs this line the component that told two sources on
            // one host apart, and there is no row id to name the source by
            // instead: the id the other call sites print is the one this call
            // failed to find. The scheme, the host and the port are what is
            // left. The rule against writing a secret to a log does not bend for
            // a line that would read better with one.
            .source => |url| try w.print("no blocklist source id for {f}", .{safe_url.redact(url)}),
        }
    }
};

pub const InsertContext = struct {
    /// Unix epoch seconds, from `std.Io.Clock.real.now(io).toSeconds()`.
    now: i64 = 0,
    group_ids: *const IdMap = &no_ids,
    source_ids: *const IdMap = &no_ids,

    /// `error.NotFound` means the caller's map lacks a name the validator has
    /// already proven the config declares. It is reported rather than asserted
    /// so a caller bug aborts the import transaction instead of the process.
    /// `NotFound` is a member of `db.Error`, so it needs no wider error set.
    pub fn groupId(self: InsertContext, name: []const u8) error{NotFound}!i64 {
        return self.group_ids.get(name) orelse {
            log.warn("{f}", .{MissingId{ .group = name }});
            return error.NotFound;
        };
    }

    /// `error.NotFound` means what it means in `groupId`, for the map keyed by
    /// source url.
    pub fn sourceId(self: InsertContext, url: []const u8) error{NotFound}!i64 {
        return self.source_ids.get(url) orelse {
            log.warn("{f}", .{MissingId{ .source = url }});
            return error.NotFound;
        };
    }
};

const testing = std.testing;

fn expectLine(expected: []const u8, missing: MissingId) !void {
    var buf: [8 * safe_url.max_len]u8 = undefined;
    try testing.expectEqualStrings(expected, try std.fmt.bufPrint(&buf, "{f}", .{missing}));
}

test "a missing source id names where the url points and not what it carries" {
    // The three components a blocklist url carries a credential in, on the one
    // line that used to print all three: an api key in the query, userinfo, and
    // a token in a path segment.
    try expectLine(
        "no blocklist source id for https://lists.example",
        .{ .source = "https://lists.example/hosts.txt?apikey=s3cr3t" },
    );
    try expectLine(
        "no blocklist source id for https://lists.example:8443",
        .{ .source = "https://user:pa55@lists.example:8443/hosts.txt" },
    );
    try expectLine(
        "no blocklist source id for https://lists.example",
        .{ .source = "https://lists.example/download/token/hunter2/hosts.txt" },
    );
    // What an operator still gets: the scheme, the host and the port.
    try expectLine(
        "no blocklist source id for http://10.0.0.2:8080",
        .{ .source = "http://10.0.0.2:8080/a/hosts.txt" },
    );
}

test "a missing group id quotes, escapes and bounds the name" {
    try expectLine("no group id for 'kids'", .{ .group = "kids" });
    // A name is database text as well as file text, so a newline in it would end
    // this line and start one of the operator's choosing.
    try expectLine(
        "no group id for 'ads\\n2026-01-01 ERROR forged'",
        .{ .group = "ads\n2026-01-01 ERROR forged" },
    );
    // Nor can a name close the quote around it.
    try expectLine("no group id for 'kids\\' --'", .{ .group = "kids' --" });
    const long_name = "n" ** (2 * safe_url.max_len);
    try expectLine("no group id for '" ++ long_name[0..safe_url.max_len] ++ "...'", .{ .group = long_name });
}

test "an InsertContext with no maps reports a missing id rather than trapping" {
    const ctx: InsertContext = .{};
    try testing.expectError(error.NotFound, ctx.groupId("default"));
    try testing.expectError(error.NotFound, ctx.sourceId("https://example.test/list.txt"));
}

test "InsertContext resolves names through the caller's maps" {
    var groups: IdMap = .empty;
    defer groups.deinit(testing.allocator);
    try groups.put(testing.allocator, "default", 1);

    var sources: IdMap = .empty;
    defer sources.deinit(testing.allocator);
    try sources.put(testing.allocator, "https://example.test/list.txt", 7);

    const ctx: InsertContext = .{ .now = 1700000000, .group_ids = &groups, .source_ids = &sources };
    try testing.expectEqual(@as(i64, 1), try ctx.groupId("default"));
    try testing.expectEqual(@as(i64, 7), try ctx.sourceId("https://example.test/list.txt"));
    try testing.expectError(error.NotFound, ctx.groupId("kids"));
}
