//! Imported portfolio history values from a manually-curated spreadsheet.
//!
//! Pre-dates the `history/` snapshot capture. Contains weekly
//! `(date, liquid, expected_return, projected_retirement)` tuples
//! transcribed from a third-party spreadsheet (the "K+E Funds"
//! workbook for the primary portfolio, plus optional sibling
//! spreadsheets for other portfolios in the same template).
//!
//! ## Lifecycle
//!
//! 1. The user exports the spreadsheet to CSV via `ssconvert -S`.
//! 2. `tools/import_values.zig` (a one-shot Zig program) consumes
//!    the CSV and emits an `imported_values.srf` next to the
//!    portfolio's `history/` snapshot directory.
//! 3. This module loads that SRF for downstream consumers
//!    (`zfin milestones`, projection overlay, forecast-vs-actual).
//!
//! The SRF is a derived artifact, not a source of truth. Hand
//! editing it is explicitly disallowed — the spreadsheet is the
//! source, and the importer regenerates the SRF wholesale.
//!
//! ## Field semantics
//!
//! - `date` — week-ending date (typically a Friday).
//! - `liquid` — total liquid net worth in USD on that date.
//!   Always present.
//! - `expected_return` — the spreadsheet's
//!   `min(1y,3y,5y,10y)`-weighted return assumption used to
//!   derive `projected_retirement`. Optional. Decimal
//!   (e.g., `0.1255` = 12.55%/yr).
//! - `projected_retirement` — the spreadsheet's predicted
//!   retirement-readiness date as of `date`. Optional. Tagged
//!   union: a future date, the `reached` sentinel meaning
//!   "model said you're already there", or absent.

const std = @import("std");
const srf = @import("srf");
const Date = @import("../Date.zig");

// ── Types ────────────────────────────────────────────────────

/// Projection of "when can the user retire," as captured at a
/// historical observation date.
///
/// `reached` is a sentinel meaning the model said "the user is
/// already at retirement readiness." It can flip back to a date
/// in a later week if a market correction pushes the projection
/// out (rare but observed in the Jan 2026 K+E Funds data: 81 of
/// the most-recent rows are `reached`, but historically the value
/// has wobbled).
pub const ProjectedRetirement = union(enum) {
    reached,
    date: Date,

    /// SRF parser hook. Accepts `reached` (case-insensitive) or
    /// `YYYY-MM-DD`. Any other shape is rejected. Returns
    /// `CoercionResult(...).initFree(...)` so SRF frees the consumed
    /// source string after parsing.
    pub fn srfParse(str: []const u8) !srf.CoercionResult(ProjectedRetirement) {
        if (std.ascii.eqlIgnoreCase(str, "reached")) return .initFree(.reached);
        const d = Date.parse(str) catch return error.InvalidProjectedRetirement;
        return .initFree(.{ .date = d });
    }

    /// SRF serializer hook. Emits `reached` or `YYYY-MM-DD` directly
    /// to the writer using the "string" type (untyped).
    pub fn srfFormat(
        self: ProjectedRetirement,
        comptime field_name: []const u8,
        writer: *std.Io.Writer,
    ) std.Io.Writer.Error!void {
        switch (self) {
            .reached => try writer.print("{s}::reached", .{field_name}),
            .date => |d| try writer.print("{s}::{f}", .{ field_name, d }),
        }
    }

    pub fn eql(a: ProjectedRetirement, b: ProjectedRetirement) bool {
        return switch (a) {
            .reached => b == .reached,
            .date => |ad| switch (b) {
                .reached => false,
                .date => |bd| ad.eql(bd),
            },
        };
    }
};

/// One record from `imported_values.srf`. All fields besides
/// `date` and `liquid` are optional (the importer omits them
/// when the source spreadsheet row had no value).
pub const HistoryPoint = struct {
    date: Date,
    liquid: f64,
    expected_return: ?f64 = null,
    projected_retirement: ?ProjectedRetirement = null,
};

/// Owned, oldest-first slice of `HistoryPoint` records loaded
/// from `imported_values.srf`. The slice's lifetime is tied to
/// `allocator`; call `deinit` when done.
pub const ImportedValues = struct {
    points: []HistoryPoint,
    allocator: std.mem.Allocator,

    /// Free the underlying slice. Records contain no allocated
    /// substrings (`Date` and `f64` are values; the union is a
    /// value).
    pub fn deinit(self: *ImportedValues) void {
        self.allocator.free(self.points);
        self.points = &.{};
    }
};

// ── Loader ───────────────────────────────────────────────────

pub const LoadError = error{
    /// SRF parse failed (malformed file, missing required field,
    /// or otherwise unreadable).
    InvalidSrf,
    /// Two records share the same `date` field. The importer
    /// guarantees this can't happen for a valid output, so this
    /// indicates corruption or hand-editing.
    DuplicateDate,
    /// Records are not strictly date-ascending. The importer
    /// guarantees ascending order, so this indicates corruption
    /// or hand-editing.
    NotSorted,
} || std.mem.Allocator.Error;

