//! The `config.db` migration runner.
//!
//! Steps are compiled into the binary in ascending order and applied inside
//! **one** transaction, then the reached version is stamped. SQLite runs DDL
//! transactionally, so a step that fails leaves the file exactly as it was.
//!
//! A database stamped *newer* than this binary is never silently accepted and
//! never downgraded: it is `error.SchemaTooNew`, distinct from every other
//! error, so the CLI can tell the operator to install a newer nxdns.

const std = @import("std");
const assert = std.debug.assert;

const db = @import("db.zig");
const config_schema = @import("config_schema.zig");

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

pub const Step = struct { version: u32, sql: [:0]const u8 };

/// One baseline, no steps. Until v0.1 a schema change edits
/// `config_schema.ddl_v1` in place, because nxdns has no installs and there is
/// no database in the world for a step to reconcile.
///
/// At v0.1 the baseline freezes and this list becomes append-only: editing a
/// released step would make a fresh database and an upgraded one disagree, and
/// nothing would detect it. `migrateSteps` already implements that discipline
/// and its tests already pin it against injected step lists.
pub const steps = [_]Step{
    .{ .version = 1, .sql = config_schema.ddl_v1 },
};

/// The schema version this binary expects. A database `readVersion` reports
/// below this needs `nxdns run` to migrate it; above it is `error.SchemaTooNew`
/// and needs a newer nxdns.
pub const target_version: u32 = steps[steps.len - 1].version;

comptime {
    assertOrdered(&steps);
}

pub const Error = db.Error || error{ SchemaTooNew, SchemaCorrupt };

/// Versions must be `1, 2, 3, …` with no gaps. A gap would make "apply every
/// step newer than the stamped version" ambiguous about what the stamp means.
fn assertOrdered(list: []const Step) void {
    assert(list.len > 0);
    for (list, 0..) |step, i| assert(@as(usize, step.version) == i + 1);
}

/// Reads the stamped version, applies every newer step in one transaction and
/// stamps the result. Returns the version now in the file.
///
/// `database` must already have had `db.applyPragmas` called: `PRAGMA
/// foreign_keys` is a no-op inside a transaction, so applying it afterwards
/// would silently leave referential integrity off.
pub fn migrate(database: *db.Db) Error!u32 {
    const version = try migrateSteps(database, &steps);
    try bridgeOperationalEvents(database);
    return version;
}

/// **Removable when the v0.1 adoption gate lands.**
///
/// `operational_events` joined `config_schema.ddl_v1` after databases stamped
/// version 1 already existed, and a stamped database never runs step 1 again —
/// so on those installs nothing would ever create the table, silently, and the
/// diagnostics store would fail to open forever. This runs after the stamp,
/// with every statement conditional, and touches no other table.
///
/// It belongs here and not in `cli.openConfigDb`: that runs *before* migration
/// everywhere (`app.zig`, `cli.zig`), so creating the table there would make a
/// fresh database's unconditional `CREATE TABLE` in `ddl_v1` fail.
fn bridgeOperationalEvents(database: *db.Db) db.Error!void {
    return database.exec(config_schema.operational_events_bridge);
}

