//! The `config.db` schema, verbatim from PLAN §11.2, plus the table lists every
//! other storage session needs.
//!
//! The DDL text is data, not code: `migrations.zig` carries it as step 1.
//!
//! Until nxdns reaches v0.1 this baseline is **editable**. nxdns has no
//! installs, so a schema change edits this string and PLAN §11.2 together — it
//! does not append a migration step. A step exists to reconcile a database
//! somebody already has, and nobody has one.
//!
//! At v0.1 this string freezes and every later change becomes an append-only
//! step. That is a deliberate act, not a rule the code already lives under.

const std = @import("std");

/// Migration step 1. Multi-statement text — it goes through `db.Db.exec`,
/// never through `prepare`.
pub const ddl_v1: [:0]const u8 =
    \\CREATE TABLE schema_version (version INTEGER NOT NULL);
    \\
    \\CREATE TABLE groups (
    \\    id INTEGER PRIMARY KEY,
    \\    name TEXT NOT NULL UNIQUE,
    \\    safe_search INTEGER NOT NULL DEFAULT 0
    \\);
    \\INSERT OR IGNORE INTO groups (id, name) VALUES (1, 'default');
    \\
    \\CREATE TABLE clients (
    \\    id INTEGER PRIMARY KEY,
    \\    ip TEXT NOT NULL UNIQUE,            -- canonical text form (v4 dotted / v6 RFC 5952)
    \\    name TEXT,
    \\    learned_name TEXT,
    \\    name_attempt_after INTEGER NOT NULL DEFAULT 0,
    \\    group_id INTEGER NOT NULL REFERENCES groups(id),
    \\    hand_edited INTEGER NOT NULL DEFAULT 0,
    \\    first_seen INTEGER NOT NULL,
    \\    last_seen INTEGER NOT NULL
    \\);
    \\
    \\CREATE TABLE client_prefixes (
    \\    id INTEGER PRIMARY KEY,
    \\    prefix TEXT NOT NULL UNIQUE,        -- "192.168.1.0/24", "fd00:abcd::/48"
    \\    group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
    \\    priority INTEGER NOT NULL DEFAULT 100
    \\);
    \\
    \\CREATE TABLE upstreams (
    \\    id INTEGER PRIMARY KEY,
    \\    url TEXT NOT NULL UNIQUE,
    \\    priority INTEGER NOT NULL DEFAULT 100,
    \\    enabled INTEGER NOT NULL DEFAULT 1,
    \\    tls_name TEXT NOT NULL DEFAULT ''  -- DoT verification name; empty verifies against the url host
    \\);
    \\
    \\CREATE TABLE blocklist_sources (
    \\    id INTEGER PRIMARY KEY,
    \\    url TEXT NOT NULL UNIQUE,
    \\    name TEXT NOT NULL,
    \\    enabled INTEGER NOT NULL DEFAULT 1,
    \\    is_suggested INTEGER NOT NULL DEFAULT 0,
    \\    last_updated INTEGER,
    \\    domain_count INTEGER NOT NULL DEFAULT 0,
    \\    wildcard_count INTEGER NOT NULL DEFAULT 0,
    \\    exception_count INTEGER NOT NULL DEFAULT 0,
    \\    skipped_regex_count INTEGER NOT NULL DEFAULT 0,
    \\    skipped_unsupported_count INTEGER NOT NULL DEFAULT 0,
    \\    checksum TEXT
    \\);
    \\
    \\CREATE TABLE group_sources (
    \\    group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
    \\    source_id INTEGER NOT NULL REFERENCES blocklist_sources(id) ON DELETE CASCADE,
    \\    PRIMARY KEY (group_id, source_id)
    \\);
    \\
    \\CREATE TABLE rules (
    \\    id INTEGER PRIMARY KEY,
    \\    group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
    \\    pattern TEXT NOT NULL,
    \\    kind TEXT NOT NULL CHECK(kind IN ('exact','wildcard','regex')),
    \\    action TEXT NOT NULL CHECK(action IN ('allow','block')),
    \\    created_at INTEGER NOT NULL
    \\);
    \\
    \\CREATE TABLE local_records (
    \\    id INTEGER PRIMARY KEY,
    \\    name TEXT NOT NULL,
    \\    rtype TEXT NOT NULL CHECK(rtype IN ('A','AAAA','CNAME')),
    \\    value TEXT NOT NULL,
    \\    ttl INTEGER NOT NULL DEFAULT 300,
    \\    UNIQUE(name, rtype, value)
    \\);
    \\
    \\CREATE TABLE forward_zones (
    \\    id INTEGER PRIMARY KEY,
    \\    zone TEXT NOT NULL UNIQUE,
    \\    resolver TEXT NOT NULL              -- "udp://192.168.1.1:53"
    \\);
    \\
    \\CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
    \\
    \\CREATE TABLE operational_events (
    \\    id INTEGER PRIMARY KEY,
    \\    code TEXT NOT NULL,
    \\    subject_key TEXT NOT NULL,
    \\    subject_label TEXT NOT NULL,
    \\    severity TEXT NOT NULL CHECK (severity IN ('warning', 'error')),
    \\    first_seen INTEGER NOT NULL,
    \\    last_seen INTEGER NOT NULL,
    \\    occurrences INTEGER NOT NULL CHECK (occurrences > 0),
    \\    resolved_at INTEGER,
    \\    detail TEXT NOT NULL DEFAULT '',
    \\    CHECK (resolved_at IS NULL OR resolved_at >= first_seen)
    \\);
    \\CREATE UNIQUE INDEX idx_operational_events_active
    \\    ON operational_events(code, subject_key) WHERE resolved_at IS NULL;
    \\CREATE INDEX idx_operational_events_last_seen
    \\    ON operational_events(last_seen DESC);
