//! Money: a thin wrapper around `f64` representing a dollar amount,
//! with format methods that integrate with `std.fmt`'s `{f}`
//! specifier.
//!
//! Storage stays `f64` (matches existing zfin convention; no decimal
//! arithmetic). The wrapper exists for two reasons:
//!
//!   1. **Format-method dispatch.** Zig 0.15+'s format-method
//!      protocol (`pub fn format(self, w: *Writer) !void`) lets
//!      `try writer.print("{f}", .{m})` render a Money value
//!      directly with no buffer ceremony. Distinct rendering
//!      modes — whole-dollar, trim-trailing-`.00`, signed — are
//!      exposed as wrapper-returning methods on `Money`:
//!      `m.whole()`, `m.trim()`, `m.signed()`. Each returns a
//!      small wrapper struct whose own `format` method emits the
//!      variant.
//!
//!   2. **Single home for money formatting.** Replaces the
//!      previous scatter across `src/format.zig` (fmtMoneyAbs,
//!      fmtMoneyAbsWhole, fmtMoneyAbsTrim) and
//!      `src/views/history.zig` (fmtSignedMoneyBuf).
//!
//! ## Usage
//!
//! ```zig
//! const Money = @import("Money.zig");
//!
//! // Default {f}: "$1,234.56" with always-present cents.
//! try writer.print("{f}\n", .{Money.from(1234.56)});
//!
//! // Whole-dollar: "$1,235" rounded.
//! try writer.print("{f}\n", .{Money.from(1234.56).whole()});
//!
//! // Elide trailing .00: "$1,234" if whole, "$1,234.56" if fractional.
//! try writer.print("{f}\n", .{Money.from(1234.00).trim()});
//!
//! // Signed: "+$1,234.56" / "-$1,234.56" / "$0.00".
//! try writer.print("{f}\n", .{Money.from(-1234.56).signed()});
//! ```
//!
//! ## On `f64` precision
//!
//! For zfin's use case (personal-finance, totals up to ~$100M,
//! display rounded to cents), f64 has plenty of headroom. f64
//! starts losing cent precision around $10^15 ($1 quadrillion).
//! See AGENTS.md "Time and money helpers" for the full discussion.
//! When this stops being true, change the underlying storage in
//! ONE place (this file) and the format methods get redone — no
//! call-site churn.

const std = @import("std");

amount: f64,

const Money = @This();

/// Generic alignment wrapper, shared with `Date` and any other
/// type that exposes a `format(self, *Writer)` method.
pub const Padded = @import("padded.zig").Padded;

/// Construct a Money from a raw f64 dollar amount.
pub fn from(amount: f64) Money {
    return .{ .amount = amount };
}

/// Zero dollars. Convenience for explicit-init sites.
pub const zero: Money = .{ .amount = 0 };

// ── Standard format method (called via `{f}`) ──────────────────

/// Default format: `$1,234.56`. Always positive (sign is the
/// caller's job). 2 decimal places.
pub fn format(self: Money, w: *std.Io.Writer) std.Io.Writer.Error!void {
    try writeAbsCents(w, self.amount);
}

// ── Variant wrappers ───────────────────────────────────────────

/// Wrapper whose `{f}` emits whole dollars rounded: `$1,234`.
/// Distinct from `trim()` — `whole()` rounds 1234.56 to `$1,235`.
pub fn whole(self: Money) Whole {
    return .{ .amount = self.amount };
}

/// Wrapper whose `{f}` elides a trailing `.00` but preserves
/// non-zero cents: `$1,234` for 1234.00, `$1,234.56` for 1234.56.
/// Use for cosmetic displays where cents-when-present are still
/// informative.
pub fn trim(self: Money) Trim {
    return .{ .amount = self.amount };
}

/// Wrapper whose `{f}` emits a leading sign: `+$1,234.56`,
/// `-$1,234.56`, or `$0.00` for zero.
pub fn signed(self: Money) Signed {
    return .{ .amount = self.amount };
}