/// Same logic against an injected step list. The seam exists for the rollback
/// and stepwise-upgrade tests, which need a second step that `steps` does not
/// yet have.
pub fn migrateSteps(database: *db.Db, list: []const Step) Error!u32 {
    assertOrdered(list);
    const target = list[list.len - 1].version;

    const current = try readVersion(database);
    if (current > target) {
        log.warn("config.db is at schema version {d}; this nxdns binary supports {d}", .{ current, target });
        return error.SchemaTooNew;
    }
    if (current == target) return current;

    var tx = try db.Tx.begin(database);
    errdefer tx.rollback();

    // Re-read under BEGIN IMMEDIATE. Two processes starting at the same moment
    // both saw `current` above; the one that loses the write lock arrives here
    // after the other committed and finds nothing to do.
    const stamped = try readVersion(database);
    if (stamped > target) {
        log.warn("config.db is at schema version {d}; this nxdns binary supports {d}", .{ stamped, target });
        return error.SchemaTooNew;
    }
    if (stamped == target) {
        try tx.commit();
        return stamped;
    }

    for (list) |step| {
        if (step.version <= stamped) continue;
        try database.exec(step.sql);
    }

    try database.exec("DELETE FROM schema_version;");
    var stmt = try database.prepare("INSERT INTO schema_version (version) VALUES (?1)");
    defer stmt.deinit();
    try stmt.bindInt(1, target);
    try stmt.exec();

    try tx.commit();
    log.info("config.db migrated from schema version {d} to {d}", .{ stamped, target });
    return target;
}

/// The schema version stamped in `database`, compared against `target_version`.
///
/// `0` when `schema_version` does not exist yet. Zero rows or more than one row
/// is `error.SchemaCorrupt` — the version of a database is never guessed.
///
/// Reads only, so it works on a connection opened `.read_only` or
/// `.immutable`. That is what it is public for: `nxdns check` may not migrate
/// (ruling F-c), and "at version 0, this binary expects 1" tells an operator
/// what to do where a bare SQLite error message does not.
pub fn readVersion(database: *db.Db) Error!u32 {
    const present = try database.queryInt(
        "SELECT count(*) FROM sqlite_schema WHERE type='table' AND name='schema_version'",
    );
    if (present == 0) return 0;

    const rows = try database.queryInt("SELECT count(*) FROM schema_version");
    if (rows != 1) {
        log.warn("schema_version holds {d} rows; exactly one is required", .{rows});
        return error.SchemaCorrupt;
    }

    const version = try database.queryInt("SELECT version FROM schema_version");
    if (version < 0 or version > std.math.maxInt(u32)) {
        log.warn("schema_version holds an out-of-range version {d}", .{version});
        return error.SchemaCorrupt;
    }
    return @intCast(version);
}

fn tableExists(database: *db.Db, name: []const u8) db.Error!bool {
    var stmt = try database.prepare("SELECT count(*) FROM sqlite_schema WHERE type='table' AND name = ?1");
    defer stmt.deinit();
    try stmt.bindText(1, name);
    if (!try stmt.step()) return error.SqliteError;
    return stmt.columnInt(0) != 0;
}

const testing = std.testing;

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

test "migrate on a fresh database creates every table and seeds the default group" {
    var database = try openMigrated();
    defer database.close();

    try testing.expectEqual(target_version, try migrate(&database));

    const expected = [_][]const u8{
        "schema_version",    "groups",        "clients",       "client_prefixes",
        "upstreams",         "rules",         "local_records", "forward_zones",
        "blocklist_sources", "group_sources", "settings",      "operational_events",
    };
    for (expected) |name| {
        try testing.expect(try tableExists(&database, name));
    }
    try testing.expectEqual(
        @as(i64, expected.len),
        try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"),
    );

    try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM groups"));
    try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT id FROM groups"));
    var stmt = try database.prepare("SELECT name, safe_search FROM groups");
    defer stmt.deinit();
    try testing.expect(try stmt.step());
    try testing.expectEqualStrings("default", stmt.columnText(0));
    try testing.expect(!stmt.columnBool(1));
}

test "migrate is idempotent" {
    var database = try openMigrated();
    defer database.close();

    try testing.expectEqual(target_version, try migrate(&database));
    const before = try database.queryInt("SELECT count(*) FROM sqlite_schema");
    const rowid_before = database.lastInsertRowid();

    try testing.expectEqual(target_version, try migrate(&database));
    try testing.expectEqual(before, try database.queryInt("SELECT count(*) FROM sqlite_schema"));
    try testing.expectEqual(rowid_before, database.lastInsertRowid());
    try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM schema_version"));
    try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM groups"));
}

