//! Staleness nags for hand-maintained data sources.
//!
//! Some datasets in this codebase require periodic human maintenance
//! (e.g. appending the latest year's T-bill rate, replacing Shiller's
//! `ie_data.csv`). Each such source declares a `pub const <name>_last_updated: Date`
//! constant which a human bumps when the refresh happens.
//!
//! This module registers those sources and prints a warning to stderr
//! on every `zfin` invocation once the annual refresh window opens
//! and the data is still stale. The refresh window is expressed as a
//! single `(month, day)` per year — the earliest date by which fresh
//! upstream data is expected to be available — not as a rolling
//! day-count.
//!
//! ### Nagging semantics
//!
//! Given today's date and an entry's `(due_month, due_day, last_updated)`:
//!
//! 1. Compute `this_years_due = Date.fromYmd(today.year(), due_month, due_day)`.
//! 2. If `today < this_years_due` — not yet nag season, silent.
//! 3. If `last_updated >= this_years_due` — already refreshed this cycle, silent.
//! 4. Otherwise — nag.
//!
//! The nag keeps firing every invocation until the human bumps
//! `last_updated` past `this_years_due`.
//!
//! ### Adding a new source
//!
//! 1. Add `pub const <name>_last_updated: Date = Date.fromYmd(YYYY, MM, DD);`
//!    to the module that owns the data.
//! 2. Append a new `StaleEntry` to `entries` below.
//! 3. Document the refresh procedure in the owning module's doc
//!    comment (or a `TODO` at the top).

const std = @import("std");
const Date = @import("../Date.zig");
const risk = @import("../analytics/risk.zig");
const shiller = @import("shiller.zig");
const review = @import("../views/review.zig");
const observations = @import("../analytics/observations.zig");

/// A hand-maintained data source that nags once a year if it hasn't
/// been refreshed by its annual `(due_month, due_day)`.
pub const StaleEntry = struct {
    /// Human-readable name surfaced in the warning.
    name: []const u8,
    /// The last time a human refreshed this data. Bumped by hand.
    last_updated: Date,
    /// Calendar month (1-12) by which fresh upstream data is expected.
    due_month: u8,
    /// Calendar day (1-31) within `due_month`.
    due_day: u8,
    /// Source file to point the user at for refresh instructions.
    source_file: []const u8,
};

/// Registry of hand-maintained data sources in this codebase. Passed
/// to `check` from `main.zig`; tests construct their own slices.
pub const entries = [_]StaleEntry{
    .{
        .name = "T-bill risk-free rate table",
        .last_updated = risk.tbill_rates_last_updated,
        .due_month = 1,
        .due_day = 31,
        .source_file = "src/analytics/risk.zig",
    },
    .{
        .name = "Shiller annual returns (ie_data.csv)",
        .last_updated = shiller.ie_data_last_updated,
        .due_month = 4,
        .due_day = 1,
        .source_file = "src/data/shiller.zig",
    },
    .{
        .name = "Review tab MaxDD color thresholds",
        .last_updated = review.maxdd_thresholds_last_reviewed,
        .due_month = 6,
        .due_day = 1,
        .source_file = "src/views/review.zig",
    },
    .{
        .name = "Observation engine thresholds",
        .last_updated = observations.observation_thresholds_last_reviewed,
        .due_month = 6,
        .due_day = 1,
        .source_file = "src/analytics/observations.zig",
    },
};

/// Per-entry refresh verdict as of `as_of`.
pub const Status = enum { ok, overdue };

/// Verdict for a single entry as of `as_of`, applying the same window
/// logic as `check` (see the module doc-block). Exposed so callers
/// that want an explicit per-entry OK/overdue line (e.g. `zfin doctor`)
/// can render every entry, not just the overdue ones that `check`
/// emits.
pub fn entryStatus(entry: StaleEntry, as_of: Date) Status {
    const this_years_due = Date.fromYmd(as_of.year(), entry.due_month, entry.due_day);
    // Not yet nag season.
    if (as_of.lessThan(this_years_due)) return .ok;
    // Already refreshed this cycle.
    if (!entry.last_updated.lessThan(this_years_due)) return .ok;
    return .overdue;
}

