//! The one way a configuration file becomes a `model.Config`.
//!
//! `nxdns run --config <file>` and `nxdns check --config <file>` must grade the
//! same file the same way, so the read, the error classification and the parse
//! live here rather than once per subcommand. `config/faults.zig` exists for the
//! same reason one layer up: two copies of a rule are two rules.
//!
//! **The classification.** `faults.isConfigFault` deliberately excludes
//! `FileNotFound` and `AccessDenied` in general — a missing file is usually a
//! broken box, not a wrong configuration. The managed file is the one place
//! where the opposite holds: the operator named that path, so a path that does
//! not resolve is a configuration fault (exit 2, `nxdns check` is the next
//! step). Only the path class converts:
//!
//!   `FileNotFound`, `AccessDenied`, `PermissionDenied`, `NotDir`, `IsDir`,
//!   `SymLinkLoop`, `NameTooLong`, `BadPathName` → `ManagedConfigUnreadable`
//!
//! Everything else `readFileAllocOptions` can return — `SystemResources`, the
//! two fd-quota errors, I/O failures, `OutOfMemory` — propagates unmapped and
//! exits 1. Those are box faults a retry can clear, and the shipped unit carries
//! `RestartPreventExitStatus=2 64`: mapping a transient failure to exit 2 would
//! stop the service permanently on a fault that would have cleared itself.
//!
//! The mapping is a named error set switched exhaustively with
//! `else => |other| return other`, so an error a Zig upgrade adds to
//! `ReadFileAllocError` defaults to exit 1 rather than silently to exit 2.

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

const model = @import("model.zig");
const validate = @import("validate.zig");

/// The ceiling on a configuration file. A file above it is a configuration
/// fault, not a resource failure: nothing an operator writes by hand comes near
/// 4 MiB, so this is a typo or a wrong path rather than a real config.
pub const max_config_bytes = 4 * 1024 * 1024;

/// Every error `readFileAllocOptions` can hand back, plus the parse.
pub const ReadError = std.Io.Dir.ReadFileAllocError;

/// The open failures that mean the operator's path is wrong rather than the box
/// being broken. Spelled out as a set rather than as switch prongs so that the
/// list is one thing a reader can find and a test can enumerate.
pub const PathFault = error{
    FileNotFound,
    AccessDenied,
    PermissionDenied,
    NotDir,
    IsDir,
    SymLinkLoop,
    NameTooLong,
    BadPathName,
};

pub const Error = ReadError || error{ ManagedConfigUnreadable, ConfigTooLarge, ParseZon };

/// The classification itself, pure and testable on its own: a path-class
/// failure becomes `ManagedConfigUnreadable`, the size limit becomes
/// `ConfigTooLarge`, and every other member travels unchanged.
pub fn mapReadError(e: ReadError) Error {
    return switch (e) {
        error.StreamTooLong => error.ConfigTooLarge,
        error.FileNotFound,
        error.AccessDenied,
        error.PermissionDenied,
        error.NotDir,
        error.IsDir,
        error.SymLinkLoop,
        error.NameTooLong,
        error.BadPathName,
        => error.ManagedConfigUnreadable,
        else => |other| other,
    };
}

/// The file, NUL-terminated because `std.zon.parse` needs a sentinel and
/// `readFileAlloc` cannot supply one. Errors travel exactly as the filesystem
/// returned them: `nxdns import` reads an operator-supplied argument, not a
/// managed file, and its exit codes are its own.
pub fn readSource(
    io: std.Io,
    gpa: Allocator,
    dir: std.Io.Dir,
    path: []const u8,
) ReadError![:0]u8 {
    return dir.readFileAllocOptions(io, path, gpa, .limited(max_config_bytes), .of(u8), 0);
}

