//! Date represented as days since epoch (compact, sortable).
//!
//! This file IS the `Date` struct (Zig "files-are-structs"
//! pattern). Importers do `const Date = @import("Date.zig");`
//! and use it as the type directly — no `.Date` field
//! extraction.
//!
//! ## Format methods
//!
//! - `Date.format(self, *std.Io.Writer) !void` — Zig 0.15+
//!   format-method protocol. Renders "YYYY-MM-DD" via the `{f}`
//!   format spec: `try writer.print("{f}", .{my_date})`.
//! - `Date.padRight(width)` / `Date.padLeft(width)` — wrapper
//!   structs for column-aligned output: `{f}` + `my_date.padLeft(12)`.
//!   Use when previously you would have written `{s:>12}` with
//!   the legacy buffer-form formatter.
//!
//! For cases that need a `[]const u8` (URL params, struct field
//! assignment), call `std.fmt.bufPrint(&buf, "{f}", .{my_date})`
//! into a `[10]u8` buffer.
//!
//! ## Construction
//!
//! - `Date.fromYmd(y, m, d)` — calendar date
//! - `Date.fromEpoch(secs)` — Unix epoch seconds
//! - `Date.parse("YYYY-MM-DD")` — ISO string

const std = @import("std");
const srf = @import("srf");

/// Days since 1970-01-01.
days: i32,

/// Self-reference so internal code can use the simple `Date.foo`
/// form rather than `@This().foo` — matches the call-site style of
/// every external consumer.
const Date = @This();

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

pub const epoch: Date = .{ .days = 0 };

pub fn year(self: Date) i16 {
    return epochDaysToYmd(self.days).year;
}

pub fn month(self: Date) u8 {
    return epochDaysToYmd(self.days).month;
}

pub fn day(self: Date) u8 {
    return epochDaysToYmd(self.days).day;
}

pub fn fromYmd(y: i16, m: u8, d: u8) Date {
    return .{ .days = ymdToEpochDays(y, m, d) };
}

/// Parse "YYYY-MM-DD" format.
pub fn parse(str: []const u8) !Date {
    if (str.len != 10 or str[4] != '-' or str[7] != '-') return error.InvalidDateFormat;
    const y = std.fmt.parseInt(i16, str[0..4], 10) catch return error.InvalidDateFormat;
    const m = std.fmt.parseInt(u8, str[5..7], 10) catch return error.InvalidDateFormat;
    const d = std.fmt.parseInt(u8, str[8..10], 10) catch return error.InvalidDateFormat;
    return fromYmd(y, m, d);
}

/// Hook for srf coercion via `FieldIterator.to(T, ...)`. Returns a
/// `CoercionResult(Date)` with `require_free_original = true` so SRF
/// frees the consumed source string after parsing.
pub fn srfParse(str: []const u8) !srf.CoercionResult(Date) {
    return .initFree(try parse(str));
}

/// Hook for srf serialization. Writes "YYYY-MM-DD" to the writer
/// using the "string" type (untyped) so the value parses cleanly
/// back through `Date.srfParse` on read.
pub fn srfFormat(self: Date, comptime field_name: []const u8, writer: *std.Io.Writer) std.Io.Writer.Error!void {
    const ymd = epochDaysToYmd(self.days);
    const y: u16 = @intCast(ymd.year);
    try writer.print("{s}::{d:0>4}-{d:0>2}-{d:0>2}", .{ field_name, y, ymd.month, ymd.day });
}

/// Zig 0.15+ format method: writes "YYYY-MM-DD" to the writer.
/// Invoked via `try w.print("{f}", .{my_date})`.
pub fn format(self: Date, w: *std.Io.Writer) std.Io.Writer.Error!void {
    const ymd = epochDaysToYmd(self.days);
    const y: u16 = @intCast(ymd.year);
    try w.print("{d:0>4}-{d:0>2}-{d:0>2}", .{ y, ymd.month, ymd.day });
}

/// Pad the formatted Date to `width` columns, right-aligned with
/// leading spaces. For column-aligned tabular output:
/// `try out.print("{f}", .{my_date.padRight(12)})`. Date renders
/// as 10 chars; widths < 10 emit the date unchanged.
pub fn padRight(self: Date, width: usize) Padded(Date) {
    return .{ .inner = self, .width = width, .alignment = .right };
}

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

