//! `zfin version` — print version and build info.
//!
//! Default: single line, e.g.
//!   zfin v0.3.1-4-g1a2b3c4 (built 2026-04-21)
//!
//! `--verbose`: adds zig version, build mode, resolved user file dir
//! (ZFIN_HOME), and the cache directory. Intended for bug reports and
//! "what am I running?" diagnostics.

const std = @import("std");
const builtin = @import("builtin");
const zfin = @import("../root.zig");
const version = @import("../version.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const Date = @import("../Date.zig");

pub const ParsedArgs = struct {
    verbose: bool = false,
};

pub const meta: framework.Meta = .{
    .name = "version",
    .group = .infra,
    .synopsis = "Show zfin version and build info",
    .help =
    \\Usage: zfin version [-v|--verbose]
    \\
    \\Print zfin's version + build date. With `--verbose`/`-v`, also
    \\prints the Zig compiler version, build mode, build target,
    \\resolved ZFIN_HOME, and cache directory — useful for bug
    \\reports.
    \\
    ,
    .uppercase_first_arg = false,
    .user_errors = error{UnexpectedArg},
};

/// Parse `--verbose`/`-v`. Unknown args produce an error on stderr.
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
    var parsed: ParsedArgs = .{};
    for (cmd_args) |a| {
        if (std.mem.eql(u8, a, "--verbose") or std.mem.eql(u8, a, "-v")) {
            parsed.verbose = true;
        } else {
            cli.stderrPrint(ctx.io, "Error: unknown argument to 'version': ");
            cli.stderrPrint(ctx.io, a);
            cli.stderrPrint(ctx.io, "\n");
            return error.UnexpectedArg;
        }
    }
    return parsed;
}

/// Run the version command.
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
    try writeVersion(ctx.out, ctx.config, parsed.verbose);
}

/// Render version info into `out`. Separated from `run` so tests can
/// exercise the formatting without going through flag parsing and the
/// stderr side-effect on unknown args.
///
/// The rendered output is fully determined by `config` (for ZFIN_HOME and
/// cache_dir when verbose) plus the build-time `version` module. Build-time
/// values (version string, build timestamp, zig version, target) are
/// constants from the perspective of a single test run, so tests assert on
/// their presence/shape rather than exact values.
pub fn writeVersion(
    out: *std.Io.Writer,
    config: zfin.Config,
    verbose: bool,
) !void {
    const build_date_buf = blk: {
        var buf: [10]u8 = undefined;
        const d = Date.fromEpoch(version.build_timestamp);
        const s = try std.fmt.bufPrint(&buf, "{f}", .{d});
        break :blk .{ .buf = buf, .len = s.len };
    };
    const build_date = build_date_buf.buf[0..build_date_buf.len];

    try out.print("zfin {s} (built {s})\n", .{ version.version_string, build_date });

    if (!verbose) return;

    try out.print("  zig version:      {s}\n", .{builtin.zig_version_string});
    try out.print("  build mode:       {s}\n", .{@tagName(builtin.mode)});
    try out.print("  target:           {s}-{s}\n", .{
        @tagName(builtin.cpu.arch),
        @tagName(builtin.os.tag),
    });
    if (config.zfin_home) |home| {
        try out.print("  ZFIN_HOME:        {s}\n", .{home});
    } else {
        try out.print("  ZFIN_HOME:        (unset)\n", .{});
    }
    try out.print("  cache dir:        {s}\n", .{config.cache_dir});
    if (version.isDirty()) {
        try out.print("  note:             built from a dirty worktree\n", .{});
    }
}

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

/// Build a bare-minimum `Config` for testing. Does not allocate and does
/// not touch the environment; the caller specifies exactly which fields
/// matter for the behavior under test. The returned Config has no
/// allocator set so `deinit` is a no-op.
fn stubConfig(zfin_home: ?[]const u8, cache_dir: []const u8) zfin.Config {
    return .{
        .cache_dir = cache_dir,
        .zfin_home = zfin_home,
    };
}

/// Build a minimal `RunCtx` for tests. Most fields are unused by the
/// version command's `run` path; we set what writeVersion reads.
fn stubCtx(out: *std.Io.Writer, cfg: zfin.Config) framework.RunCtx {
    return .{
        .io = std.testing.io,
        .allocator = std.testing.allocator,
        .gpa = std.testing.allocator,
        // SAFETY: version cmd doesn't read environ_map.
        .environ_map = undefined,
        .config = cfg,
        .svc = null,
        .globals = .{},
        .today = Date.fromYmd(2026, 5, 9),
        .now_s = 0,
        .color = false,
        .out = out,
    };
}