/// Pad the default-formatted Money to `width` columns, right-aligned.
/// For column-aligned tabular output: `try out.print("{f}", .{Money.from(x).padRight(10)})`.
///
/// Composes with the variant wrappers — call `.padRight(N)` on a
/// `Whole`, `Trim`, or `Signed` directly via the same generic
/// helper. See `Padded` below.
pub fn padRight(self: Money, width: usize) Padded(Money) {
    return .{ .inner = self, .width = width, .alignment = .right };
}

/// Pad the default-formatted Money to `width` columns, left-aligned.
pub fn padLeft(self: Money, width: usize) Padded(Money) {
    return .{ .inner = self, .width = width, .alignment = .left };
}

pub const Whole = struct {
    amount: f64,

    pub fn format(self: Whole, w: *std.Io.Writer) std.Io.Writer.Error!void {
        try writeAbsWhole(w, self.amount);
    }

    pub fn padRight(self: Whole, width: usize) Padded(Whole) {
        return .{ .inner = self, .width = width, .alignment = .right };
    }
    pub fn padLeft(self: Whole, width: usize) Padded(Whole) {
        return .{ .inner = self, .width = width, .alignment = .left };
    }
};

pub const Trim = struct {
    amount: f64,

    pub fn format(self: Trim, w: *std.Io.Writer) std.Io.Writer.Error!void {
        // Render via a fixed buffer, then trim the .00 if present.
        // The buffer is sized for the worst case (negative billions
        // with cents, ~$1,234,567,890.12 = 14 chars); 24 bytes is
        // generous.
        var tmp: [24]u8 = undefined;
        var fixed = std.Io.Writer.fixed(&tmp);
        try writeAbsCents(&fixed, self.amount);
        const written = fixed.buffered();
        const out = if (std.mem.endsWith(u8, written, ".00"))
            written[0 .. written.len - 3]
        else
            written;
        try w.writeAll(out);
    }

    pub fn padRight(self: Trim, width: usize) Padded(Trim) {
        return .{ .inner = self, .width = width, .alignment = .right };
    }
    pub fn padLeft(self: Trim, width: usize) Padded(Trim) {
        return .{ .inner = self, .width = width, .alignment = .left };
    }
};

pub const Signed = struct {
    amount: f64,

    pub fn format(self: Signed, w: *std.Io.Writer) std.Io.Writer.Error!void {
        if (self.amount > 0) try w.writeByte('+');
        if (self.amount < 0) try w.writeByte('-');
        try writeAbsCents(w, self.amount);
    }

    pub fn padRight(self: Signed, width: usize) Padded(Signed) {
        return .{ .inner = self, .width = width, .alignment = .right };
    }
    pub fn padLeft(self: Signed, width: usize) Padded(Signed) {
        return .{ .inner = self, .width = width, .alignment = .left };
    }
};

// ── Internal: byte-emission shared by all variants ─────────────

/// Write the absolute value of `amount` as `$X,XXX.XX` directly to
/// `w`. Same algorithm as the original `fmt.fmtMoneyAbs` — produces
/// byte-identical output, just streams to a writer instead of
/// returning a slice.
fn writeAbsCents(w: *std.Io.Writer, amount: f64) std.Io.Writer.Error!void {
    const cents: i64 = @intFromFloat(@round(amount * 100.0));
    const abs_cents = if (cents < 0) @as(u64, @intCast(-cents)) else @as(u64, @intCast(cents));
    const dollars = abs_cents / 100;
    const rem = abs_cents % 100;

    // Build into a stack buffer, then write all at once. The
    // buffer is sized for the worst case (24-char negative
    // billions); using a fixed buffer keeps the per-digit
    // append-and-shift logic identical to the original.
    var tmp: [24]u8 = undefined;
    var pos: usize = tmp.len;

    // Cents
    pos -= 1;
    tmp[pos] = '0' + @as(u8, @intCast(rem % 10));
    pos -= 1;
    tmp[pos] = '0' + @as(u8, @intCast(rem / 10));
    pos -= 1;
    tmp[pos] = '.';

    // Dollars with commas
    var d = dollars;
    var digit_count: usize = 0;
    if (d == 0) {
        pos -= 1;
        tmp[pos] = '0';
    } else {
        while (d > 0) {
            if (digit_count > 0 and digit_count % 3 == 0) {
                pos -= 1;
                tmp[pos] = ',';
            }
            pos -= 1;
            tmp[pos] = '0' + @as(u8, @intCast(d % 10));
            d /= 10;
            digit_count += 1;
        }
    }
    pos -= 1;
    tmp[pos] = '$';

    try w.writeAll(tmp[pos..]);
}

