//! `blocklist_sources`.
//!
//! Only the four configuration columns are read and written. `last_updated`,
//! `domain_count`, `wildcard_count`, `exception_count`, `skipped_regex_count`,
//! `skipped_unsupported_count` and `checksum` are facts a running server
//! produces; an insert leaves them at their column defaults so two exports taken
//! minutes apart stay identical.
//!
//! The import path is list / insert / deleteAll / count; the runtime columns and
//! the REST surface follow it, both keyed by row id.

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

const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const crud = @import("crud.zig");

const InsertContext = context.InsertContext;

const list_sql =
    \\SELECT url, name, enabled, is_suggested FROM blocklist_sources ORDER BY url
;

/// Every string in the result is a heap copy owned by `gpa`.
pub fn listBlocklistSources(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.BlocklistSource) {
    return crud.listRows(model.BlocklistSource, database, gpa, list_sql, readBlocklistSource);
}

fn readBlocklistSource(stmt: *db.Stmt, gpa: Allocator) db.Error!model.BlocklistSource {
    const url = try stmt.columnTextAlloc(gpa, 0);
    errdefer gpa.free(url);
    const name = try stmt.columnTextAlloc(gpa, 1);
    errdefer gpa.free(name);
    return .{
        .url = url,
        .name = name,
        .enabled = stmt.columnBool(2),
        .is_suggested = stmt.columnBool(3),
    };
}

pub fn freeBlocklistSources(gpa: Allocator, items: []const model.BlocklistSource) void {
    crud.freeRows(model.BlocklistSource, gpa, items);
}

const insert_sql =
    \\INSERT INTO blocklist_sources (url, name, enabled, is_suggested) VALUES (?1, ?2, ?3, ?4)
;

pub fn insertBlocklistSource(database: *db.Db, item: model.BlocklistSource, ctx: InsertContext) db.Error!void {
    _ = ctx;
    var stmt = try database.prepare(insert_sql);
    defer stmt.deinit();
    try stmt.bindText(1, item.url);
    try stmt.bindText(2, item.name);
    try stmt.bindBool(3, item.enabled);
    try stmt.bindBool(4, item.is_suggested);
    try stmt.exec();
}

pub fn deleteAllBlocklistSources(database: *db.Db) db.Error!void {
    return database.exec("DELETE FROM blocklist_sources;");
}

pub fn countBlocklistSources(database: *db.Db) db.Error!i64 {
    return database.queryInt("SELECT count(*) FROM blocklist_sources");
}

// ---------------------------------------------------------------------------
// runtime columns (milestone 5 S8.1)
// ---------------------------------------------------------------------------
//
// The blocklist manager needs the row id (the compiled files are named after
// it) and the counters the refresh writes. Neither belongs in `model`: an
// export carries configuration, and these are facts a running server produces.
// Both functions below are additive; no export path reads them.

pub const SourceRow = struct {
    id: i64,
    url: []const u8,
    name: []const u8,
    enabled: bool,
    /// Defaulted because the blocklist manager builds `SourceRow` values from
    /// the refresh columns alone; the REST layer is what reads this one.
    is_suggested: bool = false,
    last_updated: ?i64,
    domain_count: i64,
    wildcard_count: i64,
    /// Written `.allow` entries: the `@@||name^` exceptions the list carries.
    /// Defaulted for the same reason `is_suggested` is — the blocklist manager
    /// builds `SourceRow` values from the refresh columns alone.
    exception_count: i64 = 0,
    skipped_regex_count: i64,
    /// Lines the compiler read and could not translate into a DNS decision:
    /// cosmetic element hiding, `$`-modifier rules (save the tolerated
    /// `$important` exception suffix, which lands in `exception_count`), scheme
    /// anchors. Counted and not written, like `skipped_regex_count` and unlike
    /// the three counts above.
    skipped_unsupported_count: i64,
    checksum: ?[]const u8,
};

pub const SourceStats = struct {
    last_updated: i64,
    domain_count: i64,
    wildcard_count: i64,
    exception_count: i64,
    skipped_regex_count: i64,
    skipped_unsupported_count: i64,
    /// Lowercase hex sha256 over the `.list` body, then the `.wild` body, then
    /// the `.allow` body, each followed by `compiler.body_separator` so the
    /// digest cannot confuse a name in one body with the same name in another.
    checksum: []const u8,
};

const row_columns_sql =
    \\SELECT id, url, name, enabled, last_updated,
    \\       domain_count, wildcard_count, skipped_regex_count, checksum,
    \\       is_suggested, exception_count, skipped_unsupported_count
    \\  FROM blocklist_sources