;

/// The same three statements, each made conditional, for the bridge at the end
/// of `migrations.migrate`.
///
/// A database stamped version 1 before `operational_events` joined `ddl_v1`
/// never runs step 1 again, so nothing would ever create the table there. The
/// bridge closes that divergence for the pre-0.1 installs that exist; it is
/// **removable the moment the v0.1 adoption gate lands**, because from then on
/// a schema change is an append-only migration step and this hazard cannot
/// recur.
///
/// It must not be folded into `ddl_v1`: a fresh database would then create the
/// table twice, and the unconditional `CREATE TABLE` above is what proves the
/// baseline and this text stay in step.
pub const operational_events_bridge: [:0]const u8 =
    \\CREATE TABLE IF NOT EXISTS operational_events (
    \\    id INTEGER PRIMARY KEY,
    \\    code TEXT NOT NULL,
    \\    subject_key TEXT NOT NULL,
    \\    subject_label TEXT NOT NULL,
    \\    severity TEXT NOT NULL CHECK (severity IN ('warning', 'error')),
    \\    first_seen INTEGER NOT NULL,
    \\    last_seen INTEGER NOT NULL,
    \\    occurrences INTEGER NOT NULL CHECK (occurrences > 0),
    \\    resolved_at INTEGER,
    \\    detail TEXT NOT NULL DEFAULT '',
    \\    CHECK (resolved_at IS NULL OR resolved_at >= first_seen)
    \\);
    \\CREATE UNIQUE INDEX IF NOT EXISTS idx_operational_events_active
    \\    ON operational_events(code, subject_key) WHERE resolved_at IS NULL;
    \\CREATE INDEX IF NOT EXISTS idx_operational_events_last_seen
    \\    ON operational_events(last_seen DESC);
;

/// Child-before-parent, and correct under `foreign_keys = ON`. The reconcile
/// engine deletes in this order so that every declarative child of a dying
/// parent is removed — and counted — before the parent goes, which keeps the FK
/// cascades a safety net rather than the accountant.
///
/// `upstreams`, `local_records`, `forward_zones` and `settings` have no foreign
/// keys, so their position is free; `groups` and `blocklist_sources` must come
/// last, after every referrer. `schema_version` is deliberately absent — an
/// import must never erase the stamped migration version — and so is
/// `operational_events`, which is runtime state rather than configuration: an
/// import that wiped the diagnostics log would destroy the record of what the
/// box has been doing.
pub const delete_order = [_][]const u8{
    "group_sources",     "rules",         "client_prefixes", "clients",
    "upstreams",         "local_records", "forward_zones",   "settings",
    "blocklist_sources", "groups",
};

