//! `rules`.
//!
//! `rules` carries no `UNIQUE` constraint, so duplicate rules are legal. The
//! list therefore ends its `ORDER BY` with `id`, which is the only column that
//! makes the order — and so an export — deterministic.
//!
//! The list leads with the group *name*, not `group_id`. Ids are assigned by the
//! database and permute when a config is imported into a fresh database, so an
//! order that led with `group_id` would reorder the rules of an export →
//! import → export cycle. The name is the value the export emits, and it is the
//! same in both databases. The trailing `id` is stable for the same reason the
//! order as a whole is: `import` inserts the rules in export order, so the new
//! ids ascend in exactly the order this statement produced.
//!
//! The import path is list / insert / deleteAll / count. The REST surface
//! is the second half of this file: it speaks row ids, because that is what an
//! `/api/rules/{id}` request names.

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 groups_repo = @import("groups_repo.zig");
const crud = @import("crud.zig");

const IdMap = context.IdMap;
const InsertContext = context.InsertContext;

const list_sql =
    \\SELECT g.name, r.pattern, r.kind, r.action FROM rules r
    \\  JOIN groups g ON g.id = r.group_id
    \\ ORDER BY g.name, r.kind, r.action, r.pattern, r.id
;

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

fn readRule(stmt: *db.Stmt, gpa: Allocator) db.Error!model.Rule {
    // The DDL's CHECK constraints make both decodes total for any row nxdns
    // wrote; `error.Mismatch` covers a row that something else wrote.
    const kind = model.RuleKind.fromDb(stmt.columnText(2)) orelse return error.Mismatch;
    const action = model.RuleAction.fromDb(stmt.columnText(3)) orelse return error.Mismatch;
    const group = try stmt.columnTextAlloc(gpa, 0);
    errdefer gpa.free(group);
    const pattern = try stmt.columnTextAlloc(gpa, 1);
    errdefer gpa.free(pattern);
    return .{ .group = group, .pattern = pattern, .kind = kind, .action = action };
}

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

const insert_sql =
    \\INSERT INTO rules (group_id, pattern, kind, action, created_at) VALUES (?1, ?2, ?3, ?4, ?5)
;

pub fn insertRule(database: *db.Db, item: model.Rule, ctx: InsertContext) db.Error!void {
    const group_id = try ctx.groupId(item.group);

    var stmt = try database.prepare(insert_sql);
    defer stmt.deinit();
    try stmt.bindInt(1, group_id);
    try stmt.bindText(2, item.pattern);
    try stmt.bindText(3, item.kind.toDb());
    try stmt.bindText(4, item.action.toDb());
    try stmt.bindInt(5, ctx.now);
    try stmt.exec();
}

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

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

// ---------------------------------------------------------------------------
// REST surface (milestone 8)
// ---------------------------------------------------------------------------
//
// The read shape carries both `group_id` and the group name: the UI groups the
// rules it lists, and the client that edits one sends an id back. The write
// shape carries only the id, so a group that does not exist surfaces as the
// foreign-key violation it is instead of a lookup miss.

pub const RuleRow = struct {
    id: i64,
    group_id: i64,
    group: []const u8,
    pattern: []const u8,
    kind: model.RuleKind,
    action: model.RuleAction,
    created_at: i64,
};

pub const RuleInput = struct {
    group_id: i64,
    pattern: []const u8,
    kind: model.RuleKind,
    action: model.RuleAction,
};

const list_rule_rows_sql =
    \\SELECT r.id, r.group_id, g.name, r.pattern, r.kind, r.action, r.created_at FROM rules r
    \\  JOIN groups g ON g.id = r.group_id
    \\ ORDER BY g.name, r.kind, r.action, r.pattern, r.id
;

const get_rule_sql =
    \\SELECT r.id, r.group_id, g.name, r.pattern, r.kind, r.action, r.created_at FROM rules r
    \\  JOIN groups g ON g.id = r.group_id
    \\ WHERE r.id = ?1
;

/// Same order as `listRules`; every string is a heap copy owned by `gpa`.
pub fn listRuleRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(RuleRow) {
    return crud.listRows(RuleRow, database, gpa, list_rule_rows_sql, readRuleRow);
}

pub fn freeRuleRow(gpa: Allocator, row: RuleRow) void {
    crud.freeRow(RuleRow, gpa, row);
}

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

pub fn getRule(database: *db.Db, gpa: Allocator, id: i64) db.Error!?RuleRow {
    var stmt = try database.prepare(get_rule_sql);
    defer stmt.deinit();
    try stmt.bindInt(1, id);
    if (!try stmt.step()) return null;
    return try readRuleRow(&stmt, gpa);
}