;

const list_rows_sql = row_columns_sql ++ " ORDER BY url";

/// Every source with its row id and its runtime columns, in the same `url`
/// order `listBlocklistSources` uses. Every string is a heap copy owned by
/// `gpa`; free the whole list with `freeSourceRows` and then `deinit` the list.
pub fn listSourceRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(SourceRow) {
    return crud.listRows(SourceRow, database, gpa, list_rows_sql, readSourceRow);
}

fn readSourceRow(stmt: *db.Stmt, gpa: Allocator) db.Error!SourceRow {
    const url = try stmt.columnTextAlloc(gpa, 1);
    errdefer gpa.free(url);
    const name = try stmt.columnTextAlloc(gpa, 2);
    errdefer gpa.free(name);
    const checksum = try stmt.columnTextAllocOrNull(gpa, 8);
    errdefer if (checksum) |value| gpa.free(value);
    return .{
        .id = stmt.columnInt(0),
        .url = url,
        .name = name,
        .enabled = stmt.columnBool(3),
        .is_suggested = stmt.columnBool(9),
        .last_updated = if (stmt.isNull(4)) null else stmt.columnInt(4),
        .domain_count = stmt.columnInt(5),
        .wildcard_count = stmt.columnInt(6),
        .exception_count = stmt.columnInt(10),
        .skipped_regex_count = stmt.columnInt(7),
        .skipped_unsupported_count = stmt.columnInt(11),
        .checksum = checksum,
    };
}

/// Frees `url`, `name` and the `checksum` payload when it is not null.
pub fn freeSourceRow(gpa: Allocator, row: SourceRow) void {
    crud.freeRow(SourceRow, gpa, row);
}

pub fn freeSourceRows(gpa: Allocator, items: []const SourceRow) void {
    crud.freeRows(SourceRow, gpa, items);
}

const update_stats_sql =
    \\UPDATE blocklist_sources
    \\   SET last_updated = ?2, domain_count = ?3, wildcard_count = ?4,
    \\       skipped_regex_count = ?5, checksum = ?6, exception_count = ?7,
    \\       skipped_unsupported_count = ?8
    \\ WHERE id = ?1
;

/// Writes the runtime columns for one source after a compile. The configuration
/// columns (`url`, `name`, `enabled`, `is_suggested`) are untouched.
pub fn updateSourceStats(database: *db.Db, id: i64, stats: SourceStats) db.Error!void {
    var stmt = try database.prepare(update_stats_sql);
    defer stmt.deinit();
    try stmt.bindInt(1, id);
    try stmt.bindInt(2, stats.last_updated);
    try stmt.bindInt(3, stats.domain_count);
    try stmt.bindInt(4, stats.wildcard_count);
    try stmt.bindInt(5, stats.skipped_regex_count);
    try stmt.bindText(6, stats.checksum);
    try stmt.bindInt(7, stats.exception_count);
    try stmt.bindInt(8, stats.skipped_unsupported_count);
    try stmt.exec();
}

// ---------------------------------------------------------------------------
// REST surface (milestone 8)
// ---------------------------------------------------------------------------
//
// `/api/blocklists` is this table. The read shape is `SourceRow` above — the
// UI wants the counters next to the configuration — and the write shape is
// `model.BlocklistSource`, whose four fields are the four columns an operator
// may set.

pub fn getSource(database: *db.Db, gpa: Allocator, id: i64) db.Error!?SourceRow {
    var stmt = try database.prepare(row_columns_sql ++ " WHERE id = ?1");
    defer stmt.deinit();
    try stmt.bindInt(1, id);
    if (!try stmt.step()) return null;
    return try readSourceRow(&stmt, gpa);
}

/// The runtime columns stay at their defaults, so a source added through the
/// API looks exactly like an imported one until the first refresh.
///
/// `error.Constraint`: `blocklist_sources.url` is UNIQUE.
pub fn insertSourceRow(database: *db.Db, item: model.BlocklistSource) db.Error!i64 {
    var stmt = try database.prepare(insert_sql);
    defer stmt.deinit();
    try stmt.bindText(1, item.url);
    try stmt.bindText(2, item.name);
    try stmt.bindBool(3, item.enabled);
    try stmt.bindBool(4, item.is_suggested);
    try stmt.exec();
    return database.lastInsertRowid();
}

const update_source_sql =
    \\UPDATE blocklist_sources
    \\   SET url = ?2, name = ?3, enabled = ?4, is_suggested = ?5
    \\ WHERE id = ?1
;

