//! Atomic filesystem writes.
//!
//! `writeFileAtomic` writes to `<path>.tmp`, fsyncs, and renames to
//! `<path>`. Crash-safe replacement for `createFile + writeAll + close`:
//! if the process dies mid-write, the destination file is left at its
//! prior contents (or absent) rather than truncated or half-written.
//!
//! Used by the snapshot writer so a ctrl-C or kernel panic mid-run
//! can't produce a corrupt `history/<date>-portfolio.srf`.

const std = @import("std");

/// Suffix appended to the temp file during atomic writes. Exposed so
/// callers that want to sweep orphan temp files (e.g. from a previous
/// crash) know what to look for.
pub const tmp_suffix = ".tmp";

/// Write `bytes` to `path` atomically.
///
/// Strategy:
/// 1. Write to `<path>.tmp` (truncating any previous tmp file).
/// 2. `fsync` the tmp file so the data is durable before we rename.
/// 3. Rename tmp -> path (atomic on POSIX when src/dst are on the same
///    filesystem, which is guaranteed here because both are the literal
///    path plus `.tmp`).
///
/// On any error the tmp file is best-effort removed so we don't leave
/// clutter behind. The caller's `path` is unchanged unless the final
/// rename succeeds.
///
/// The allocator is used for a short-lived temp-path buffer
/// (`path.len + tmp_suffix.len` bytes) and freed before return.
pub fn writeFileAtomic(
    io: std.Io,
    allocator: std.mem.Allocator,
    path: []const u8,
    bytes: []const u8,
) !void {
    const tmp_path = try std.fmt.allocPrint(allocator, "{s}{s}", .{ path, tmp_suffix });
    defer allocator.free(tmp_path);

    {
        var tmp_file = try std.Io.Dir.cwd().createFile(io, tmp_path, .{
            .truncate = true,
            .exclusive = false,
        });
        errdefer {
            tmp_file.close(io);
            // Best-effort cleanup of the temp file while unwinding
            // the primary error; a failed delete leaves a stray
            // .tmp that the next write overwrites.
            std.Io.Dir.cwd().deleteFile(io, tmp_path) catch |err| {
                std.log.debug("atomic write cleanup deleteFile({s}): {t}", .{ tmp_path, err });
            };
        }

        try tmp_file.writeStreamingAll(io, bytes);
        // fsync so the kernel flushes data to disk before the rename
        // appears. Without this, a crash between rename() and the data
        // hitting disk could leave an empty-but-present file at `path`.
        try tmp_file.sync(io);
        tmp_file.close(io);
    }

    std.Io.Dir.cwd().rename(tmp_path, std.Io.Dir.cwd(), path, io) catch |err| {
        // Same best-effort cleanup as above; the rename failure is
        // the error the caller needs to see.
        std.Io.Dir.cwd().deleteFile(io, tmp_path) catch |del_err| {
            std.log.debug("atomic write cleanup deleteFile({s}): {t}", .{ tmp_path, del_err });
        };
        return err;
    };
}

// ── Tests ────────────────────────────────────────────────────

test "writeFileAtomic creates new file" {
    const io = std.testing.io;
    var tmp_dir = std.testing.tmpDir(.{});
    defer tmp_dir.cleanup();

    var path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir_len = try tmp_dir.dir.realPathFile(io, ".", &path_buf);
    const dir_path = path_buf[0..dir_len];
    const file_path = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "atomic_new.txt" });
    defer std.testing.allocator.free(file_path);

    try writeFileAtomic(io, std.testing.allocator, file_path, "hello world\n");

    const contents = try std.Io.Dir.cwd().readFileAlloc(io, file_path, std.testing.allocator, .limited(4096));
    defer std.testing.allocator.free(contents);
    try std.testing.expectEqualStrings("hello world\n", contents);

    // Tmp file should have been consumed by rename.
    const tmp_path = try std.fmt.allocPrint(std.testing.allocator, "{s}{s}", .{ file_path, tmp_suffix });
    defer std.testing.allocator.free(tmp_path);
    try std.testing.expectError(error.FileNotFound, std.Io.Dir.cwd().access(io, tmp_path, .{}));

    // Clean up for the next test run.
    std.Io.Dir.cwd().deleteFile(io, file_path) catch {};
}

test "writeFileAtomic overwrites existing file" {
    const io = std.testing.io;
    var tmp_dir = std.testing.tmpDir(.{});
    defer tmp_dir.cleanup();

    var path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir_len = try tmp_dir.dir.realPathFile(io, ".", &path_buf);
    const dir_path = path_buf[0..dir_len];
    const file_path = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "atomic_over.txt" });
    defer std.testing.allocator.free(file_path);

    // Seed with old content.
    {
        var f = try std.Io.Dir.cwd().createFile(io, file_path, .{});
        try f.writeStreamingAll(io, "old contents");
        f.close(io);
    }

    try writeFileAtomic(io, std.testing.allocator, file_path, "new contents");

    const contents = try std.Io.Dir.cwd().readFileAlloc(io, file_path, std.testing.allocator, .limited(4096));
    defer std.testing.allocator.free(contents);
    try std.testing.expectEqualStrings("new contents", contents);

    std.Io.Dir.cwd().deleteFile(io, file_path) catch {};
}

test "writeFileAtomic: missing parent directory surfaces FileNotFound" {
    // Point at a path whose parent directory doesn't exist. The tmp dir
    // itself exists (so the filesystem is fine), but the "missing"
    // subdirectory does not — createFile on the .tmp file must fail
    // with FileNotFound regardless of platform.
    const io = std.testing.io;
    var tmp_dir = std.testing.tmpDir(.{});
    defer tmp_dir.cleanup();

    var path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const dir_len = try tmp_dir.dir.realPathFile(io, ".", &path_buf);
    const dir_path = path_buf[0..dir_len];
    const bad_path = try std.fs.path.join(
        std.testing.allocator,
        &.{ dir_path, "missing", "file.txt" },
    );
    defer std.testing.allocator.free(bad_path);

    try std.testing.expectError(
        error.FileNotFound,
        writeFileAtomic(io, std.testing.allocator, bad_path, "x"),
    );
}