/// Every table that holds configuration, in a fixed order. It includes
/// `groups`, because a byte-stability dump has to be able to see a reconcile
/// that renumbered a group.
///
/// `schema_version` is absent: it is the migration's, not the operator's.
/// `operational_events` is absent for the same class of reason: it is the
/// program's own record of its failures, and an export of it would be a log
/// dump, not a configuration.
pub const table_names = [_][]const u8{
    "groups",            "clients",       "client_prefixes", "upstreams",
    "blocklist_sources", "group_sources", "rules",           "local_records",
    "forward_zones",     "settings",
};

const db = @import("db.zig");
const testing = std.testing;

test "table_names names exactly the tables delete_order does" {
    try testing.expectEqual(delete_order.len, table_names.len);
    for (delete_order) |name| {
        try testing.expect(indexOf(&table_names, name) != null);
    }
    try testing.expect(indexOf(&table_names, "groups") != null);
    try testing.expect(indexOf(&table_names, "schema_version") == null);
    // Runtime state, not configuration: neither list may reach it, or an
    // import would wipe the diagnostics log and an export would emit it.
    try testing.expect(indexOf(&table_names, "operational_events") == null);
    try testing.expect(indexOf(&delete_order, "operational_events") == null);
}

test "the bridge creates exactly what the baseline does" {
    // The two texts are separate on purpose (a fresh database must not create
    // the table twice), which is exactly how they could drift. Applying each to
    // its own database and comparing `sqlite_schema` is what keeps them equal.
    const baseline = try schemaOf(ddl_v1);
    defer testing.allocator.free(baseline);
    const bridged = try schemaOf(operational_events_bridge);
    defer testing.allocator.free(bridged);

    // SQLite stores the `CREATE` text verbatim, so the conditional is the one
    // difference the two are allowed to have.
    const size = std.mem.replacementSize(u8, bridged, " IF NOT EXISTS", "");
    const plain = try testing.allocator.alloc(u8, size);
    defer testing.allocator.free(plain);
    _ = std.mem.replace(u8, bridged, " IF NOT EXISTS", "", plain);

    try testing.expectEqualStrings(baseline, plain);
}

/// Every `sqlite_schema` row of `operational_events`, after applying `sql`.
fn schemaOf(sql: [:0]const u8) ![]u8 {
    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    defer database.close();
    try database.exec(sql);

    var out: std.Io.Writer.Allocating = .init(testing.allocator);
    errdefer out.deinit();
    var stmt = try database.prepare(
        \\SELECT type, name, sql FROM sqlite_schema
        \\ WHERE tbl_name = 'operational_events' ORDER BY name
    );
    defer stmt.deinit();
    while (try stmt.step()) {
        try out.writer.print("{s} {s}\n{s}\n", .{ stmt.columnText(0), stmt.columnText(1), stmt.columnText(2) });
    }
    return out.toOwnedSlice();
}

test "delete_order lists every referrer before the table it references" {
    // The two parents in the schema. Every child that references them must be
    // deleted first, or `foreign_keys = ON` turns import's wipe into a
    // Constraint error inside the transaction.
    const referrers_of_groups = [_][]const u8{ "clients", "client_prefixes", "group_sources", "rules" };
    const referrers_of_sources = [_][]const u8{"group_sources"};

    try testing.expect(indexOf(&delete_order, "groups") != null);
    for (referrers_of_groups) |child| {
        try testing.expect(indexOf(&delete_order, child).? < indexOf(&delete_order, "groups").?);
    }
    for (referrers_of_sources) |child| {
        try testing.expect(indexOf(&delete_order, child).? < indexOf(&delete_order, "blocklist_sources").?);
    }
}

fn indexOf(haystack: []const []const u8, needle: []const u8) ?usize {
    for (haystack, 0..) |item, i| {
        if (std.mem.eql(u8, item, needle)) return i;
    }
    return null;
}