/// Writes the four configuration columns. The runtime columns are the refresh
/// path's and stay as they are — even when `url` changes, because the next
/// refresh compares checksums and replaces them anyway.
///
/// `error.NotFound`: no source holds `id`. `error.Constraint`:
/// `blocklist_sources.url` is UNIQUE.
pub fn updateSource(database: *db.Db, id: i64, item: model.BlocklistSource) db.Error!void {
    var stmt = try database.prepare(update_source_sql);
    defer stmt.deinit();
    try stmt.bindInt(1, id);
    try stmt.bindText(2, item.url);
    try stmt.bindText(3, item.name);
    try stmt.bindBool(4, item.enabled);
    try stmt.bindBool(5, item.is_suggested);
    return crud.execStrict(database, &stmt);
}

/// `error.NotFound`: no source holds `id`. `group_sources` references it
/// `ON DELETE CASCADE`, so every group's assignment loses it silently and no
/// constraint can fire.
pub fn deleteSource(database: *db.Db, id: i64) db.Error!void {
    var stmt = try database.prepare("DELETE FROM blocklist_sources WHERE id = ?1");
    defer stmt.deinit();
    try stmt.bindInt(1, id);
    return crud.execStrict(database, &stmt);
}

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

const testing = std.testing;

fn openMigrated() !db.Db {
    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    errdefer database.close();
    try db.applyPragmas(&database, .{});
    _ = try migrations.migrate(&database);
    return database;
}

fn seedSources(database: *db.Db) !void {
    const ctx: InsertContext = .{};
    try insertBlocklistSource(database, .{
        .url = "https://c.example/list.txt",
        .name = "C list",
    }, ctx);
    try insertBlocklistSource(database, .{
        .url = "https://a.example/list.txt",
        .name = "A list",
        .enabled = false,
    }, ctx);
    try insertBlocklistSource(database, .{
        .url = "https://b.example/list.txt",
        .name = "B list",
        .is_suggested = true,
    }, ctx);
}

test "blocklist_sources round-trip in url order" {
    var database = try openMigrated();
    defer database.close();
    try seedSources(&database);

    var items = try listBlocklistSources(&database, testing.allocator);
    defer items.deinit(testing.allocator);
    defer freeBlocklistSources(testing.allocator, items.items);

    try testing.expectEqual(@as(usize, 3), items.items.len);
    try testing.expectEqualStrings("https://a.example/list.txt", items.items[0].url);
    try testing.expectEqualStrings("A list", items.items[0].name);
    try testing.expect(!items.items[0].enabled);
    try testing.expect(!items.items[0].is_suggested);
    try testing.expectEqualStrings("https://b.example/list.txt", items.items[1].url);
    try testing.expectEqualStrings("B list", items.items[1].name);
    try testing.expect(items.items[1].enabled);
    try testing.expect(items.items[1].is_suggested);
    try testing.expectEqualStrings("https://c.example/list.txt", items.items[2].url);
    try testing.expectEqualStrings("C list", items.items[2].name);
    try testing.expect(items.items[2].enabled);
    try testing.expect(!items.items[2].is_suggested);
}

test "insertBlocklistSource leaves the runtime columns at their defaults" {
    var database = try openMigrated();
    defer database.close();
    try seedSources(&database);

    try testing.expectEqual(
        @as(i64, 3),
        try database.queryInt("SELECT count(*) FROM blocklist_sources WHERE last_updated IS NULL"),
    );
    try testing.expectEqual(
        @as(i64, 3),
        try database.queryInt("SELECT count(*) FROM blocklist_sources WHERE checksum IS NULL"),
    );
    try testing.expectEqual(
        @as(i64, 0),
        try database.queryInt(
            // Every runtime counter, summed only because this asserts they are
            // all 0. No production query may add the two skip counters to the
            // three written ones: a skipped line was never written.
            "SELECT sum(domain_count + wildcard_count + exception_count + skipped_regex_count" ++
                " + skipped_unsupported_count) FROM blocklist_sources",
        ),
    );
}

test "deleteAllBlocklistSources empties the table and countBlocklistSources reflects it" {
    var database = try openMigrated();
    defer database.close();
    try seedSources(&database);

    try testing.expectEqual(@as(i64, 3), try countBlocklistSources(&database));
    try deleteAllBlocklistSources(&database);
    try testing.expectEqual(@as(i64, 0), try countBlocklistSources(&database));
}

fn listBlocklistSourcesUnderFailure(gpa: Allocator) !void {
    var database = try openMigrated();
    defer database.close();
    try seedSources(&database);

    var items = try listBlocklistSources(&database, gpa);
    defer items.deinit(gpa);
    defer freeBlocklistSources(gpa, items.items);
}