/// `readSource` under the managed-file classification, with the reason recorded
/// as a diagnostic. A Zig error carries no text, so the path an operator has to
/// go and fix reaches them through `Diagnostics` — the same channel every other
/// configuration problem travels down, and the reason `check` and `run` print
/// these in one shape.
pub fn readManaged(
    io: std.Io,
    gpa: Allocator,
    dir: std.Io.Dir,
    path: []const u8,
    diags: *validate.Diagnostics,
) Error![:0]u8 {
    return readSource(io, gpa, dir, path) catch |e| {
        switch (e) {
            error.StreamTooLong => try diags.add(
                error.ConfigTooLarge,
                "{s}",
                .{path},
                "larger than {d} bytes",
                .{max_config_bytes},
            ),
            error.FileNotFound => try diags.add(
                error.ManagedConfigUnreadable,
                "{s}",
                .{path},
                "no such file",
                .{},
            ),
            error.AccessDenied, error.PermissionDenied => try diags.add(
                error.ManagedConfigUnreadable,
                "{s}",
                .{path},
                "not readable",
                .{},
            ),
            error.IsDir => try diags.add(
                error.ManagedConfigUnreadable,
                "{s}",
                .{path},
                "is a directory, not a configuration file",
                .{},
            ),
            error.NotDir, error.SymLinkLoop, error.NameTooLong, error.BadPathName => try diags.add(
                error.ManagedConfigUnreadable,
                "{s}",
                .{path},
                "cannot be opened ({s})",
                .{@errorName(e)},
            ),
            // A box fault. It exits 1 with its own name and records nothing: a
            // diagnostic would file it under "the configuration is wrong".
            else => {},
        }
        return mapReadError(e);
    };
}

/// The line and column of a ZON syntax error are the only thing the operator can
/// act on, so they travel the same channel as every other config problem: the
/// caller's `Diagnostics`, which `run`, `check` and `import` all render. The
/// global log is not that channel — an operator reading command output would see
/// a bare `ParseZon` and nothing else.
///
/// `std.zon.parse.Diagnostics` renders one "line:column: error: text" line per
/// problem, plus a "note:" line each, so each rendered line becomes one
/// `Problem` and the list keeps the parser's order. Rendering them inline would
/// put newlines inside a single `FAIL` record.
pub fn reportParseFailure(
    diags: *validate.Diagnostics,
    zon_diag: *const std.zon.parse.Diagnostics,
) error{OutOfMemory}!void {
    const rendered = try std.fmt.allocPrint(diags.gpa, "{f}", .{zon_diag});
    defer diags.gpa.free(rendered);

    var lines = std.mem.splitScalar(u8, rendered, '\n');
    while (lines.next()) |line| {
        if (line.len == 0) continue;
        try diags.add(error.ParseZon, "config", .{}, "{s}", .{line});
    }
}

/// The parse, with its failure rendered. The result is arena-owned and
/// `std.zon.parse.free` is NEVER called on it: `Parser.parseStruct` fills an
/// absent field by copying the struct's default straight through
/// (parse.zig:874), so a defaulted `[]const u8` — and this model has many
/// non-empty string defaults — points into the binary's read-only data.
/// `parse.free` keeps no record of which fields were parsed and which were
/// defaulted, so it would `@memset` and free rodata. Freeing the arena is the
/// only correct release.
pub fn parse(
    arena: Allocator,
    source: [:0]const u8,
    diags: *validate.Diagnostics,
) error{ ParseZon, OutOfMemory }!model.Config {
    var zon_diag: std.zon.parse.Diagnostics = .{};
    return std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) {
        error.OutOfMemory => error.OutOfMemory,
        error.ParseZon => {
            try reportParseFailure(diags, &zon_diag);
            return error.ParseZon;
        },
    };
}

/// Read and parse the managed file, everything a caller needs before
/// `validate.validate`. Validation is deliberately left to the caller: `check`
/// runs it beside its certificate and upstream probes, `run` runs it alone, and
/// both call the same validator on the same `Config`, which is what makes the
/// two agree.
///
/// `arena` owns both the source text and the returned configuration.
pub fn load(
    io: std.Io,
    arena: Allocator,
    dir: std.Io.Dir,
    path: []const u8,
    diags: *validate.Diagnostics,
) Error!model.Config {
    const source = try readManaged(io, arena, dir, path, diags);
    return parse(arena, source, diags);
}

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