test "a database stamped newer than the binary is error.SchemaTooNew" {
    var database = try openMigrated();
    defer database.close();
    _ = try migrate(&database);

    const future: i64 = @as(i64, target_version) + 1;
    var stmt = try database.prepare("UPDATE schema_version SET version = ?1");
    defer stmt.deinit();
    try stmt.bindInt(1, future);
    try stmt.exec();

    try testing.expectError(error.SchemaTooNew, migrate(&database));
    try testing.expectEqual(future, try database.queryInt("SELECT version FROM schema_version"));
}

test "schema_version holding two rows is error.SchemaCorrupt" {
    var database = try openMigrated();
    defer database.close();
    _ = try migrate(&database);

    try database.exec("INSERT INTO schema_version (version) VALUES (1);");
    try testing.expectError(error.SchemaCorrupt, migrate(&database));
}

test "a failing step rolls the whole migration back" {
    var database = try openMigrated();
    defer database.close();

    const broken = [_]Step{
        .{ .version = 1, .sql = config_schema.ddl_v1 },
        .{ .version = 2, .sql = "CREATE TABLE second (" },
    };
    // db.zig maps SQLITE_ERROR — the generic "SQL error" — to error.Unexpected.
    try testing.expectError(error.Unexpected, migrateSteps(&database, &broken));

    try testing.expect(!try tableExists(&database, "schema_version"));
    try testing.expect(!try tableExists(&database, "groups"));
    try testing.expect(!try tableExists(&database, "second"));
    try testing.expectEqual(@as(u32, 0), try readVersion(&database));
}

test "a stepwise upgrade applies only the new steps" {
    var database = try openMigrated();
    defer database.close();

    const first = [_]Step{.{ .version = 1, .sql = config_schema.ddl_v1 }};
    try testing.expectEqual(@as(u32, 1), try migrateSteps(&database, &first));
    try testing.expect(try tableExists(&database, "groups"));
    try testing.expect(!try tableExists(&database, "extra"));

    const second = [_]Step{
        .{ .version = 1, .sql = config_schema.ddl_v1 },
        .{ .version = 2, .sql = "CREATE TABLE extra (id INTEGER PRIMARY KEY);" },
    };
    try testing.expectEqual(@as(u32, 2), try migrateSteps(&database, &second));
    try testing.expect(try tableExists(&database, "extra"));
    try testing.expectEqual(@as(u32, 2), try readVersion(&database));
    // Step 1 did not run a second time: `groups` still holds one seeded row.
    try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM groups"));
}

fn columnExists(database: *db.Db, table: []const u8, column: []const u8) !bool {
    var stmt = try database.prepare("SELECT count(*) FROM pragma_table_info(?1) WHERE name = ?2");
    defer stmt.deinit();
    try stmt.bindText(1, table);
    try stmt.bindText(2, column);
    if (!try stmt.step()) return error.SqliteError;
    return stmt.columnInt(0) != 0;
}

test "a fresh database reaches the baseline with every v1 column and rule kind" {
    var database = try openMigrated();
    defer database.close();

    try testing.expectEqual(@as(u32, 1), try migrate(&database));
    try testing.expectEqual(@as(u32, 1), target_version);
    try testing.expect(try columnExists(&database, "clients", "learned_name"));
    try testing.expect(try columnExists(&database, "clients", "name_attempt_after"));
    try testing.expect(try columnExists(&database, "upstreams", "tls_name"));
    try testing.expect(try columnExists(&database, "blocklist_sources", "exception_count"));
    try testing.expect(try columnExists(&database, "blocklist_sources", "skipped_unsupported_count"));

    try database.exec(
        \\INSERT INTO rules (group_id, pattern, kind, action, created_at)
        \\VALUES (1, '^ad[0-9]+-', 'regex', 'block', 100);
    );
    try testing.expectError(error.Constraint, database.exec(
        \\INSERT INTO rules (group_id, pattern, kind, action, created_at)
        \\VALUES (1, 'x', 'glob', 'block', 100);
    ));
}