test "listBlocklistSources is leak-safe under allocation failure" {
    try testing.checkAllAllocationFailures(testing.allocator, listBlocklistSourcesUnderFailure, .{});
}

test "listSourceRows returns row ids and the runtime columns in url order" {
    var database = try openMigrated();
    defer database.close();
    try seedSources(&database);

    var rows = try listSourceRows(&database, testing.allocator);
    defer rows.deinit(testing.allocator);
    defer freeSourceRows(testing.allocator, rows.items);

    try testing.expectEqual(@as(usize, 3), rows.items.len);
    try testing.expectEqualStrings("https://a.example/list.txt", rows.items[0].url);
    try testing.expectEqualStrings("https://b.example/list.txt", rows.items[1].url);
    try testing.expectEqualStrings("https://c.example/list.txt", rows.items[2].url);
    try testing.expectEqualStrings("A list", rows.items[0].name);
    try testing.expect(!rows.items[0].enabled);
    try testing.expect(rows.items[1].enabled);

    for (rows.items) |row| {
        try testing.expect(row.id > 0);
        try testing.expectEqual(@as(?i64, null), row.last_updated);
        try testing.expectEqual(@as(?[]const u8, null), row.checksum);
        try testing.expectEqual(@as(i64, 0), row.domain_count);
        try testing.expectEqual(@as(i64, 0), row.wildcard_count);
        try testing.expectEqual(@as(i64, 0), row.exception_count);
        try testing.expectEqual(@as(i64, 0), row.skipped_regex_count);
        try testing.expectEqual(@as(i64, 0), row.skipped_unsupported_count);
    }
}

test "updateSourceStats writes the runtime columns of one source only" {
    var database = try openMigrated();
    defer database.close();
    try seedSources(&database);

    var before = try listSourceRows(&database, testing.allocator);
    defer before.deinit(testing.allocator);
    defer freeSourceRows(testing.allocator, before.items);

    const target = before.items[1];
    try updateSourceStats(&database, target.id, .{
        .last_updated = 1_700_000_000,
        .domain_count = 4321,
        .wildcard_count = 21,
        .exception_count = 9,
        .skipped_regex_count = 7,
        .skipped_unsupported_count = 15,
        .checksum = "a" ** 64,
    });

    var after = try listSourceRows(&database, testing.allocator);
    defer after.deinit(testing.allocator);
    defer freeSourceRows(testing.allocator, after.items);

    const updated = after.items[1];
    try testing.expectEqual(target.id, updated.id);
    try testing.expectEqualStrings("https://b.example/list.txt", updated.url);
    try testing.expectEqualStrings("B list", updated.name);
    try testing.expect(updated.enabled);
    try testing.expectEqual(@as(?i64, 1_700_000_000), updated.last_updated);
    try testing.expectEqual(@as(i64, 4321), updated.domain_count);
    try testing.expectEqual(@as(i64, 21), updated.wildcard_count);
    try testing.expectEqual(@as(i64, 9), updated.exception_count);
    try testing.expectEqual(@as(i64, 7), updated.skipped_regex_count);
    try testing.expectEqual(@as(i64, 15), updated.skipped_unsupported_count);
    try testing.expectEqualStrings("a" ** 64, updated.checksum.?);

    // The two untouched rows kept their defaults.
    try testing.expectEqual(@as(?i64, null), after.items[0].last_updated);
    try testing.expectEqual(@as(?[]const u8, null), after.items[2].checksum);
}

fn listSourceRowsUnderFailure(gpa: Allocator) !void {
    var database = try openMigrated();
    defer database.close();
    try seedSources(&database);
    try updateSourceStats(&database, 1, .{
        .last_updated = 1,
        .domain_count = 2,
        .wildcard_count = 3,
        .exception_count = 5,
        .skipped_regex_count = 4,
        .skipped_unsupported_count = 6,
        .checksum = "b" ** 64,
    });

    var rows = try listSourceRows(&database, gpa);
    defer rows.deinit(gpa);
    defer freeSourceRows(gpa, rows.items);

    // `checksum` is the one allocated optional in this directory. Without a
    // non-null one in the result the injection never reaches its allocation and
    // this test stops covering the shape it exists for. Row id 1 sorts last:
    // `seedSources` inserts `c.example` first and the list orders by url.
    try testing.expect(rows.items[2].checksum != null);
}

test "listSourceRows is leak-safe under allocation failure" {
    try testing.checkAllAllocationFailures(testing.allocator, listSourceRowsUnderFailure, .{});
}

// --- REST surface ----------------------------------------------------------