/// Day of week: 0=Monday, 1=Tuesday, ..., 4=Friday, 5=Saturday, 6=Sunday.
pub fn dayOfWeek(self: Date) u8 {
    // 1970-01-01 was a Thursday (day 3 in 0=Mon scheme)
    const d = @mod(self.days + 3, @as(i32, 7));
    return @intCast(if (d < 0) d + 7 else d);
}

pub fn eql(a: Date, b: Date) bool {
    return a.days == b.days;
}

pub fn lessThan(a: Date, b: Date) bool {
    return a.days < b.days;
}

pub fn addDays(self: Date, n: i32) Date {
    return .{ .days = self.days + n };
}

/// Convert to Unix epoch seconds (midnight UTC on this date).
pub fn toEpoch(self: Date) i64 {
    return @as(i64, self.days) * std.time.s_per_day;
}

/// Create a Date from a Unix epoch timestamp (seconds since 1970-01-01).
pub fn fromEpoch(epoch_secs: i64) Date {
    return .{ .days = @intCast(@divFloor(epoch_secs, std.time.s_per_day)) };
}

/// Subtract N calendar years. Clamps Feb 29 -> Feb 28 if target is not a leap year.
pub fn subtractYears(self: Date, n: u16) Date {
    const ymd = epochDaysToYmd(self.days);
    const new_year: i16 = ymd.year - @as(i16, @intCast(n));
    const new_day: u8 = if (ymd.month == 2 and ymd.day == 29 and !isLeapYear(new_year)) 28 else ymd.day;
    return .{ .days = ymdToEpochDays(new_year, ymd.month, new_day) };
}

/// Add N calendar years. Clamps Feb 29 -> Feb 28 if target is not
/// a leap year. Mirror of `subtractYears` — used by callers that
/// need "what date will it be when this person turns N", i.e.
/// `birthdate.addYears(target_age)`.
pub fn addYears(self: Date, n: u16) Date {
    const ymd = epochDaysToYmd(self.days);
    const new_year: i16 = ymd.year + @as(i16, @intCast(n));
    const new_day: u8 = if (ymd.month == 2 and ymd.day == 29 and !isLeapYear(new_year)) 28 else ymd.day;
    return .{ .days = ymdToEpochDays(new_year, ymd.month, new_day) };
}

/// Subtract N calendar months. Clamps day to end of month if needed (e.g. Mar 31 - 1M = Feb 28).
pub fn subtractMonths(self: Date, n: u16) Date {
    const ymd = epochDaysToYmd(self.days);
    var m: i32 = @as(i32, ymd.month) - @as(i32, n);
    var y: i16 = ymd.year;
    while (m < 1) {
        m += 12;
        y -= 1;
    }
    const new_month: u8 = @intCast(m);
    const max_day = daysInMonth(y, new_month);
    const new_day: u8 = if (ymd.day > max_day) max_day else ymd.day;
    return .{ .days = ymdToEpochDays(y, new_month, new_day) };
}

/// Return the last day of the previous month.
/// E.g., if self is 2026-02-24, returns 2026-01-31.
pub fn lastDayOfPriorMonth(self: Date) Date {
    const ymd = epochDaysToYmd(self.days);
    if (ymd.month == 1) {
        return fromYmd(ymd.year - 1, 12, 31);
    } else {
        return fromYmd(ymd.year, ymd.month - 1, daysInMonth(ymd.year, ymd.month - 1));
    }
}

/// Last day of `(y, m)`. E.g., `lastDayOfMonth(2024, 2)`
/// returns `2024-02-29`. `m` must be 1..12; values outside
/// that range trigger `unreachable` in `daysInMonth`.
pub fn lastDayOfMonth(y: i16, m: u8) Date {
    return fromYmd(y, m, daysInMonth(y, m));
}