test "the baseline rules table cascades from its group" {
    var database = try openMigrated();
    defer database.close();
    _ = try migrate(&database);

    try database.exec(
        \\INSERT INTO groups (id, name) VALUES (2, 'kids');
        \\INSERT INTO rules (id, group_id, pattern, kind, action, created_at) VALUES
        \\    (9, 2, '*.tracker.net', 'wildcard', 'allow', 2000);
    );
    try database.exec("DELETE FROM groups WHERE id = 2;");
    try testing.expectEqual(
        @as(i64, 0),
        try database.queryInt("SELECT count(*) FROM rules WHERE group_id = 2"),
    );
}

test "a failing step rolls back an upgrade of a populated database" {
    var database = try openMigrated();
    defer database.close();
    _ = try migrate(&database);
    try database.exec("INSERT INTO upstreams (url, priority, enabled) VALUES ('tls://1.1.1.1:853', 10, 1);");

    // Rollback of an *upgrade* is a different case from rollback of the initial
    // creation ("a failing step rolls the whole migration back"): here a
    // populated database must come back untouched, not cease to exist.
    const broken = steps ++ [_]Step{
        .{ .version = target_version + 1, .sql = "CREATE TABLE second (id INTEGER PRIMARY KEY);" },
        .{ .version = target_version + 2, .sql = "CREATE TABLE third (" },
    };
    try testing.expectError(error.Unexpected, migrateSteps(&database, &broken));

    // One transaction: the step that did succeed went back with the one that did not.
    try testing.expect(!try tableExists(&database, "second"));
    try testing.expectEqual(target_version, try readVersion(&database));
    try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
}

test "readVersion reports 0 before a migration and target_version after it" {
    var database = try openMigrated();
    defer database.close();

    try testing.expectEqual(@as(u32, 0), try readVersion(&database));
    _ = try migrate(&database);
    try testing.expectEqual(target_version, try readVersion(&database));
}

/// `.zig-cache/tmp/` is where `std.testing.tmpDir` puts its directories, and
/// SQLite's VFS resolves filenames against the same working directory
/// (`db.zig`'s immutable tests).
const tmp_prefix = ".zig-cache/tmp/";
const sub_path_len = @typeInfo(@FieldType(testing.TmpDir, "sub_path")).array.len;

test "readVersion reads a file database through an immutable open, writing nothing" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined;
    const path = try std.fmt.bufPrintZ(&path_buf, "{s}{s}/config.db", .{ tmp_prefix, &tmp.sub_path });

    // `check` reads the stamped version without migrating (ruling F-c), so the
    // read has to work through a connection that cannot write at all.
    {
        var database = try db.Db.open(path, .{ .mode = .read_write_create });
        defer database.close();
        try db.applyPragmas(&database, .{});
        try testing.expectEqual(target_version, try migrate(&database));
    }

    var database = try db.Db.open(path, .{ .mode = .{ .immutable = testing.io } });
    defer database.close();
    try testing.expectEqual(target_version, try readVersion(&database));

    // A write through this connection is refused by SQLite, not by convention.
    try testing.expectError(error.ReadOnly, database.exec("DELETE FROM schema_version;"));
    try testing.expectEqual(@as(u32, 1), try readVersion(&database));
}

test "delete_order and table_names name exactly the tables the schema creates" {
    var database = try openMigrated();
    defer database.close();
    _ = try migrate(&database);

    for (config_schema.delete_order) |name| {
        try testing.expect(try tableExists(&database, name));
    }
    for (config_schema.table_names) |name| {
        try testing.expect(try tableExists(&database, name));
    }
    // delete_order covers every table except two: `schema_version`, which is
    // the migration's own, and `operational_events`, which is runtime state an
    // import must never wipe.
    try testing.expect(!try tableExists(&database, "no_such_table"));
    try testing.expect(try tableExists(&database, "operational_events"));
    try testing.expectEqual(
        @as(i64, config_schema.delete_order.len + 2),
        try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"),
    );
}