/// Write a warning line for each entry in `entries` that is overdue
/// for refresh as of `as_of`. Writes nothing when every entry is
/// fresh. Entries are processed in order; multiple warnings are
/// separated by a blank line.
pub fn check(
    writer: *std.Io.Writer,
    as_of: Date,
    list: []const StaleEntry,
) !void {
    var wrote_any = false;
    for (list) |entry| {
        if (entryStatus(entry, as_of) == .ok) continue;
        const this_years_due = Date.fromYmd(
            as_of.year(),
            entry.due_month,
            entry.due_day,
        );

        if (wrote_any) try writer.writeAll("\n");
        wrote_any = true;

        try writer.print(
            "warning: {s} is overdue for refresh.\n" ++
                "  Expected by {f}; last updated {f}.\n" ++
                "  See {s} for refresh instructions.\n",
            .{ entry.name, this_years_due, entry.last_updated, entry.source_file },
        );
    }
}

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

test "silent before due date" {
    var buf: [1024]u8 = undefined;
    var w = std.Io.Writer.fixed(&buf);
    const list = [_]StaleEntry{.{
        .name = "Test data",
        .last_updated = Date.fromYmd(2025, 3, 15),
        .due_month = 4,
        .due_day = 1,
        .source_file = "src/test.zig",
    }};
    try check(&w, Date.fromYmd(2026, 3, 31), &list);
    try std.testing.expectEqualStrings("", w.buffered());
}

test "nag on due date when stale" {
    var buf: [1024]u8 = undefined;
    var w = std.Io.Writer.fixed(&buf);
    const list = [_]StaleEntry{.{
        .name = "Test data",
        .last_updated = Date.fromYmd(2025, 3, 15),
        .due_month = 4,
        .due_day = 1,
        .source_file = "src/test.zig",
    }};
    try check(&w, Date.fromYmd(2026, 4, 1), &list);
    const expected =
        "warning: Test data is overdue for refresh.\n" ++
        "  Expected by 2026-04-01; last updated 2025-03-15.\n" ++
        "  See src/test.zig for refresh instructions.\n";
    try std.testing.expectEqualStrings(expected, w.buffered());
}

test "nag after due date when stale" {
    var buf: [1024]u8 = undefined;
    var w = std.Io.Writer.fixed(&buf);
    const list = [_]StaleEntry{.{
        .name = "Test data",
        .last_updated = Date.fromYmd(2025, 3, 15),
        .due_month = 4,
        .due_day = 1,
        .source_file = "src/test.zig",
    }};
    try check(&w, Date.fromYmd(2026, 6, 15), &list);
    const expected =
        "warning: Test data is overdue for refresh.\n" ++
        "  Expected by 2026-04-01; last updated 2025-03-15.\n" ++
        "  See src/test.zig for refresh instructions.\n";
    try std.testing.expectEqualStrings(expected, w.buffered());
}

test "silent when already refreshed this cycle" {
    var buf: [1024]u8 = undefined;
    var w = std.Io.Writer.fixed(&buf);
    const list = [_]StaleEntry{.{
        .name = "Test data",
        .last_updated = Date.fromYmd(2026, 4, 2),
        .due_month = 4,
        .due_day = 1,
        .source_file = "src/test.zig",
    }};
    try check(&w, Date.fromYmd(2026, 6, 15), &list);
    try std.testing.expectEqualStrings("", w.buffered());
}

test "silent when last_updated equals this year's due date" {
    var buf: [1024]u8 = undefined;
    var w = std.Io.Writer.fixed(&buf);
    const list = [_]StaleEntry{.{
        .name = "Test data",
        .last_updated = Date.fromYmd(2026, 4, 1),
        .due_month = 4,
        .due_day = 1,
        .source_file = "src/test.zig",
    }};
    try check(&w, Date.fromYmd(2026, 4, 1), &list);
    try std.testing.expectEqualStrings("", w.buffered());
}

test "two-cycle stale still fires" {
    var buf: [1024]u8 = undefined;
    var w = std.Io.Writer.fixed(&buf);
    const list = [_]StaleEntry{.{
        .name = "Test data",
        .last_updated = Date.fromYmd(2024, 2, 1),
        .due_month = 4,
        .due_day = 1,
        .source_file = "src/test.zig",
    }};
    try check(&w, Date.fromYmd(2026, 6, 15), &list);
    const expected =
        "warning: Test data is overdue for refresh.\n" ++
        "  Expected by 2026-04-01; last updated 2024-02-01.\n" ++
        "  See src/test.zig for refresh instructions.\n";
    try std.testing.expectEqualStrings(expected, w.buffered());
}

test "empty list is a no-op" {
    var buf: [16]u8 = undefined;
    var w = std.Io.Writer.fixed(&buf);
    try check(&w, Date.fromYmd(2030, 12, 31), &[_]StaleEntry{});
    try std.testing.expectEqualStrings("", w.buffered());
}