fn daysInMonth(y: i16, m: u8) u8 {
    const table = [_]u8{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
    if (m == 2 and isLeapYear(y)) return 29;
    return table[m - 1];
}

/// Three-letter English abbreviation of a month number
/// (`1` → `"Jan"`, `12` → `"Dec"`). Returns `"???"` for
/// out-of-range input rather than panicking — display
/// helpers prefer a placeholder over a crash.
pub fn monthShort(m: u8) []const u8 {
    const table = [_][]const u8{
        "Jan", "Feb", "Mar", "Apr", "May", "Jun",
        "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
    };
    if (m < 1 or m > 12) return "???";
    return table[m - 1];
}

/// Returns approximate number of years between two dates.
pub fn yearsBetween(from: Date, to: Date) f64 {
    return @as(f64, @floatFromInt(to.days - from.days)) / 365.25;
}

/// Pack year + month into a single comparable integer
/// (`year × 100 + month`, e.g. 2026-03 → 202603). Used by
/// month-end resampling code in `analytics/risk.zig` and
/// `analytics/portfolio_risk.zig` as a hash-map / monotonic
/// boundary key. Defensively clamps negative years to 0 — real
/// candle data never has them, but the function would otherwise
/// panic on underflow during the `i16 → u32` cast.
pub fn yearMonth(self: Date) u32 {
    const y_raw = self.year();
    const y: u32 = if (y_raw < 0) 0 else @intCast(y_raw);
    const m: u32 = self.month();
    return y * 100 + m;
}

/// Calendar-month distance between two dates as a signed integer.
/// Positive when `to` is after `from`, negative when before, zero
/// when both fall in the same calendar month.
///
/// Computed from the year/month fields only — day-of-month is
/// ignored. So `monthsBetween(2024-01-31, 2024-02-01) == 1`
/// despite being one calendar day apart, and
/// `monthsBetween(2024-01-01, 2024-01-31) == 0`. That's the
/// correct shape for month-bucket arithmetic, where the consumer
/// wants "how many distinct calendar-month buckets separate
/// these dates."
pub fn monthsBetween(from: Date, to: Date) i32 {
    const from_y: i32 = from.year();
    const from_m: i32 = from.month();
    const to_y: i32 = to.year();
    const to_m: i32 = to.month();
    return (to_y - from_y) * 12 + (to_m - from_m);
}

/// Whole years between two dates, floored to a non-negative
/// `u16`. Returns 0 when `to` is at or before `from`. Built on
/// `yearsBetween` (365.25-day approximation) — sufficient for
/// "how many full years until X" displays where the displayed
/// date itself is the precision-bearing value.
///
/// Distinct from `ageOn`, which is calendar-precise — use that
/// when the answer must match calendar-anniversary intuition
/// (e.g. "what age will I be on this exact date").
pub fn wholeYearsBetween(from: Date, to: Date) u16 {
    if (to.days <= from.days) return 0;
    const years = yearsBetween(from, to);
    const floored: i32 = @intFromFloat(@floor(years));
    if (floored < 0) return 0;
    return @intCast(floored);
}

/// Calendar-year age of a person born on `self` evaluated on
/// `on`. Whole-year integer math: subtract years, then drop one
/// if the birthday hasn't occurred yet that calendar year.
/// Returns 0 when `on` is at or before `self`.
///
/// Distinct from `wholeYearsBetween`, which uses a 365.25-day
/// approximation that floors-down the exact-anniversary case to
/// `age − 1`. For "what age will I be on date X" displays where
/// the answer must match the calendar (e.g. you turn 65 ON
/// your 65th birthday, not the day after), use `ageOn`.
pub fn ageOn(self: Date, on: Date) u16 {
    if (on.days <= self.days) return 0;
    var years: i16 = on.year() - self.year();
    const before_birthday = (on.month() < self.month()) or
        (on.month() == self.month() and on.day() < self.day());
    if (before_birthday) years -= 1;
    if (years < 0) return 0;
    return @intCast(years);
}

fn isLeapYear(y: i16) bool {
    const yu: u16 = @bitCast(y);
    return (yu % 4 == 0 and yu % 100 != 0) or (yu % 400 == 0);
}

const Ymd = struct { year: i16, month: u8, day: u8 };

fn epochDaysToYmd(days: i32) Ymd {
    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
    // Using i64 throughout to avoid overflow on unsigned intermediate values.
    const z: i64 = @as(i64, days) + 719468;
    const era: i64 = @divFloor(if (z >= 0) z else z - 146096, 146097);
    const doe_i: i64 = z - era * 146097; // [0, 146096]
    const doe: u64 = @intCast(doe_i);
    const yoe_val: u64 = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
    const y: i64 = @as(i64, @intCast(yoe_val)) + era * 400;
    const doy: u64 = doe - (365 * yoe_val + yoe_val / 4 - yoe_val / 100);
    const mp: u64 = (5 * doy + 2) / 153;
    const d: u8 = @intCast(doy - (153 * mp + 2) / 5 + 1);
    const m_raw: u64 = if (mp < 10) mp + 3 else mp - 9;
    const m: u8 = @intCast(m_raw);
    const y_adj: i16 = @intCast(if (m <= 2) y + 1 else y);
    return .{ .year = y_adj, .month = m, .day = d };
}

fn ymdToEpochDays(y: i16, m: u8, d: u8) i32 {
    const y_adj: i64 = @as(i64, y) - @as(i64, if (m <= 2) @as(i64, 1) else @as(i64, 0));
    const era: i64 = @divFloor(if (y_adj >= 0) y_adj else y_adj - 399, 400);
    const yoe: u64 = @intCast(y_adj - era * 400);
    const m_adj: u64 = if (m > 2) @as(u64, m) - 3 else @as(u64, m) + 9;
    const doy: u64 = (153 * m_adj + 2) / 5 + @as(u64, d) - 1;
    const doe: u64 = yoe * 365 + yoe / 4 -| yoe / 100 + doy;
    return @intCast(era * 146097 + @as(i64, @intCast(doe)) - 719468);
}

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

test "date roundtrip" {
    const d = Date.fromYmd(2024, 6, 15);
    try std.testing.expectEqual(@as(i16, 2024), d.year());
    try std.testing.expectEqual(@as(u8, 6), d.month());
    try std.testing.expectEqual(@as(u8, 15), d.day());
}

test "date parse" {
    const d = try Date.parse("2024-06-15");
    try std.testing.expectEqual(@as(i16, 2024), d.year());
    try std.testing.expectEqual(@as(u8, 6), d.month());
    try std.testing.expectEqual(@as(u8, 15), d.day());
}

test "format produces YYYY-MM-DD via {f}" {
    const d = Date.fromYmd(2024, 1, 5);
    var buf: [10]u8 = undefined;
    const s = try std.fmt.bufPrint(&buf, "{f}", .{d});
    try std.testing.expectEqualStrings("2024-01-05", s);
}

test "format method produces YYYY-MM-DD via {f}" {
    const d = Date.fromYmd(2024, 1, 5);
    var aw: std.Io.Writer.Allocating = .init(std.testing.allocator);
    defer aw.deinit();
    try aw.writer.print("{f}", .{d});
    const s = try aw.toOwnedSlice();
    defer std.testing.allocator.free(s);
    try std.testing.expectEqualStrings("2024-01-05", s);
}

test "format method handles four-digit years correctly" {
    // Edge case: year boundaries.
    var aw: std.Io.Writer.Allocating = .init(std.testing.allocator);
    defer aw.deinit();
    try aw.writer.print("{f}", .{Date.fromYmd(1970, 1, 1)});
    try aw.writer.print(" ", .{});
    try aw.writer.print("{f}", .{Date.fromYmd(2099, 12, 31)});
    const s = try aw.toOwnedSlice();
    defer std.testing.allocator.free(s);
    try std.testing.expectEqualStrings("1970-01-01 2099-12-31", s);
}

test "padRight pads with leading spaces" {
    var aw: std.Io.Writer.Allocating = .init(std.testing.allocator);
    defer aw.deinit();
    try aw.writer.print("{f}", .{Date.fromYmd(2024, 1, 5).padRight(14)});
    const s = try aw.toOwnedSlice();
    defer std.testing.allocator.free(s);
    try std.testing.expectEqualStrings("    2024-01-05", s);
}

test "padLeft pads with trailing spaces" {
    var aw: std.Io.Writer.Allocating = .init(std.testing.allocator);
    defer aw.deinit();
    try aw.writer.print("{f}", .{Date.fromYmd(2024, 1, 5).padLeft(14)});
    const s = try aw.toOwnedSlice();
    defer std.testing.allocator.free(s);
    try std.testing.expectEqualStrings("2024-01-05    ", s);
}

test "padRight: width <= 10 emits the date unchanged" {
    var aw: std.Io.Writer.Allocating = .init(std.testing.allocator);
    defer aw.deinit();
    try aw.writer.print("{f}", .{Date.fromYmd(2024, 1, 5).padRight(8)});
    const s = try aw.toOwnedSlice();
    defer std.testing.allocator.free(s);
    try std.testing.expectEqualStrings("2024-01-05", s);
}

test "subtractYears" {
    const d = Date.fromYmd(2026, 2, 24);
    const d1 = d.subtractYears(1);
    try std.testing.expectEqual(@as(i16, 2025), d1.year());
    try std.testing.expectEqual(@as(u8, 2), d1.month());
    try std.testing.expectEqual(@as(u8, 24), d1.day());

    const d3 = d.subtractYears(3);
    try std.testing.expectEqual(@as(i16, 2023), d3.year());

    // Leap year edge case: Feb 29 2024 - 1 year = Feb 28 2023
    const leap = Date.fromYmd(2024, 2, 29);
    const non_leap = leap.subtractYears(1);
    try std.testing.expectEqual(@as(i16, 2023), non_leap.year());
    try std.testing.expectEqual(@as(u8, 2), non_leap.month());
    try std.testing.expectEqual(@as(u8, 28), non_leap.day());
}

test "addYears" {
    // Symmetric with subtractYears — same algorithm, opposite direction.
    const d = Date.fromYmd(2026, 2, 24);
    const d1 = d.addYears(1);
    try std.testing.expectEqual(@as(i16, 2027), d1.year());
    try std.testing.expectEqual(@as(u8, 2), d1.month());
    try std.testing.expectEqual(@as(u8, 24), d1.day());

    const d3 = d.addYears(3);
    try std.testing.expectEqual(@as(i16, 2029), d3.year());

    // Leap year edge case: Feb 29 2024 + 1 year = Feb 28 2025 (target year is non-leap)
    const leap = Date.fromYmd(2024, 2, 29);
    const non_leap = leap.addYears(1);
    try std.testing.expectEqual(@as(i16, 2025), non_leap.year());
    try std.testing.expectEqual(@as(u8, 2), non_leap.month());
    try std.testing.expectEqual(@as(u8, 28), non_leap.day());

    // Leap year edge case: Feb 29 2024 + 4 years = Feb 29 2028 (target year IS leap)
    const leap_to_leap = leap.addYears(4);
    try std.testing.expectEqual(@as(i16, 2028), leap_to_leap.year());
    try std.testing.expectEqual(@as(u8, 2), leap_to_leap.month());
    try std.testing.expectEqual(@as(u8, 29), leap_to_leap.day());

    // Zero years is a no-op
    const same = d.addYears(0);
    try std.testing.expect(same.eql(d));
}

test "lastDayOfPriorMonth" {
    // Feb 24 -> Jan 31
    const d1 = Date.fromYmd(2026, 2, 24).lastDayOfPriorMonth();
    try std.testing.expectEqual(@as(i16, 2026), d1.year());
    try std.testing.expectEqual(@as(u8, 1), d1.month());
    try std.testing.expectEqual(@as(u8, 31), d1.day());

    // Jan 15 -> Dec 31 of prior year
    const d2 = Date.fromYmd(2026, 1, 15).lastDayOfPriorMonth();
    try std.testing.expectEqual(@as(i16, 2025), d2.year());
    try std.testing.expectEqual(@as(u8, 12), d2.month());
    try std.testing.expectEqual(@as(u8, 31), d2.day());

    // Mar 1 leap year -> Feb 29
    const d3 = Date.fromYmd(2024, 3, 1).lastDayOfPriorMonth();
    try std.testing.expectEqual(@as(u8, 2), d3.month());
    try std.testing.expectEqual(@as(u8, 29), d3.day());

    // Mar 1 non-leap -> Feb 28
    const d4 = Date.fromYmd(2025, 3, 1).lastDayOfPriorMonth();
    try std.testing.expectEqual(@as(u8, 2), d4.month());
    try std.testing.expectEqual(@as(u8, 28), d4.day());
}

test "lastDayOfMonth" {
    // 31-day month.
    try std.testing.expectEqual(Date.fromYmd(2026, 1, 31), Date.lastDayOfMonth(2026, 1));
    // 30-day month.
    try std.testing.expectEqual(Date.fromYmd(2026, 4, 30), Date.lastDayOfMonth(2026, 4));
    // Feb in leap year.
    try std.testing.expectEqual(Date.fromYmd(2024, 2, 29), Date.lastDayOfMonth(2024, 2));
    // Feb in non-leap year.
    try std.testing.expectEqual(Date.fromYmd(2025, 2, 28), Date.lastDayOfMonth(2025, 2));
    // Century non-leap (divisible by 100 but not 400).
    try std.testing.expectEqual(Date.fromYmd(2100, 2, 28), Date.lastDayOfMonth(2100, 2));
    // 400-year leap.
    try std.testing.expectEqual(Date.fromYmd(2000, 2, 29), Date.lastDayOfMonth(2000, 2));
    // December.
    try std.testing.expectEqual(Date.fromYmd(2026, 12, 31), Date.lastDayOfMonth(2026, 12));
}

test "dayOfWeek" {
    // 1970-01-01 was a Thursday (3 in 0=Mon scheme)
    try std.testing.expectEqual(@as(u8, 3), Date.fromYmd(1970, 1, 1).dayOfWeek());
    // 2024-01-01 (Monday)
    try std.testing.expectEqual(@as(u8, 0), Date.fromYmd(2024, 1, 1).dayOfWeek());
    // 2024-01-19 (Friday)
    try std.testing.expectEqual(@as(u8, 4), Date.fromYmd(2024, 1, 19).dayOfWeek());
    // 2024-01-20 (Saturday)
    try std.testing.expectEqual(@as(u8, 5), Date.fromYmd(2024, 1, 20).dayOfWeek());
    // 2024-01-21 (Sunday)
    try std.testing.expectEqual(@as(u8, 6), Date.fromYmd(2024, 1, 21).dayOfWeek());
}

test "eql and lessThan" {
    const a = Date.fromYmd(2024, 6, 15);
    const b = Date.fromYmd(2024, 6, 15);
    const c = Date.fromYmd(2024, 6, 16);
    try std.testing.expect(a.eql(b));
    try std.testing.expect(!a.eql(c));
    try std.testing.expect(a.lessThan(c));
    try std.testing.expect(!c.lessThan(a));
    try std.testing.expect(!a.lessThan(b));
}

test "addDays" {
    const d = Date.fromYmd(2024, 1, 30);
    const next = d.addDays(2);
    try std.testing.expectEqual(@as(u8, 1), next.day());
    try std.testing.expectEqual(@as(u8, 2), next.month()); // Feb 1
    // Negative days
    const prev = d.addDays(-30);
    try std.testing.expectEqual(@as(u8, 31), prev.day());
    try std.testing.expectEqual(@as(u8, 12), prev.month()); // Dec 31
    try std.testing.expectEqual(@as(i16, 2023), prev.year());
}

test "subtractMonths" {
    // Normal case
    const d1 = Date.fromYmd(2024, 6, 15).subtractMonths(3);
    try std.testing.expectEqual(@as(u8, 3), d1.month());
    try std.testing.expectEqual(@as(u8, 15), d1.day());
    // Cross year boundary: Jan - 1 = Dec prior year
    const d2 = Date.fromYmd(2024, 1, 15).subtractMonths(1);
    try std.testing.expectEqual(@as(u8, 12), d2.month());
    try std.testing.expectEqual(@as(i16, 2023), d2.year());
    // Day clamping: Mar 31 - 1M = Feb 29 (leap year 2024)
    const d3 = Date.fromYmd(2024, 3, 31).subtractMonths(1);
    try std.testing.expectEqual(@as(u8, 2), d3.month());
    try std.testing.expectEqual(@as(u8, 29), d3.day());
    // Day clamping: Mar 31 - 1M = Feb 28 (non-leap year 2025)
    const d4 = Date.fromYmd(2025, 3, 31).subtractMonths(1);
    try std.testing.expectEqual(@as(u8, 2), d4.month());
    try std.testing.expectEqual(@as(u8, 28), d4.day());
}

test "yearsBetween" {
    const a = Date.fromYmd(2024, 1, 1);
    const b = Date.fromYmd(2025, 1, 1);
    try std.testing.expectApproxEqAbs(@as(f64, 1.0), Date.yearsBetween(a, b), 0.01);
    // Half year
    const c = Date.fromYmd(2024, 7, 1);
    try std.testing.expectApproxEqAbs(@as(f64, 0.5), Date.yearsBetween(a, c), 0.02);
    // Same date
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), Date.yearsBetween(a, a), 0.001);
}

