//! `nxdns export`: the database rendered back as canonical ZON.
//!
//! Deterministic by construction. Every list arrives through a repository whose
//! `ORDER BY` ends in a unique column set, every scalar comes from the settings
//! map, and the header carries no timestamp, version or host name. That is what
//! makes `export` → `import` → `export` byte-identical, and it keeps a config
//! diff free of noise.
//!
//! Runtime columns are absent from the model on purpose, so two exports taken
//! minutes apart on a live server are identical too.

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

const db = @import("../storage/db.zig");
const clients_repo = @import("../storage/repositories/clients_repo.zig");
const groups_repo = @import("../storage/repositories/groups_repo.zig");
const local_repo = @import("../storage/repositories/local_repo.zig");
const rules_repo = @import("../storage/repositories/rules_repo.zig");
const settings_repo = @import("../storage/repositories/settings_repo.zig");
const sources_repo = @import("../storage/repositories/sources_repo.zig");
const upstreams_repo = @import("../storage/repositories/upstreams_repo.zig");
const model = @import("model.zig");

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

/// What reading the database into a `Config` can fail with. A caller that only
/// wants the value never has to handle a filesystem error.
pub const ReadError = db.Error || model.SettingsError;

pub const Error = ReadError || Writer.Error ||
    std.Io.Dir.CreateFileAtomicError || std.Io.File.SyncError || std.Io.File.Atomic.ReplaceError;

const header =
    \\// nxdns configuration
    \\// generated by `nxdns export` from the running configuration
    \\
;

/// Reads every repository into `arena` and assembles a `Config`. Every string in
/// the result belongs to `arena`; there is nothing else to free.
pub fn readConfig(database: *db.Db, arena: Allocator) ReadError!model.Config {
    var cfg: model.Config = .{};

    // The scalar sections first: an absent key keeps the model default, which is
    // how a migration adds a setting with no data step. Unknown keys are counted
    // and logged rather than refused — downgrading a binary must not make a
    // config database unreadable.
    const settings = try settings_repo.listSettings(database, arena);
    var unknown_keys: usize = 0;
    try model.fromSettings(settings.items, &cfg, &unknown_keys);
    if (unknown_keys != 0) {
        log.warn("{d} unknown settings key(s) were ignored while exporting", .{unknown_keys});
    }

    cfg.groups = (try groups_repo.listGroups(database, arena)).items;
    cfg.upstreams = (try upstreams_repo.listUpstreams(database, arena)).items;
    cfg.clients = (try clients_repo.listClients(database, arena)).items;
    cfg.client_prefixes = (try clients_repo.listClientPrefixes(database, arena)).items;
    cfg.blocklist_sources = (try sources_repo.listBlocklistSources(database, arena)).items;
    cfg.group_sources = (try groups_repo.listGroupSources(database, arena)).items;
    cfg.rules = (try rules_repo.listRules(database, arena)).items;
    cfg.local_records = (try local_repo.listLocalRecords(database, arena)).items;
    cfg.forward_zones = (try local_repo.listForwardZones(database, arena)).items;

    // `web.password` is operator input and is never stored, so the exported
    // file always states it as absent. Absent rather than `""`: a present empty
    // password is refused by `validate` (ruling 4), so exporting one would make
    // every export fail its own rules. It is also what makes the round trip
    // stable — re-applying the file takes the "password_hash written verbatim"
    // branch and stores the same hash.
    cfg.web.password = null;
    return cfg;
}

/// Canonical ZON: the fixed header, then the value with every default emitted.
/// Emitting defaults makes the file a complete record of the running
/// configuration and makes the round trip independent of a later change to a
/// default value.
pub fn writeConfig(cfg: model.Config, w: *Writer) Writer.Error!void {
    try w.writeAll(header);
    try std.zon.stringify.serialize(cfg, .{
        .whitespace = true,
        .emit_default_optional_fields = true,
    }, w);
    try w.writeAll("\n");
}

pub fn writeToWriter(gpa: Allocator, database: *db.Db, w: *Writer) Error!void {
    var arena_state: std.heap.ArenaAllocator = .init(gpa);
    defer arena_state.deinit();

    const cfg = try readConfig(database, arena_state.allocator());
    return writeConfig(cfg, w);
}