test "a version-1 database created without operational_events gains exactly it" {
    // The silent divergence the bridge exists for: this is what the Pi's
    // `config.db` looks like — stamped 1, so step 1 never runs again.
    var database = try openMigrated();
    defer database.close();
    try database.exec(config_schema.ddl_v1);
    try database.exec("DROP TABLE operational_events;");
    try database.exec("INSERT INTO schema_version (version) VALUES (1);");
    try testing.expect(!try tableExists(&database, "operational_events"));

    const tables_before = try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'");
    const groups_before = try database.queryInt("SELECT count(*) FROM groups");

    try testing.expectEqual(@as(u32, 1), try migrate(&database));

    try testing.expect(try tableExists(&database, "operational_events"));
    try testing.expectEqual(
        tables_before + 1,
        try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"),
    );
    // Both indexes came with it, and no other table moved.
    try testing.expectEqual(
        @as(i64, 2),
        try database.queryInt(
            "SELECT count(*) FROM sqlite_schema WHERE type='index' AND tbl_name='operational_events'",
        ),
    );
    try testing.expectEqual(groups_before, try database.queryInt("SELECT count(*) FROM groups"));
}

test "the bridge does not double-create on a fresh database or on a second run" {
    var database = try openMigrated();
    defer database.close();

    // `ddl_v1` creates the table unconditionally, so a bridge that ran as part
    // of the step list would fail here rather than be a no-op.
    try testing.expectEqual(@as(u32, 1), try migrate(&database));
    const schema_rows = try database.queryInt("SELECT count(*) FROM sqlite_schema");

    try database.exec(
        \\INSERT INTO operational_events
        \\  (code, subject_key, subject_label, severity, first_seen, last_seen, occurrences)
        \\VALUES ('disk.space', 'data', 'data', 'warning', 100, 100, 1);
    );
    try testing.expectEqual(@as(u32, 1), try migrate(&database));
    try testing.expectEqual(schema_rows, try database.queryInt("SELECT count(*) FROM sqlite_schema"));
    // A `CREATE TABLE IF NOT EXISTS` that had somehow replaced the table would
    // show up as a lost row, not as a schema difference.
    try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM operational_events"));
}

test "the partial unique index allows one active row per key and any number of resolved ones" {
    var database = try openMigrated();
    defer database.close();
    _ = try migrate(&database);

    const insert =
        \\INSERT INTO operational_events
        \\  (code, subject_key, subject_label, severity, first_seen, last_seen, occurrences, resolved_at)
        \\VALUES ('blocklist.refresh', 'https://a.example', 'a', 'warning', 100, 100, 1, ?1)
    ;
    {
        var stmt = try database.prepare(insert);
        defer stmt.deinit();
        try stmt.bindNull(1);
        try stmt.exec();
    }
    {
        // A second active row for the same (code, subject_key) is what
        // `report`'s overflow probe exists to avoid, and the index proves it.
        // Its own statement: `Stmt.reset` re-reports the code of a failed step,
        // so a reused one would answer `error.Constraint` a second time.
        var stmt = try database.prepare(insert);
        defer stmt.deinit();
        try stmt.bindNull(1);
        try testing.expectError(error.Constraint, stmt.step());
    }

    var resolved = try database.prepare(insert);
    defer resolved.deinit();
    for ([_]i64{ 200, 300 }) |resolved_at| {
        try resolved.reset();
        try resolved.bindInt(1, resolved_at);
        try resolved.exec();
    }
    try testing.expectEqual(@as(i64, 3), try database.queryInt("SELECT count(*) FROM operational_events"));
}