test "parseArgs: no args → verbose=false" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{};
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expect(!parsed.verbose);
}

test "parseArgs: --verbose sets verbose" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"--verbose"};
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expect(parsed.verbose);
}

test "parseArgs: -v short form" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"-v"};
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expect(parsed.verbose);
}

test "parseArgs: unknown flag errors" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"--bogus"};
    try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}

test "run: no args prints single-line banner" {
    var buf: [1024]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const cfg = stubConfig(null, "/tmp/test-cache");
    var ctx = stubCtx(&w, cfg);
    try run(&ctx, .{});
    const out = w.buffered();

    // Banner shape: starts with "zfin ", contains " (built ", ends with newline.
    try std.testing.expect(std.mem.startsWith(u8, out, "zfin "));
    try std.testing.expect(std.mem.indexOf(u8, out, " (built ") != null);
    try std.testing.expect(std.mem.endsWith(u8, out, ")\n"));

    // Non-verbose: should NOT include any of the indented fields.
    try std.testing.expect(std.mem.indexOf(u8, out, "zig version:") == null);
    try std.testing.expect(std.mem.indexOf(u8, out, "cache dir:") == null);
    try std.testing.expect(std.mem.indexOf(u8, out, "ZFIN_HOME:") == null);

    // Single line of output.
    const newline_count = std.mem.count(u8, out, "\n");
    try std.testing.expectEqual(@as(usize, 1), newline_count);
}

test "run: --verbose includes all diagnostic fields" {
    var buf: [2048]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const cfg = stubConfig("/some/zfin/home", "/tmp/expected-cache-dir");
    var ctx = stubCtx(&w, cfg);
    try run(&ctx, .{ .verbose = true });
    const out = w.buffered();

    // Banner still first line
    try std.testing.expect(std.mem.startsWith(u8, out, "zfin "));

    // Every verbose field present
    try std.testing.expect(std.mem.indexOf(u8, out, "zig version:") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "build mode:") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "target:") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "ZFIN_HOME:") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "cache dir:") != null);

    // Config values appear in the rendered output (proof the config is
    // actually consulted, not ignored).
    try std.testing.expect(std.mem.indexOf(u8, out, "/some/zfin/home") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "/tmp/expected-cache-dir") != null);

    // The exact zig version string from builtin should appear.
    try std.testing.expect(std.mem.indexOf(u8, out, builtin.zig_version_string) != null);
}

test "writeVersion: ZFIN_HOME=null renders '(unset)'" {
    var buf: [2048]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const cfg = stubConfig(null, "/some/cache");
    try writeVersion(&w, cfg, true);
    const out = w.buffered();
    try std.testing.expect(std.mem.indexOf(u8, out, "ZFIN_HOME:        (unset)") != null);
    // And the bogus path should NOT appear.
    try std.testing.expect(std.mem.indexOf(u8, out, "(some/home)") == null);
}

test "writeVersion: build date parses as a valid YYYY-MM-DD" {
    var buf: [512]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const cfg = stubConfig(null, "/cache");
    try writeVersion(&w, cfg, false);
    const out = w.buffered();

    // Extract the text between "(built " and ")".
    const prefix = "(built ";
    const start = (std.mem.indexOf(u8, out, prefix) orelse return error.MissingPrefix) + prefix.len;
    const end = std.mem.indexOfScalarPos(u8, out, start, ')') orelse return error.MissingCloseParen;
    const date_str = out[start..end];

    // Format: exactly 10 chars, positions 4 and 7 are '-'.
    try std.testing.expectEqual(@as(usize, 10), date_str.len);
    try std.testing.expectEqual(@as(u8, '-'), date_str[4]);
    try std.testing.expectEqual(@as(u8, '-'), date_str[7]);
    // Year is plausibly recent-ish (>= 2020, < 2100).
    const year = try std.fmt.parseInt(u16, date_str[0..4], 10);
    try std.testing.expect(year >= 2020 and year < 2100);
}

test "Date.fromEpoch round-trip for build timestamp" {
    // Sanity-check the Date conversion used for build_date formatting.
    const ts: i64 = 1_745_222_400; // 2025-04-21 00:00 UTC
    const d = Date.fromEpoch(ts);
    var buf: [10]u8 = undefined;
    const s = std.fmt.bufPrint(&buf, "{f}", .{d}) catch unreachable;
    try std.testing.expectEqualStrings("2025-04-21", s);
}