test "yearMonth packs as YYYYMM" {
    try std.testing.expectEqual(@as(u32, 202603), Date.fromYmd(2026, 3, 16).yearMonth());
    try std.testing.expectEqual(@as(u32, 202412), Date.fromYmd(2024, 12, 31).yearMonth());
    try std.testing.expectEqual(@as(u32, 100001), Date.fromYmd(1000, 1, 1).yearMonth());
}

test "monthsBetween: same calendar month is zero" {
    const a = Date.fromYmd(2024, 1, 1);
    const b = Date.fromYmd(2024, 1, 31);
    try std.testing.expectEqual(@as(i32, 0), Date.monthsBetween(a, b));
    try std.testing.expectEqual(@as(i32, 0), Date.monthsBetween(b, a));
}

test "monthsBetween: forward and backward" {
    const a = Date.fromYmd(2024, 1, 15);
    const b = Date.fromYmd(2024, 2, 1);
    try std.testing.expectEqual(@as(i32, 1), Date.monthsBetween(a, b));
    try std.testing.expectEqual(@as(i32, -1), Date.monthsBetween(b, a));
}

test "monthsBetween: across year boundary" {
    const a = Date.fromYmd(2023, 11, 15);
    const b = Date.fromYmd(2024, 2, 1);
    try std.testing.expectEqual(@as(i32, 3), Date.monthsBetween(a, b));
    const c = Date.fromYmd(2026, 3, 1);
    const d = Date.fromYmd(2014, 3, 1);
    try std.testing.expectEqual(@as(i32, 144), Date.monthsBetween(d, c)); // 12 years × 12
}