const testing = std.testing;

test "every path-class open failure is a managed-config fault" {
    inline for (@typeInfo(PathFault).error_set.?) |member| {
        const e = @field(ReadError, member.name);
        try testing.expectEqual(error.ManagedConfigUnreadable, mapReadError(e));
    }
}

test "a box fault outside the path class propagates unmapped" {
    // Each of these exits 1: a retry can clear them, and the shipped unit's
    // `RestartPreventExitStatus=2 64` would make exit 2 permanent.
    try testing.expectEqual(error.SystemResources, mapReadError(error.SystemResources));
    try testing.expectEqual(error.ProcessFdQuotaExceeded, mapReadError(error.ProcessFdQuotaExceeded));
    try testing.expectEqual(error.SystemFdQuotaExceeded, mapReadError(error.SystemFdQuotaExceeded));
    try testing.expectEqual(error.OutOfMemory, mapReadError(error.OutOfMemory));
    try testing.expectEqual(error.InputOutput, mapReadError(error.InputOutput));
}

test "the size limit is its own fault, not an unreadable path" {
    try testing.expectEqual(error.ConfigTooLarge, mapReadError(error.StreamTooLong));
}

test "the path class is exactly the eight members the ruling names" {
    // A member added to `PathFault` without a decision recorded in the spec
    // fails here rather than quietly moving an exit code from 1 to 2.
    const expected = [_][]const u8{
        "FileNotFound", "AccessDenied", "PermissionDenied", "NotDir",
        "IsDir",        "SymLinkLoop",  "NameTooLong",      "BadPathName",
    };
    const members = @typeInfo(PathFault).error_set.?;
    try testing.expectEqual(expected.len, members.len);
    inline for (members) |member| {
        var found = false;
        for (expected) |name| {
            if (std.mem.eql(u8, name, member.name)) found = true;
        }
        try testing.expect(found);
    }
}

test "a missing managed file records the path and the reason" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();

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

    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();

    try testing.expectError(
        error.ManagedConfigUnreadable,
        load(testing.io, arena_state.allocator(), tmp.dir, "nope.zon", &diags),
    );
    try testing.expectEqual(@as(usize, 1), diags.failureCount());
    try testing.expectEqualStrings("nope.zon", diags.problems.items[0].path);
    try testing.expectEqualStrings("no such file", diags.problems.items[0].message);
}

test "a directory named as the managed file is a configuration fault, not a crash" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.createDirPath(testing.io, "sub");

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

    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();

    try testing.expectError(
        error.ManagedConfigUnreadable,
        load(testing.io, arena_state.allocator(), tmp.dir, "sub", &diags),
    );
    try testing.expectEqual(@as(usize, 1), diags.failureCount());
}

test "load parses a valid file and renders a syntax error line by line" {
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(testing.io, .{ .sub_path = "good.zon", .data =
        \\.{
        \\    .groups = .{ .{ .name = "default" } },
        \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
        \\}
    });
    try tmp.dir.writeFile(testing.io, .{ .sub_path = "bad.zon", .data = ".{ .groups = " });

    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    var good_diags: validate.Diagnostics = .init(testing.allocator);
    defer good_diags.deinit();
    const cfg = try load(testing.io, arena, tmp.dir, "good.zon", &good_diags);
    try testing.expectEqual(@as(usize, 0), good_diags.problems.items.len);
    try testing.expectEqual(@as(usize, 1), cfg.upstreams.len);

    var bad_diags: validate.Diagnostics = .init(testing.allocator);
    defer bad_diags.deinit();
    try testing.expectError(
        error.ParseZon,
        load(testing.io, arena, tmp.dir, "bad.zon", &bad_diags),
    );
    try testing.expect(bad_diags.failureCount() >= 1);
    try testing.expectEqualStrings("config", bad_diags.problems.items[0].path);
}