/// Write the absolute value of `amount` as `$X,XXX` (rounded to
/// whole dollars, no decimal portion) directly to `w`.
fn writeAbsWhole(w: *std.Io.Writer, amount: f64) std.Io.Writer.Error!void {
    const dollars_signed: i64 = @intFromFloat(@round(amount));
    const dollars: u64 = if (dollars_signed < 0) @intCast(-dollars_signed) else @intCast(dollars_signed);

    var tmp: [24]u8 = undefined;
    var pos: usize = tmp.len;

    var d = dollars;
    var digit_count: usize = 0;
    if (d == 0) {
        pos -= 1;
        tmp[pos] = '0';
    } else {
        while (d > 0) {
            if (digit_count > 0 and digit_count % 3 == 0) {
                pos -= 1;
                tmp[pos] = ',';
            }
            pos -= 1;
            tmp[pos] = '0' + @as(u8, @intCast(d % 10));
            d /= 10;
            digit_count += 1;
        }
    }
    pos -= 1;
    tmp[pos] = '$';

    try w.writeAll(tmp[pos..]);
}

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

const testing = std.testing;

/// Render a value via the `{f}` format spec into an arena-allocated
/// string. Used by tests to verify format-method output matches the
/// expected string exactly.
fn renderToString(allocator: std.mem.Allocator, value: anytype) ![]u8 {
    var aw: std.Io.Writer.Allocating = .init(allocator);
    defer aw.deinit();
    try aw.writer.print("{f}", .{value});
    return aw.toOwnedSlice();
}

test "Money default {f}: $X,XXX.XX with cents" {
    const allocator = testing.allocator;

    const cases = [_]struct { amount: f64, expected: []const u8 }{
        .{ .amount = 0, .expected = "$0.00" },
        .{ .amount = 1, .expected = "$1.00" },
        .{ .amount = 1.23, .expected = "$1.23" },
        .{ .amount = 1234.56, .expected = "$1,234.56" },
        .{ .amount = 1_234_567.89, .expected = "$1,234,567.89" },
        // Negative amounts emit absolute value — sign is caller's job
        // unless they use `.signed()`.
        .{ .amount = -1234.56, .expected = "$1,234.56" },
    };

    for (cases) |c| {
        const s = try renderToString(allocator, Money.from(c.amount));
        defer allocator.free(s);
        try testing.expectEqualStrings(c.expected, s);
    }
}

test "Money.whole(): rounds to dollars, no decimals" {
    const allocator = testing.allocator;

    const cases = [_]struct { amount: f64, expected: []const u8 }{
        .{ .amount = 0, .expected = "$0" },
        .{ .amount = 1, .expected = "$1" },
        .{ .amount = 1234, .expected = "$1,234" },
        .{ .amount = 1_234_567, .expected = "$1,234,567" },
        // @round is half-away-from-zero.
        .{ .amount = 1.5, .expected = "$2" },
        .{ .amount = 1.49, .expected = "$1" },
        .{ .amount = 0.4, .expected = "$0" },
        // Negative magnitude rounds: |-1234.56| = 1234.56 → 1235.
        .{ .amount = -1234.56, .expected = "$1,235" },
    };

    for (cases) |c| {
        const s = try renderToString(allocator, Money.from(c.amount).whole());
        defer allocator.free(s);
        try testing.expectEqualStrings(c.expected, s);
    }
}