fn readRuleRow(stmt: *db.Stmt, gpa: Allocator) db.Error!RuleRow {
    // The DDL's CHECK constraints make both decodes total for any row nxdns
    // wrote; `error.Mismatch` covers a row that something else wrote.
    const kind = model.RuleKind.fromDb(stmt.columnText(4)) orelse return error.Mismatch;
    const action = model.RuleAction.fromDb(stmt.columnText(5)) orelse return error.Mismatch;
    const group = try stmt.columnTextAlloc(gpa, 2);
    errdefer gpa.free(group);
    const pattern = try stmt.columnTextAlloc(gpa, 3);
    errdefer gpa.free(pattern);
    return .{
        .id = stmt.columnInt(0),
        .group_id = stmt.columnInt(1),
        .group = group,
        .pattern = pattern,
        .kind = kind,
        .action = action,
        .created_at = stmt.columnInt(6),
    };
}

/// `now_s` is unix epoch seconds, from `std.Io.Clock.real`; it becomes
/// `created_at`, the column that dates a rule for the operator.
///
/// `error.Constraint`: `group_id` names no group. `rules` has no UNIQUE
/// constraint, so a rule identical to one already stored is accepted — the
/// table has always allowed that.
pub fn insertRuleRow(database: *db.Db, item: RuleInput, now_s: i64) db.Error!i64 {
    var stmt = try database.prepare(insert_sql);
    defer stmt.deinit();
    try stmt.bindInt(1, item.group_id);
    try stmt.bindText(2, item.pattern);
    try stmt.bindText(3, item.kind.toDb());
    try stmt.bindText(4, item.action.toDb());
    try stmt.bindInt(5, now_s);
    try stmt.exec();
    return database.lastInsertRowid();
}

const update_rule_sql =
    \\UPDATE rules SET group_id = ?2, pattern = ?3, kind = ?4, action = ?5 WHERE id = ?1
;

/// `created_at` is when the rule was written, not when it was last touched, so
/// an edit leaves it alone.
///
/// `error.NotFound`: no rule holds `id`. `error.Constraint`: `group_id` names no
/// group.
pub fn updateRule(database: *db.Db, id: i64, item: RuleInput) db.Error!void {
    var stmt = try database.prepare(update_rule_sql);
    defer stmt.deinit();
    try stmt.bindInt(1, id);
    try stmt.bindInt(2, item.group_id);
    try stmt.bindText(3, item.pattern);
    try stmt.bindText(4, item.kind.toDb());
    try stmt.bindText(5, item.action.toDb());
    return crud.execStrict(database, &stmt);
}