/// Load `imported_values.srf` from the given path.
///
/// Returns an `ImportedValues` with an empty slice if the file
/// does not exist (treated as "no historical data available",
/// not an error). All other failures return a non-null error.
///
/// Caller owns the returned `ImportedValues`; call `deinit`.
pub fn loadImportedValues(
    io: std.Io,
    allocator: std.mem.Allocator,
    path: []const u8,
) !ImportedValues {
    const bytes = std.Io.Dir.cwd().readFileAlloc(
        io,
        path,
        allocator,
        .limited(50 * 1024 * 1024),
    ) catch |err| switch (err) {
        error.FileNotFound => return ImportedValues{
            .points = &.{},
            .allocator = allocator,
        },
        else => return err,
    };
    defer allocator.free(bytes);

    return parseImportedValues(allocator, bytes);
}

/// Parse `imported_values.srf` bytes. Lower-level entry point —
/// `loadImportedValues` is the typical call site.
///
/// Validates: ascending-date order and no duplicate dates.
/// String fields on each record are owned by the returned slice
/// (no borrows from `bytes` — they're parsed into value-typed
/// fields only).
pub fn parseImportedValues(
    allocator: std.mem.Allocator,
    bytes: []const u8,
) !ImportedValues {
    var reader = std.Io.Reader.fixed(bytes);
    var it = srf.iterator(&reader, allocator, .{ .parse_allocator = .none }) catch return error.InvalidSrf;
    defer it.deinit();

    var points: std.ArrayList(HistoryPoint) = .empty;
    errdefer points.deinit(allocator);

    while (it.next() catch return error.InvalidSrf) |fields| {
        const point = fields.to(HistoryPoint, .{}) catch return error.InvalidSrf;
        try points.append(allocator, point);
    }

    // Validate ordering and uniqueness in one pass.
    if (points.items.len > 1) {
        var prev = points.items[0].date;
        for (points.items[1..]) |p| {
            if (p.date.eql(prev)) return error.DuplicateDate;
            if (p.date.lessThan(prev)) return error.NotSorted;
            prev = p.date;
        }
    }

    const owned = try points.toOwnedSlice(allocator);
    return ImportedValues{ .points = owned, .allocator = allocator };
}

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

test "ProjectedRetirement.srfParse: reached" {
    const r = try ProjectedRetirement.srfParse("reached");
    try std.testing.expect(r.value == .reached);

    // Case-insensitive.
    const r2 = try ProjectedRetirement.srfParse("REACHED");
    try std.testing.expect(r2.value == .reached);
}

test "ProjectedRetirement.srfParse: date" {
    const r = try ProjectedRetirement.srfParse("2030-01-15");
    try std.testing.expect(r.value == .date);
    try std.testing.expectEqual(@as(i16, 2030), r.value.date.year());
    try std.testing.expectEqual(@as(u8, 1), r.value.date.month());
    try std.testing.expectEqual(@as(u8, 15), r.value.date.day());
}

test "ProjectedRetirement.srfParse: invalid" {
    try std.testing.expectError(error.InvalidProjectedRetirement, ProjectedRetirement.srfParse("not a date"));
    try std.testing.expectError(error.InvalidProjectedRetirement, ProjectedRetirement.srfParse(""));
    try std.testing.expectError(error.InvalidProjectedRetirement, ProjectedRetirement.srfParse("2030/01/15"));
}

test "ProjectedRetirement.eql" {
    const r: ProjectedRetirement = .reached;
    const r2: ProjectedRetirement = .reached;
    const d1 = ProjectedRetirement{ .date = Date.fromYmd(2030, 1, 15) };
    const d2 = ProjectedRetirement{ .date = Date.fromYmd(2030, 1, 15) };
    const d3 = ProjectedRetirement{ .date = Date.fromYmd(2030, 1, 16) };

    try std.testing.expect(r.eql(r2));
    try std.testing.expect(d1.eql(d2));
    try std.testing.expect(!d1.eql(d3));
    try std.testing.expect(!r.eql(d1));
    try std.testing.expect(!d1.eql(r));
}

test "parseImportedValues: minimal valid file" {
    const data =
        \\#!srfv1
        \\date::2014-07-03,liquid:num:1280036.42,expected_return:num:0.1223,projected_retirement::2023-09-27
        \\date::2014-07-10,liquid:num:1272951.94,expected_return:num:0.1206,projected_retirement::2023-11-24
        \\
    ;
    var iv = try parseImportedValues(std.testing.allocator, data);
    defer iv.deinit();

    try std.testing.expectEqual(@as(usize, 2), iv.points.len);
    try std.testing.expectEqual(Date.fromYmd(2014, 7, 3), iv.points[0].date);
    try std.testing.expectEqual(@as(f64, 1280036.42), iv.points[0].liquid);
    try std.testing.expectEqual(@as(?f64, 0.1223), iv.points[0].expected_return);
    try std.testing.expect(iv.points[0].projected_retirement != null);
    try std.testing.expect(iv.points[0].projected_retirement.? == .date);
    try std.testing.expectEqual(
        Date.fromYmd(2023, 9, 27),
        iv.points[0].projected_retirement.?.date,
    );
}