/// Atomic and owner-only. The exported file carries `web.password_hash`, so 0600
/// is not optional.
///
/// `createFileAtomic` puts its temporary file in the destination's own directory
/// (verified in `Io/Threaded.zig`: `atomicFileInit` receives either `dir` or the
/// directory opened on `dirname(dest_path)`), so the final `replace` is a
/// same-filesystem `rename` and is genuinely atomic.
pub fn writeToFile(
    io: std.Io,
    gpa: Allocator,
    database: *db.Db,
    dir: std.Io.Dir,
    path: []const u8,
) Error!void {
    var arena_state: std.heap.ArenaAllocator = .init(gpa);
    defer arena_state.deinit();
    const cfg = try readConfig(database, arena_state.allocator());

    var af = try dir.createFileAtomic(io, path, .{
        .permissions = .fromMode(0o600),
        .replace = true,
    });
    defer af.deinit(io);

    var buf: [4096]u8 = undefined;
    var fw = af.file.writer(io, &buf);
    writeConfig(cfg, &fw.interface) catch |e| return reportWriteFailure(&fw, e);
    fw.interface.flush() catch |e| return reportWriteFailure(&fw, e);

    // Before `replace`, which closes the file: the rename must publish durable
    // bytes, not an empty file with the content still in the page cache.
    try af.file.sync(io);
    try af.replace(io);
}

/// `Writer.Error` is a single `error.WriteFailed`; the cause lives on the
/// `File.Writer`. Logging it at `warn` is what turns "export failed" into
/// something an operator can act on.
fn reportWriteFailure(fw: *std.Io.File.Writer, e: Writer.Error) Writer.Error {
    if (fw.err) |cause| log.warn("writing the export failed: {s}", .{@errorName(cause)});
    return e;
}

// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------

const testing = std.testing;
const migrations = @import("../storage/migrations.zig");
const import = @import("import.zig");
const validate = @import("validate.zig");

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

const seed_source: [:0]const u8 =
    \\.{
    \\    .dns = .{ .port = 5353 },
    \\    .logging = .{ .level = .err, .retention_days = 7 },
    \\    .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$aGFzaGhhc2g" },
    \\    .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } },
    \\    .upstreams = .{
    \\        .{ .url = "https://dns.example/dns-query", .priority = 10 },
    \\        .{ .url = "tls://192.0.2.53:853", .priority = 20, .enabled = false, .tls_name = "dot.example" },
    \\    },
    \\    .clients = .{ .{ .ip = "fd00::1", .name = "tablet", .group = "kids" } },
    \\    .client_prefixes = .{ .{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 50 } },
    \\    .blocklist_sources = .{ .{ .url = "https://lists.example/ads.txt", .name = "ads" } },
    \\    .group_sources = .{ .{ .group = "kids", .source_url = "https://lists.example/ads.txt" } },
    \\    .rules = .{
    \\        .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block },
    \\        .{ .group = "default", .pattern = "allowed.example", .kind = .exact, .action = .allow },
    \\    },
    \\    .local_records = .{
    \\        .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 600 },
    \\        .{ .name = "nas.lan", .rtype = .aaaa, .value = "fd00::10" },
    \\    },
    \\    .forward_zones = .{ .{ .zone = "lan", .resolver = "udp://192.168.1.1:53" } },
    \\}
;

fn seed(io: std.Io, database: *db.Db, source: [:0]const u8) !void {
    var diags: validate.Diagnostics = .init(testing.allocator);
    defer diags.deinit();
    return import.importSource(io, testing.allocator, database, source, .{}, &diags);
}

test "writeConfig emits the fixed header and re-parses into an equal config" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = testing.allocator;

    var database = try openMigrated();
    defer database.close();
    try seed(io, &database, seed_source);

    var out: Writer.Allocating = .init(gpa);
    defer out.deinit();
    try writeToWriter(gpa, &database, &out.writer);

    // The header is fixed text. Anything variable in it — a timestamp, a version
    // or a host name — would break the byte-stable round trip.
    const text = out.written();
    try testing.expect(std.mem.startsWith(u8, text, header));
    try testing.expectEqualStrings(header, text[0..header.len]);

    var arena_state: std.heap.ArenaAllocator = .init(gpa);
    defer arena_state.deinit();
    const source = try gpa.dupeZ(u8, text);
    defer gpa.free(source);
    const reparsed = try std.zon.parse.fromSliceAlloc(
        model.Config,
        arena_state.allocator(),
        source,
        null,
        .{},
    );
    try testing.expectEqual(@as(u16, 5353), reparsed.dns.port);
    try testing.expectEqual(model.LogLevel.err, reparsed.logging.level);
}