test "wholeYearsBetween" {
    const a = Date.fromYmd(2024, 1, 1);
    // 2024-01-01 → 2025-01-01 is 366 days (2024 is a leap year).
    // 366 / 365.25 ≈ 1.002 → floor = 1.
    const b = Date.fromYmd(2025, 1, 1);
    try std.testing.expectEqual(@as(u16, 1), Date.wholeYearsBetween(a, b));

    // 2025-01-01 → 2026-01-01 is 365 days (2025 is not a leap year).
    // 365 / 365.25 ≈ 0.9993 → floor = 0. Caveat of the 365.25-day
    // approximation: spans of exactly one non-leap year underflow.
    // For calendar-precise age math, use Date.ageOn.
    const c = Date.fromYmd(2026, 1, 1);
    try std.testing.expectEqual(@as(u16, 0), Date.wholeYearsBetween(b, c));

    // Multi-year spans average out the leap-year noise.
    const ten_years = Date.fromYmd(2034, 1, 5);
    try std.testing.expectEqual(@as(u16, 10), Date.wholeYearsBetween(a, ten_years));

    // Same date returns 0.
    try std.testing.expectEqual(@as(u16, 0), Date.wholeYearsBetween(a, a));

    // `to` before `from` returns 0 (clamps the negative case).
    const earlier = Date.fromYmd(2020, 1, 1);
    try std.testing.expectEqual(@as(u16, 0), Date.wholeYearsBetween(a, earlier));
}