test "Money.trim(): elide .00 only" {
    const allocator = testing.allocator;

    const cases = [_]struct { amount: f64, expected: []const u8 }{
        // Whole values elide .00.
        .{ .amount = 0, .expected = "$0" },
        .{ .amount = 1234, .expected = "$1,234" },
        .{ .amount = 1_234_567, .expected = "$1,234,567" },
        // Non-zero cents preserved.
        .{ .amount = 1234.56, .expected = "$1,234.56" },
        .{ .amount = 0.01, .expected = "$0.01" },
        .{ .amount = 0.5, .expected = "$0.50" },
        // Sub-cent rounds-to-zero-cents elides too.
        .{ .amount = 10.001, .expected = "$10" },
        // Negative magnitude.
        .{ .amount = -1234, .expected = "$1,234" },
        .{ .amount = -1234.56, .expected = "$1,234.56" },
    };

    for (cases) |c| {
        const s = try renderToString(allocator, Money.from(c.amount).trim());
        defer allocator.free(s);
        try testing.expectEqualStrings(c.expected, s);
    }
}

test "Money.signed(): leading sign for non-zero, none for zero" {
    const allocator = testing.allocator;

    const cases = [_]struct { amount: f64, expected: []const u8 }{
        .{ .amount = 1234.56, .expected = "+$1,234.56" },
        .{ .amount = -1234.56, .expected = "-$1,234.56" },
        // Zero gets no sign — purely cosmetic, matches the prior
        // `fmtSignedMoneyBuf` convention.
        .{ .amount = 0, .expected = "$0.00" },
    };

    for (cases) |c| {
        const s = try renderToString(allocator, Money.from(c.amount).signed());
        defer allocator.free(s);
        try testing.expectEqualStrings(c.expected, s);
    }
}

test "Money.zero is zero" {
    try testing.expectEqual(@as(f64, 0), Money.zero.amount);
}

test "Money.from preserves the underlying f64" {
    try testing.expectEqual(@as(f64, 1234.56), Money.from(1234.56).amount);
    try testing.expectEqual(@as(f64, -42), Money.from(-42).amount);
}

test "Money.padRight pads with leading spaces" {
    const allocator = testing.allocator;
    // "$1,234.56" is 9 chars; pad to 12 → 3 leading spaces.
    const s = try renderToString(allocator, Money.from(1234.56).padRight(12));
    defer allocator.free(s);
    try testing.expectEqualStrings("   $1,234.56", s);
}

test "Money.padLeft pads with trailing spaces" {
    const allocator = testing.allocator;
    const s = try renderToString(allocator, Money.from(1234.56).padLeft(12));
    defer allocator.free(s);
    try testing.expectEqualStrings("$1,234.56   ", s);
}

test "padRight: text wider than width emits unchanged (no truncation)" {
    const allocator = testing.allocator;
    // "$1,234,567.89" is 13 chars; padding to 5 → unchanged.
    const s = try renderToString(allocator, Money.from(1_234_567.89).padRight(5));
    defer allocator.free(s);
    try testing.expectEqualStrings("$1,234,567.89", s);
}

test "padRight composes with whole/trim/signed variants" {
    const allocator = testing.allocator;

    // Whole + padRight: "$1,234" is 6 chars; pad to 10 → 4 spaces.
    const s_whole = try renderToString(allocator, Money.from(1234).whole().padRight(10));
    defer allocator.free(s_whole);
    try testing.expectEqualStrings("    $1,234", s_whole);

    // Trim + padRight: "$1,234.56" is 9 chars; pad to 12 → 3 spaces.
    const s_trim = try renderToString(allocator, Money.from(1234.56).trim().padRight(12));
    defer allocator.free(s_trim);
    try testing.expectEqualStrings("   $1,234.56", s_trim);

    // Signed + padRight: "+$1,234.56" is 10 chars; pad to 14 → 4 spaces.
    const s_signed = try renderToString(allocator, Money.from(1234.56).signed().padRight(14));
    defer allocator.free(s_signed);
    try testing.expectEqualStrings("    +$1,234.56", s_signed);
}