test "parseImportedValues: reached sentinel" {
    const data =
        \\#!srfv1
        \\date::2026-01-09,liquid:num:7912778.43,expected_return:num:0.1255,projected_retirement::reached
        \\
    ;
    var iv = try parseImportedValues(std.testing.allocator, data);
    defer iv.deinit();

    try std.testing.expectEqual(@as(usize, 1), iv.points.len);
    try std.testing.expect(iv.points[0].projected_retirement != null);
    try std.testing.expect(iv.points[0].projected_retirement.? == .reached);
}

test "parseImportedValues: missing optional fields" {
    const data =
        \\#!srfv1
        \\date::2015-01-01,liquid:num:1500000.00
        \\
    ;
    var iv = try parseImportedValues(std.testing.allocator, data);
    defer iv.deinit();

    try std.testing.expectEqual(@as(usize, 1), iv.points.len);
    try std.testing.expectEqual(@as(?f64, null), iv.points[0].expected_return);
    try std.testing.expectEqual(@as(?ProjectedRetirement, null), iv.points[0].projected_retirement);
}

test "parseImportedValues: empty file (header only)" {
    const data = "#!srfv1\n";
    var iv = try parseImportedValues(std.testing.allocator, data);
    defer iv.deinit();

    try std.testing.expectEqual(@as(usize, 0), iv.points.len);
}

test "parseImportedValues: rejects duplicate dates" {
    const data =
        \\#!srfv1
        \\date::2015-01-01,liquid:num:1500000.00
        \\date::2015-01-01,liquid:num:1500001.00
        \\
    ;
    try std.testing.expectError(
        error.DuplicateDate,
        parseImportedValues(std.testing.allocator, data),
    );
}

test "parseImportedValues: rejects non-monotonic dates" {
    const data =
        \\#!srfv1
        \\date::2015-01-08,liquid:num:1500000.00
        \\date::2015-01-01,liquid:num:1490000.00
        \\
    ;
    try std.testing.expectError(
        error.NotSorted,
        parseImportedValues(std.testing.allocator, data),
    );
}

test "loadImportedValues: missing file returns empty" {
    var iv = try loadImportedValues(
        std.testing.io,
        std.testing.allocator,
        "/nonexistent/path/imported_values.srf",
    );
    defer iv.deinit();
    try std.testing.expectEqual(@as(usize, 0), iv.points.len);
}

test "loadImportedValues: round-trip via filesystem" {
    const io = std.testing.io;
    const data =
        \\#!srfv1
        \\date::2014-07-03,liquid:num:1280036.42,expected_return:num:0.1223,projected_retirement::2023-09-27
        \\date::2014-07-10,liquid:num:1272951.94,projected_retirement::reached
        \\
    ;

    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, "imported_values.srf" },
    );
    defer std.testing.allocator.free(file_path);

    {
        var f = try std.Io.Dir.cwd().createFile(io, file_path, .{});
        try f.writeStreamingAll(io, data);
        f.close(io);
    }
    defer std.Io.Dir.cwd().deleteFile(io, file_path) catch {};

    var iv = try loadImportedValues(io, std.testing.allocator, file_path);
    defer iv.deinit();

    try std.testing.expectEqual(@as(usize, 2), iv.points.len);
    try std.testing.expectEqual(Date.fromYmd(2014, 7, 3), iv.points[0].date);
    try std.testing.expectEqual(@as(f64, 1280036.42), iv.points[0].liquid);
    try std.testing.expect(iv.points[1].projected_retirement.? == .reached);
}

test "parseImportedValues: comments and blank lines tolerated" {
    // Mirrors what tools/import_values.zig actually emits.
    const data =
        \\#!srfv1
        \\# Manually-imported weekly portfolio history.
        \\#
        \\# Re-generated wholesale; do not hand-edit.
        \\
        \\date::2014-07-03,liquid:num:1280036.42,expected_return:num:0.1223,projected_retirement::2023-09-27
        \\date::2014-07-10,liquid:num:1272951.94
        \\date::2014-07-18,liquid:num:1274083.25,projected_retirement::reached
        \\
    ;
    var iv = try parseImportedValues(std.testing.allocator, data);
    defer iv.deinit();

    try std.testing.expectEqual(@as(usize, 3), iv.points.len);
    try std.testing.expectEqual(@as(f64, 1280036.42), iv.points[0].liquid);
    try std.testing.expectEqual(@as(?f64, null), iv.points[1].expected_return);
    try std.testing.expect(iv.points[2].projected_retirement.? == .reached);
}
