//! First-start seeding (PLAN §3.5).
//!
//! A policy wrapper over `import.importFile`, and nothing more. There is exactly
//! one code path from a config file into the database, so bootstrap and
//! `nxdns import` cannot drift apart.
//!
//! The policy is three lines long:
//!
//! - no file → normal steady state, keep the database as it is;
//! - database already configured → the file is ignored, as PLAN §3.5 requires.
//!   Configured means an operator put something there. A database that has only
//!   answered queries is not configured, however many client rows the DNS path
//!   materialised into it, and `import.isEmpty` is where that line is drawn;
//! - otherwise → import it, and a file that is unreadable, unparseable or
//!   invalid is an error. The operator wrote that file and meant it; starting
//!   with silent defaults instead is the exact failure mode PLAN §1.3 exists to
//!   prevent.

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

const db = @import("../storage/db.zig");
const import = @import("import.zig");
const validate = @import("validate.zig");

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

pub const Outcome = enum { seeded, db_already_configured, no_config_file };

pub const Error = import.Error || std.Io.Dir.AccessError;

/// Called by `nxdns run` before serving.
pub fn bootstrap(
    io: std.Io,
    gpa: Allocator,
    database: *db.Db,
    dir: std.Io.Dir,
    config_path: []const u8,
    diags: *validate.Diagnostics,
) Error!Outcome {
    dir.access(io, config_path, .{}) catch |e| switch (e) {
        error.FileNotFound => {
            log.info("no configuration file at '{s}'; using the database as it is", .{config_path});
            return .no_config_file;
        },
        else => |other| return other,
    };

    // Deliberately before the read: on every start after the first, the file is
    // not even opened.
    if (!try import.isEmpty(database)) {
        log.info("configuration file ignored; the database is already configured", .{});
        return .db_already_configured;
    }

    try import.importFile(io, gpa, database, dir, config_path, .{ .force = false }, diags);
    log.info("seeded the database from '{s}'", .{config_path});
    return .seeded;
}

// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
//
// All three outcomes are exercised end to end in
// `src/storage/storage_integration_test.zig` (S7) against a real data directory.
// What the two cases below add is the one distinction that decides which outcome
// an operator gets, and it is too important to leave behind a `-Dintegration`
// flag: whether the database has been *configured*, not whether it has been
// *used*.

const testing = std.testing;

const clients_repo = @import("../storage/repositories/clients_repo.zig");
const migrations = @import("../storage/migrations.zig");

const seed_source =
    \\.{
    \\    .groups = .{ .{ .name = "default" } },
    \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
    \\}
;

/// Unparseable on purpose: a call that succeeds proves the file was never read.
const broken_source = ".{ .groups = ";

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;
}

test "a server that has answered queries still seeds from its configuration file" {
    const io = testing.io;
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data = seed_source });

    var database = try openMigrated();
    defer database.close();

    // The unattended first boot: the server came up on defaults, answered
    // traffic, and the operator dropped a config file in afterwards.
    try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000);

    var diags: validate.Diagnostics = .init(testing.allocator);
    defer diags.deinit();

    const outcome = try bootstrap(io, testing.allocator, &database, tmp.dir, "config.zon", &diags);
    try testing.expectEqual(Outcome.seeded, outcome);
    try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
    // Seeding did not cost the operator the device list they had been watching.
    try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
}

test "a client the operator has customised keeps the configuration file out" {
    const io = testing.io;
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data = broken_source });

    var database = try openMigrated();
    defer database.close();

    try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000);
    const id = try database.queryInt("SELECT id FROM clients WHERE ip = '192.168.1.5'");
    try clients_repo.updateClient(&database, id, .{ .name = "tv", .group_id = 1 });

    var diags: validate.Diagnostics = .init(testing.allocator);
    defer diags.deinit();

    const outcome = try bootstrap(io, testing.allocator, &database, tmp.dir, "config.zon", &diags);
    try testing.expectEqual(Outcome.db_already_configured, outcome);
    try testing.expectEqual(@as(usize, 0), diags.problems.items.len);
    // The name and the flag the operator set are still theirs.
    try testing.expectEqual(@as(i64, 1), try database.queryInt(
        "SELECT count(*) FROM clients WHERE name = 'tv' AND hand_edited = 1",
    ));
}
