//! `settings`.
//!
//! The row type is `model.SettingPair`, the same type `model.toSettings` and
//! `model.fromSettings` speak, so the scalar sections cross the storage boundary
//! without a second shape.
//!
//! The import path is list / insert / deleteAll / count; `putSetting` is Phase
//! 8's single-key write. `settings` has no row ids — the key is the identity —
//! so it gains no by-id surface.

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;

/// Both strings of every pair are heap copies owned by `gpa`.
pub fn listSettings(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.SettingPair) {
    return crud.listRows(
        model.SettingPair,
        database,
        gpa,
        "SELECT key, value FROM settings ORDER BY key",
        readSetting,
    );
}

fn readSetting(stmt: *db.Stmt, gpa: Allocator) db.Error!model.SettingPair {
    const key = try stmt.columnTextAlloc(gpa, 0);
    errdefer gpa.free(key);
    const value = try stmt.columnTextAlloc(gpa, 1);
    errdefer gpa.free(value);
    return .{ .key = key, .value = value };
}

/// Only for lists `listSettings` produced. `model.toSettings` builds pairs whose
/// `key` is a comptime string and must never be freed; that list is the caller's
/// to release, field by field.
pub fn freeSettings(gpa: Allocator, items: []const model.SettingPair) void {
    crud.freeRows(model.SettingPair, gpa, items);
}

pub fn insertSetting(database: *db.Db, item: model.SettingPair, ctx: InsertContext) db.Error!void {
    _ = ctx;
    var stmt = try database.prepare("INSERT INTO settings (key, value) VALUES (?1, ?2)");
    defer stmt.deinit();
    try stmt.bindText(1, item.key);
    try stmt.bindText(2, item.value);
    try stmt.exec();
}

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

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

// ---------------------------------------------------------------------------
// REST surface (milestone 8)
// ---------------------------------------------------------------------------

const put_setting_sql =
    \\INSERT INTO settings (key, value) VALUES (?1, ?2)
    \\ON CONFLICT(key) DO UPDATE SET value = excluded.value
;

/// Writes one key, whether or not it is already stored.
///
/// `PUT /api/settings` is a partial update over a table whose rows the import
/// path writes once and never revisits, so a plain `INSERT` would fail on every
/// key the config already carries and a plain `UPDATE` would drop every key it
/// does not. The conflict target is `settings.key`, the table's PRIMARY KEY.
///
/// No constraint can fire: the table has one key column and one `NOT NULL`
/// value, and both are bound.
pub fn putSetting(database: *db.Db, key: []const u8, value: []const u8) db.Error!void {
    var stmt = try database.prepare(put_setting_sql);
    defer stmt.deinit();
    try stmt.bindText(1, key);
    try stmt.bindText(2, value);
    try stmt.exec();
}

/// Removes one key. Silent about a key that is not stored: the reconcile
/// engine's sweep computes the set of keys to drop from a list it has already
/// read, so "no such row" is not a caller error the way it is for a by-id
/// mutation, and `execStrict` would turn a harmless race into a failed
/// transaction.
pub fn deleteSetting(database: *db.Db, key: []const u8) db.Error!void {
    var stmt = try database.prepare("DELETE FROM settings WHERE key = ?1");
    defer stmt.deinit();
    try stmt.bindText(1, key);
    return stmt.exec();
}

// ---------------------------------------------------------------------------
// 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 seedSettings(database: *db.Db) !void {
    const ctx: InsertContext = .{};
    try insertSetting(database, .{ .key = "web.port", .value = "8080" }, ctx);
    try insertSetting(database, .{ .key = "dns.port", .value = "53" }, ctx);
    // An apostrophe proves the value is bound, not concatenated into the SQL.
    try insertSetting(database, .{ .key = "logging.file_path", .value = "/var/log/o'brien.log" }, ctx);
}

test "settings round-trip in ascending key order" {
    var database = try openMigrated();
    defer database.close();
    try seedSettings(&database);

    var items = try listSettings(&database, testing.allocator);
    defer items.deinit(testing.allocator);
    defer freeSettings(testing.allocator, items.items);

    try testing.expectEqual(@as(usize, 3), items.items.len);
    try testing.expectEqualStrings("dns.port", items.items[0].key);
    try testing.expectEqualStrings("53", items.items[0].value);
    try testing.expectEqualStrings("logging.file_path", items.items[1].key);
    try testing.expectEqualStrings("/var/log/o'brien.log", items.items[1].value);
    try testing.expectEqualStrings("web.port", items.items[2].key);
    try testing.expectEqualStrings("8080", items.items[2].value);
}

test "a value holding an apostrophe survives the round trip" {
    var database = try openMigrated();
    defer database.close();
    const ctx: InsertContext = .{};
    const value = "he said 'hello'; DROP TABLE settings;--";
    try insertSetting(&database, .{ .key = "web.password_hash", .value = value }, ctx);

    var items = try listSettings(&database, testing.allocator);
    defer items.deinit(testing.allocator);
    defer freeSettings(testing.allocator, items.items);

    try testing.expectEqual(@as(usize, 1), items.items.len);
    try testing.expectEqualStrings(value, items.items[0].value);
    try testing.expectEqual(@as(i64, 1), try countSettings(&database));
}

test "deleteAllSettings empties the table and countSettings reflects it" {
    var database = try openMigrated();
    defer database.close();
    try seedSettings(&database);

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

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

    var items = try listSettings(&database, gpa);
    defer items.deinit(gpa);
    defer freeSettings(gpa, items.items);
}

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

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

test "putSetting writes a key that is absent and overwrites one that is present" {
    var database = try openMigrated();
    defer database.close();
    try seedSettings(&database);

    try putSetting(&database, "web.port", "9090");
    try putSetting(&database, "cache.max_entries", "20000");

    var items = try listSettings(&database, testing.allocator);
    defer items.deinit(testing.allocator);
    defer freeSettings(testing.allocator, items.items);

    try testing.expectEqual(@as(usize, 4), items.items.len);
    try testing.expectEqualStrings("cache.max_entries", items.items[0].key);
    try testing.expectEqualStrings("20000", items.items[0].value);
    try testing.expectEqualStrings("web.port", items.items[3].key);
    try testing.expectEqualStrings("9090", items.items[3].value);
    // The keys it did not name are untouched.
    try testing.expectEqualStrings("dns.port", items.items[1].key);
    try testing.expectEqualStrings("53", items.items[1].value);
}

test "putSetting is idempotent and leaves the row count alone" {
    var database = try openMigrated();
    defer database.close();

    try putSetting(&database, "web.password_hash", "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA");
    try putSetting(&database, "web.password_hash", "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA");
    try testing.expectEqual(@as(i64, 1), try countSettings(&database));

    var items = try listSettings(&database, testing.allocator);
    defer items.deinit(testing.allocator);
    defer freeSettings(testing.allocator, items.items);
    try testing.expectEqualStrings("$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA", items.items[0].value);
}