/// `error.NotFound`: no rule holds `id`. Nothing references `rules`, so a delete
/// cannot violate a constraint.
pub fn deleteRule(database: *db.Db, id: i64) db.Error!void {
    var stmt = try database.prepare("DELETE FROM rules 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 seedGroupIds() !IdMap {
    var ids: IdMap = .empty;
    errdefer ids.deinit(testing.allocator);
    try ids.put(testing.allocator, "default", 1);
    return ids;
}

fn seedRules(database: *db.Db, ids: *const IdMap) !void {
    const ctx: InsertContext = .{ .now = 1700000000, .group_ids = ids };
    try insertRule(database, .{
        .group = "default",
        .pattern = "*.ads.example",
        .kind = .wildcard,
        .action = .block,
    }, ctx);
    try insertRule(database, .{
        .group = "default",
        .pattern = "tracker.example",
        .kind = .exact,
        .action = .block,
    }, ctx);
    try insertRule(database, .{
        .group = "default",
        .pattern = "allowed.example",
        .kind = .exact,
        .action = .allow,
    }, ctx);
}

test "rules round-trip in group, kind, action, pattern, id order" {
    var database = try openMigrated();
    defer database.close();
    var ids = try seedGroupIds();
    defer ids.deinit(testing.allocator);
    try seedRules(&database, &ids);

    var items = try listRules(&database, testing.allocator);
    defer items.deinit(testing.allocator);
    defer freeRules(testing.allocator, items.items);

    // One group, so `kind` leads: 'exact' before 'wildcard'; inside 'exact',
    // 'allow' before 'block'.
    try testing.expectEqual(@as(usize, 3), items.items.len);
    try testing.expectEqualStrings("allowed.example", items.items[0].pattern);
    try testing.expectEqual(model.RuleKind.exact, items.items[0].kind);
    try testing.expectEqual(model.RuleAction.allow, items.items[0].action);
    try testing.expectEqualStrings("default", items.items[0].group);
    try testing.expectEqualStrings("tracker.example", items.items[1].pattern);
    try testing.expectEqual(model.RuleKind.exact, items.items[1].kind);
    try testing.expectEqual(model.RuleAction.block, items.items[1].action);
    try testing.expectEqualStrings("*.ads.example", items.items[2].pattern);
    try testing.expectEqual(model.RuleKind.wildcard, items.items[2].kind);
    try testing.expectEqual(model.RuleAction.block, items.items[2].action);

    try testing.expectEqual(
        @as(i64, 1700000000),
        try database.queryInt("SELECT created_at FROM rules WHERE pattern = 'tracker.example'"),
    );
}

test "a regex rule round-trips through the table with its pattern untouched" {
    var database = try openMigrated();
    defer database.close();
    var ids = try seedGroupIds();
    defer ids.deinit(testing.allocator);

    // Uppercase, a trailing metacharacter and a backslash escape: everything
    // the name-shaped kinds normalize away and this one must not.
    const pattern = "^AD[0-9]+-\\.example\\.";
    try insertRule(&database, .{
        .group = "default",
        .pattern = pattern,
        .kind = .regex,
        .action = .block,
    }, .{ .now = 1700000000, .group_ids = &ids });

    var items = try listRules(&database, testing.allocator);
    defer items.deinit(testing.allocator);
    defer freeRules(testing.allocator, items.items);

    try testing.expectEqual(@as(usize, 1), items.items.len);
    try testing.expectEqual(model.RuleKind.regex, items.items[0].kind);
    try testing.expectEqualStrings(pattern, items.items[0].pattern);
}

test "a duplicate rule is accepted and stays deterministically ordered by id" {
    var database = try openMigrated();
    defer database.close();
    var ids = try seedGroupIds();
    defer ids.deinit(testing.allocator);

    const ctx: InsertContext = .{ .now = 1, .group_ids = &ids };
    const rule: model.Rule = .{
        .group = "default",
        .pattern = "dup.example",
        .kind = .exact,
        .action = .block,
    };
    try insertRule(&database, rule, ctx);
    try insertRule(&database, rule, ctx);

    var items = try listRules(&database, testing.allocator);
    defer items.deinit(testing.allocator);
    defer freeRules(testing.allocator, items.items);
    try testing.expectEqual(@as(usize, 2), items.items.len);
    try testing.expectEqualStrings("dup.example", items.items[0].pattern);
    try testing.expectEqualStrings("dup.example", items.items[1].pattern);
}

/// Inserts `names` in the given order and returns the ids the database assigned.
fn seedGroupsInOrder(database: *db.Db, names: []const []const u8) !IdMap {
    var ids: IdMap = .empty;
    errdefer ids.deinit(testing.allocator);
    for (names) |name| {
        try groups_repo.insertGroup(database, .{ .name = name }, .{});
        try ids.put(testing.allocator, name, database.lastInsertRowid());
    }
    return ids;
}

fn seedCrossGroupRules(database: *db.Db, ids: *const IdMap) !void {
    const ctx: InsertContext = .{ .now = 1700000000, .group_ids = ids };
    try insertRule(database, .{
        .group = "zeta",
        .pattern = "z.example",
        .kind = .exact,
        .action = .block,
    }, ctx);
    try insertRule(database, .{
        .group = "alpha",
        .pattern = "a.example",
        .kind = .exact,
        .action = .block,
    }, ctx);
}

test "list order does not depend on which id each group received" {
    // Two databases hold the same rules under the same group names, but the
    // groups were inserted in opposite orders, so every group id differs. This
    // is what an export → import → export cycle does to the ids.
    var first = try openMigrated();
    defer first.close();
    var first_ids = try seedGroupsInOrder(&first, &.{ "zeta", "alpha" });
    defer first_ids.deinit(testing.allocator);
    try seedCrossGroupRules(&first, &first_ids);

    var second = try openMigrated();
    defer second.close();
    var second_ids = try seedGroupsInOrder(&second, &.{ "alpha", "zeta" });
    defer second_ids.deinit(testing.allocator);
    try seedCrossGroupRules(&second, &second_ids);

    try testing.expect(first_ids.get("alpha").? != second_ids.get("alpha").?);

    var a = try listRules(&first, testing.allocator);
    defer a.deinit(testing.allocator);
    defer freeRules(testing.allocator, a.items);
    var b = try listRules(&second, testing.allocator);
    defer b.deinit(testing.allocator);
    defer freeRules(testing.allocator, b.items);

    try testing.expectEqual(@as(usize, 2), a.items.len);
    try testing.expectEqual(a.items.len, b.items.len);
    for (a.items, b.items) |x, y| {
        try testing.expectEqualStrings(x.group, y.group);
        try testing.expectEqualStrings(x.pattern, y.pattern);
    }

    // And the sequence is the group names in order, not the insertion order.
    try testing.expectEqualStrings("alpha", a.items[0].group);
    try testing.expectEqualStrings("a.example", a.items[0].pattern);
    try testing.expectEqualStrings("zeta", a.items[1].group);
    try testing.expectEqualStrings("z.example", a.items[1].pattern);
}

test "deleteAllRules empties the table and countRules reflects it" {
    var database = try openMigrated();
    defer database.close();
    var ids = try seedGroupIds();
    defer ids.deinit(testing.allocator);
    try seedRules(&database, &ids);

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

test "insertRule reports a group the caller's map does not hold" {
    var database = try openMigrated();
    defer database.close();
    const ctx: InsertContext = .{};
    try testing.expectError(error.NotFound, insertRule(&database, .{
        .group = "kids",
        .pattern = "x.example",
        .kind = .exact,
        .action = .block,
    }, ctx));
}

fn listRulesUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
    var database = try openMigrated();
    defer database.close();
    try seedRules(&database, ids);

    var items = try listRules(&database, gpa);
    defer items.deinit(gpa);
    defer freeRules(gpa, items.items);
}

test "listRules is leak-safe under allocation failure" {
    var ids = try seedGroupIds();
    defer ids.deinit(testing.allocator);
    try testing.checkAllAllocationFailures(testing.allocator, listRulesUnderFailure, .{&ids});
}

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

test "a rule round-trips through insert, get, list, update and delete" {
    var database = try openMigrated();
    defer database.close();
    const kids = try groups_repo.insertGroupRow(&database, .{ .name = "kids" });

    const id = try insertRuleRow(&database, .{
        .group_id = 1,
        .pattern = "tracker.example",
        .kind = .exact,
        .action = .block,
    }, 1700000000);

    const fetched = (try getRule(&database, testing.allocator, id)).?;
    defer freeRuleRow(testing.allocator, fetched);
    try testing.expectEqual(id, fetched.id);
    try testing.expectEqual(@as(i64, 1), fetched.group_id);
    try testing.expectEqualStrings("default", fetched.group);
    try testing.expectEqualStrings("tracker.example", fetched.pattern);
    try testing.expectEqual(model.RuleKind.exact, fetched.kind);
    try testing.expectEqual(model.RuleAction.block, fetched.action);
    try testing.expectEqual(@as(i64, 1700000000), fetched.created_at);

    try updateRule(&database, id, .{
        .group_id = kids,
        .pattern = "*.ads.example",
        .kind = .wildcard,
        .action = .allow,
    });
    const updated = (try getRule(&database, testing.allocator, id)).?;
    defer freeRuleRow(testing.allocator, updated);
    try testing.expectEqual(kids, updated.group_id);
    try testing.expectEqualStrings("kids", updated.group);
    try testing.expectEqualStrings("*.ads.example", updated.pattern);
    try testing.expectEqual(model.RuleKind.wildcard, updated.kind);
    try testing.expectEqual(model.RuleAction.allow, updated.action);
    // An edit is not a creation, so the date stands.
    try testing.expectEqual(@as(i64, 1700000000), updated.created_at);

    var rows = try listRuleRows(&database, testing.allocator);
    defer rows.deinit(testing.allocator);
    defer freeRuleRows(testing.allocator, rows.items);
    try testing.expectEqual(@as(usize, 1), rows.items.len);
    try testing.expectEqual(id, rows.items[0].id);

    try deleteRule(&database, id);
    try testing.expectEqual(@as(?RuleRow, null), try getRule(&database, testing.allocator, id));
    try testing.expectEqual(@as(i64, 0), try countRules(&database));
}

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

    const item: RuleInput = .{ .group_id = 1, .pattern = "x.example", .kind = .exact, .action = .block };
    try testing.expectError(error.NotFound, updateRule(&database, 404, item));
    try testing.expectError(error.NotFound, deleteRule(&database, 404));
    try testing.expectEqual(@as(?RuleRow, null), try getRule(&database, testing.allocator, 404));
}

test "a rule in a group that does not exist surfaces as error.Constraint" {
    var database = try openMigrated();
    defer database.close();

    const item: RuleInput = .{ .group_id = 404, .pattern = "x.example", .kind = .exact, .action = .block };
    try testing.expectError(error.Constraint, insertRuleRow(&database, item, 1));

    const id = try insertRuleRow(&database, .{
        .group_id = 1,
        .pattern = "x.example",
        .kind = .exact,
        .action = .block,
    }, 1);
    try testing.expectError(error.Constraint, updateRule(&database, id, item));
    try testing.expectEqual(@as(i64, 1), try countRules(&database));
}

fn ruleRowsUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
    var database = try openMigrated();
    defer database.close();
    try seedRules(&database, ids);

    var rows = try listRuleRows(&database, gpa);
    defer rows.deinit(gpa);
    defer freeRuleRows(gpa, rows.items);

    const one = (try getRule(&database, gpa, rows.items[0].id)).?;
    defer freeRuleRow(gpa, one);
}

test "the rule read surface is leak-safe under allocation failure" {
    var ids = try seedGroupIds();
    defer ids.deinit(testing.allocator);
    try testing.checkAllAllocationFailures(testing.allocator, ruleRowsUnderFailure, .{&ids});
}