test "readConfig, writeConfig, import and readConfig again produce an equal config" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = testing.allocator;

    var first = try openMigrated();
    defer first.close();
    try seed(io, &first, seed_source);

    var text: Writer.Allocating = .init(gpa);
    defer text.deinit();
    try writeToWriter(gpa, &first, &text.writer);
    const source = try text.toOwnedSliceSentinel(0);
    defer gpa.free(source);

    var second = try openMigrated();
    defer second.close();
    try seed(io, &second, source);

    var arena_a: std.heap.ArenaAllocator = .init(gpa);
    defer arena_a.deinit();
    var arena_b: std.heap.ArenaAllocator = .init(gpa);
    defer arena_b.deinit();

    const a = try readConfig(&first, arena_a.allocator());
    const b = try readConfig(&second, arena_b.allocator());

    try testing.expectEqual(a.dns.port, b.dns.port);
    try testing.expectEqual(a.logging.level, b.logging.level);
    try testing.expectEqualStrings(a.web.password_hash.?, b.web.password_hash.?);
    try testing.expectEqual(a.groups.len, b.groups.len);
    try testing.expectEqual(a.upstreams.len, b.upstreams.len);
    for (a.upstreams, b.upstreams) |left, right| {
        try testing.expectEqualStrings(left.url, right.url);
        try testing.expectEqualStrings(left.tls_name, right.tls_name);
    }
    try testing.expectEqual(a.rules.len, b.rules.len);
    try testing.expectEqualStrings(a.clients[0].ip, b.clients[0].ip);
    try testing.expectEqualStrings(a.forward_zones[0].resolver, b.forward_zones[0].resolver);
}

test "export is byte-stable across a re-import" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = testing.allocator;

    var first = try openMigrated();
    defer first.close();
    try seed(io, &first, seed_source);

    var a: Writer.Allocating = .init(gpa);
    defer a.deinit();
    try writeToWriter(gpa, &first, &a.writer);
    const source = try gpa.dupeZ(u8, a.written());
    defer gpa.free(source);

    var second = try openMigrated();
    defer second.close();
    try seed(io, &second, source);

    var b: Writer.Allocating = .init(gpa);
    defer b.deinit();
    try writeToWriter(gpa, &second, &b.writer);

    try testing.expectEqualStrings(a.written(), b.written());
}

test "an exported password_hash survives a re-import unchanged" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = testing.allocator;

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

    const cfg: model.Config = .{
        .groups = &.{.{ .name = "default" }},
        .upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
        .web = .{ .password = "correct horse battery staple" },
    };
    var diags: validate.Diagnostics = .init(gpa);
    defer diags.deinit();
    try import.apply(io, gpa, &database, cfg, 42, .{}, &diags);

    var arena_state: std.heap.ArenaAllocator = .init(gpa);
    defer arena_state.deinit();
    const exported = try readConfig(&database, arena_state.allocator());

    try testing.expectEqual(@as(?[]const u8, null), exported.web.password);
    try testing.expect(std.mem.startsWith(u8, exported.web.password_hash.?, "$argon2id$"));
}

test "the exported password form is the one validate accepts" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = testing.allocator;

    var database = try openMigrated();
    defer database.close();
    var apply_diags: validate.Diagnostics = .init(gpa);
    defer apply_diags.deinit();
    try import.apply(io, gpa, &database, .{
        .groups = &.{.{ .name = "default" }},
        .upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
        .web = .{ .password = "correct horse battery staple" },
    }, 42, .{}, &apply_diags);

    var out: Writer.Allocating = .init(gpa);
    defer out.deinit();
    try writeToWriter(gpa, &database, &out.writer);

    // The literal form matters: an export carrying `password = ""` beside a
    // stored hash would trip `EmptyWebPassword` on the way back in, so export
    // would produce a file its own validator refuses.
    try testing.expect(std.mem.indexOf(u8, out.written(), ".password = null,") != null);

    const source = try gpa.dupeZ(u8, out.written());
    defer gpa.free(source);
    var arena_state: std.heap.ArenaAllocator = .init(gpa);
    defer arena_state.deinit();
    const reparsed = try std.zon.parse.fromSliceAlloc(
        model.Config,
        arena_state.allocator(),
        source,
        null,
        .{},
    );
    var diags: validate.Diagnostics = .init(gpa);
    defer diags.deinit();
    try validate.validate(reparsed, &diags);
    try testing.expectEqual(@as(?[]const u8, null), reparsed.web.password);
    try testing.expect(std.mem.startsWith(u8, reparsed.web.password_hash.?, "$argon2id$"));
}