test "a blocklist source round-trips through insert, get, list, update and delete" {
    var database = try openMigrated();
    defer database.close();

    const id = try insertSourceRow(&database, .{
        .url = "https://lists.example/hosts.txt",
        .name = "Example",
        .is_suggested = true,
    });

    const fetched = (try getSource(&database, testing.allocator, id)).?;
    defer freeSourceRow(testing.allocator, fetched);
    try testing.expectEqual(id, fetched.id);
    try testing.expectEqualStrings("https://lists.example/hosts.txt", fetched.url);
    try testing.expectEqualStrings("Example", fetched.name);
    try testing.expect(fetched.enabled);
    try testing.expect(fetched.is_suggested);
    try testing.expectEqual(@as(?i64, null), fetched.last_updated);

    try updateSource(&database, id, .{
        .url = "https://lists.example/hosts.txt",
        .name = "Example list",
        .enabled = false,
    });
    const updated = (try getSource(&database, testing.allocator, id)).?;
    defer freeSourceRow(testing.allocator, updated);
    try testing.expectEqualStrings("Example list", updated.name);
    try testing.expect(!updated.enabled);
    try testing.expect(!updated.is_suggested);

    try deleteSource(&database, id);
    try testing.expectEqual(@as(?SourceRow, null), try getSource(&database, testing.allocator, id));
    try testing.expectEqual(@as(i64, 0), try countBlocklistSources(&database));
}

test "updateSource leaves the runtime columns where the refresh path left them" {
    var database = try openMigrated();
    defer database.close();

    const id = try insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "A" });
    try updateSourceStats(&database, id, .{
        .last_updated = 1_700_000_000,
        .domain_count = 12,
        .wildcard_count = 3,
        .exception_count = 2,
        .skipped_regex_count = 1,
        .skipped_unsupported_count = 4,
        .checksum = "c" ** 64,
    });

    try updateSource(&database, id, .{ .url = "https://lists.example/b.txt", .name = "B" });

    const row = (try getSource(&database, testing.allocator, id)).?;
    defer freeSourceRow(testing.allocator, row);
    try testing.expectEqualStrings("https://lists.example/b.txt", row.url);
    try testing.expectEqual(@as(?i64, 1_700_000_000), row.last_updated);
    try testing.expectEqual(@as(i64, 12), row.domain_count);
    try testing.expectEqualStrings("c" ** 64, row.checksum.?);
}

test "source update and delete report NotFound for an id no row holds" {
    var database = try openMigrated();
    defer database.close();

    try testing.expectError(
        error.NotFound,
        updateSource(&database, 404, .{ .url = "https://lists.example/a.txt", .name = "A" }),
    );
    try testing.expectError(error.NotFound, deleteSource(&database, 404));
    try testing.expectEqual(@as(?SourceRow, null), try getSource(&database, testing.allocator, 404));
}

test "a duplicate source url surfaces as error.Constraint on insert and on update" {
    var database = try openMigrated();
    defer database.close();

    _ = try insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "A" });
    const other = try insertSourceRow(&database, .{ .url = "https://lists.example/b.txt", .name = "B" });

    try testing.expectError(
        error.Constraint,
        insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "again" }),
    );
    try testing.expectError(
        error.Constraint,
        updateSource(&database, other, .{ .url = "https://lists.example/a.txt", .name = "B" }),
    );
    try testing.expectEqual(@as(i64, 2), try countBlocklistSources(&database));
}

test "deleting a source drops it from every group assignment" {
    var database = try openMigrated();
    defer database.close();

    const id = try insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "A" });
    const keep = try insertSourceRow(&database, .{ .url = "https://lists.example/b.txt", .name = "B" });
    try database.exec("INSERT INTO group_sources (group_id, source_id) VALUES (1, 1), (1, 2);");

    try deleteSource(&database, id);
    try testing.expectEqual(@as(i64, 1), try countGroupSources(&database));
    try testing.expectEqual(
        keep,
        try database.queryInt("SELECT source_id FROM group_sources"),
    );
}

fn countGroupSources(database: *db.Db) db.Error!i64 {
    return database.queryInt("SELECT count(*) FROM group_sources");
}

fn sourceRowUnderFailure(gpa: Allocator) !void {
    var database = try openMigrated();
    defer database.close();
    try seedSources(&database);

    const one = (try getSource(&database, gpa, 1)).?;
    defer freeSourceRow(gpa, one);
}

test "getSource is leak-safe under allocation failure" {
    try testing.checkAllAllocationFailures(testing.allocator, sourceRowUnderFailure, .{});
}