test "multiple stale entries separated by blank line" {
    var buf: [2048]u8 = undefined;
    var w = std.Io.Writer.fixed(&buf);
    const list = [_]StaleEntry{
        .{
            .name = "Alpha",
            .last_updated = Date.fromYmd(2024, 12, 1),
            .due_month = 1,
            .due_day = 31,
            .source_file = "src/alpha.zig",
        },
        .{
            .name = "Beta",
            .last_updated = Date.fromYmd(2025, 3, 15),
            .due_month = 4,
            .due_day = 1,
            .source_file = "src/beta.zig",
        },
    };
    try check(&w, Date.fromYmd(2026, 6, 15), &list);
    const expected =
        "warning: Alpha is overdue for refresh.\n" ++
        "  Expected by 2026-01-31; last updated 2024-12-01.\n" ++
        "  See src/alpha.zig for refresh instructions.\n" ++
        "\n" ++
        "warning: Beta is overdue for refresh.\n" ++
        "  Expected by 2026-04-01; last updated 2025-03-15.\n" ++
        "  See src/beta.zig for refresh instructions.\n";
    try std.testing.expectEqualStrings(expected, w.buffered());
}

test "fresh entry does not emit blank line before later stale entry" {
    var buf: [2048]u8 = undefined;
    var w = std.Io.Writer.fixed(&buf);
    const list = [_]StaleEntry{
        .{
            .name = "Alpha",
            .last_updated = Date.fromYmd(2026, 2, 1), // fresh
            .due_month = 1,
            .due_day = 31,
            .source_file = "src/alpha.zig",
        },
        .{
            .name = "Beta",
            .last_updated = Date.fromYmd(2025, 3, 15), // stale
            .due_month = 4,
            .due_day = 1,
            .source_file = "src/beta.zig",
        },
    };
    try check(&w, Date.fromYmd(2026, 6, 15), &list);
    const expected =
        "warning: Beta is overdue for refresh.\n" ++
        "  Expected by 2026-04-01; last updated 2025-03-15.\n" ++
        "  See src/beta.zig for refresh instructions.\n";
    try std.testing.expectEqualStrings(expected, w.buffered());
}

test "silent when today is one day before due" {
    var buf: [256]u8 = undefined;
    var w = std.Io.Writer.fixed(&buf);
    const list = [_]StaleEntry{.{
        .name = "Test data",
        .last_updated = Date.fromYmd(2020, 1, 1),
        .due_month = 4,
        .due_day = 1,
        .source_file = "src/test.zig",
    }};
    try check(&w, Date.fromYmd(2026, 3, 31), &list);
    try std.testing.expectEqualStrings("", w.buffered());
}

test "real registry compiles and is non-empty" {
    // Guard that the registry stays wired up; doesn't assert any
    // particular nag behavior (real dates drift over time).
    try std.testing.expect(entries.len >= 4);
    for (entries) |e| {
        try std.testing.expect(e.name.len > 0);
        try std.testing.expect(e.source_file.len > 0);
        try std.testing.expect(e.due_month >= 1 and e.due_month <= 12);
        try std.testing.expect(e.due_day >= 1 and e.due_day <= 31);
    }
}

test "entryStatus: ok before due date" {
    const e: StaleEntry = .{ .name = "X", .last_updated = Date.fromYmd(2020, 1, 1), .due_month = 4, .due_day = 1, .source_file = "x" };
    try std.testing.expectEqual(Status.ok, entryStatus(e, Date.fromYmd(2026, 3, 31)));
}

test "entryStatus: overdue on/after due date when stale" {
    const e: StaleEntry = .{ .name = "X", .last_updated = Date.fromYmd(2025, 3, 15), .due_month = 4, .due_day = 1, .source_file = "x" };
    try std.testing.expectEqual(Status.overdue, entryStatus(e, Date.fromYmd(2026, 4, 1)));
    try std.testing.expectEqual(Status.overdue, entryStatus(e, Date.fromYmd(2026, 6, 15)));
}

test "entryStatus: ok when refreshed this cycle" {
    const e: StaleEntry = .{ .name = "X", .last_updated = Date.fromYmd(2026, 4, 1), .due_month = 4, .due_day = 1, .source_file = "x" };
    try std.testing.expectEqual(Status.ok, entryStatus(e, Date.fromYmd(2026, 6, 15)));
}