test "ageOn: exact anniversary returns full year (not approximation)" {
    // Born 1981-04-12, evaluated on 2046-04-12: exactly 65, not 64.
    // Distinguishes from wholeYearsBetween which uses 365.25-day
    // approximation and floors down on exact anniversaries.
    try std.testing.expectEqual(@as(u16, 65), Date.fromYmd(1981, 4, 12).ageOn(Date.fromYmd(2046, 4, 12)));
}

test "ageOn: before birthday this year drops by one" {
    // Born June 1, evaluated April 12 — birthday hasn't occurred yet.
    try std.testing.expectEqual(@as(u16, 64), Date.fromYmd(1981, 6, 1).ageOn(Date.fromYmd(2046, 4, 12)));
}

test "ageOn: birthday month, before day drops by one" {
    // Same month, day not yet reached.
    try std.testing.expectEqual(@as(u16, 64), Date.fromYmd(1981, 4, 15).ageOn(Date.fromYmd(2046, 4, 12)));
}

test "ageOn: same date returns 0" {
    try std.testing.expectEqual(@as(u16, 0), Date.fromYmd(2026, 5, 12).ageOn(Date.fromYmd(2026, 5, 12)));
}

test "ageOn: birthdate after `on` returns 0" {
    // Defensive: future birthdate evaluated against past date.
    try std.testing.expectEqual(@as(u16, 0), Date.fromYmd(2030, 1, 1).ageOn(Date.fromYmd(2026, 5, 12)));
}

test "parse error cases" {
    try std.testing.expectError(error.InvalidDateFormat, Date.parse("not-a-date"));
    try std.testing.expectError(error.InvalidDateFormat, Date.parse("20240115")); // no dashes
    try std.testing.expectError(error.InvalidDateFormat, Date.parse("2024/01/15")); // wrong separator
}
