//! Forecast-vs-actual evaluation analytics.
//!
//! Two evaluation views over the spreadsheet's historical
//! forecasts (loaded from `imported_values.srf` via
//! `src/data/imported_values.zig`):
//!
//!   1. **Convergence**: how the spreadsheet's predicted
//!      retirement date moved over time as data accumulated.
//!      For each anchor row that supplied a
//!      `projected_retirement` field, emit a
//!      `(observation_date, years_until_retirement)` pair so a
//!      chart can show whether the prediction was converging on
//!      "now" (slope -1) or pushing out (slope > -1).
//!
//!   2. **Return back-test**: how the spreadsheet's
//!      `expected_return` claim compared to what actually
//!      happened over the next 1y/3y/5y. For each anchor row
//!      that supplied an `expected_return`, find the future
//!      data point ~N years later and compute the realized
//!      CAGR of the `liquid` series over that window.
//!
//! Both functions are pure: deterministic over their inputs,
//! no I/O, no allocation lifetime concerns beyond the returned
//! slice. The view layer (`src/views/forecast_evaluation.zig`)
//! and the rendering layer (CLI braille / TUI Kitty graphics)
//! consume these structs.

const std = @import("std");
const Date = @import("../Date.zig");
const imported = @import("../data/imported_values.zig");
const milestones = @import("milestones.zig");

const HistoryPoint = imported.HistoryPoint;
const ProjectedRetirement = imported.ProjectedRetirement;

// ── View 1: Convergence ──────────────────────────────────────

/// One emitted point on the retirement-date convergence chart.
///
/// `years_until_retirement` is computed as
/// `Date.yearsBetween(observation_date, projected_date)` for
/// `projected_retirement = .date`, or `0.0` when the source
/// row was `.reached`. The reached_at_observation flag lets
/// renderers style "reached" rows differently (e.g. a
/// distinct marker color) without having to detect them by
/// the `0.0` value alone — a row could legitimately have the
/// projected date EQUAL the observation date and produce
/// `years_until_retirement = 0.0` without being a `reached`
/// sentinel.
pub const ConvergencePoint = struct {
    observation_date: Date,
    projected_date: Date,
    years_until_retirement: f64,
    reached: bool,
};

/// Extract convergence points from a slice of imported history
/// rows. Rows with `projected_retirement = null` are skipped
/// (they don't contribute to the chart). The output is in
/// `points`-input order, which the importer guarantees is
/// date-ascending.
///
/// Caller owns the returned slice.
pub fn convergencePoints(
    allocator: std.mem.Allocator,
    points: []const HistoryPoint,
) ![]ConvergencePoint {
    var out: std.ArrayList(ConvergencePoint) = .empty;
    errdefer out.deinit(allocator);

    for (points) |p| {
        const proj = p.projected_retirement orelse continue;
        switch (proj) {
            .reached => try out.append(allocator, .{
                .observation_date = p.date,
                // The "projected_date" for a reached row is the
                // observation date itself — the model said
                // "retirement-ready right now."
                .projected_date = p.date,
                .years_until_retirement = 0.0,
                .reached = true,
            }),
            .date => |target| try out.append(allocator, .{
                .observation_date = p.date,
                .projected_date = target,
                .years_until_retirement = Date.yearsBetween(p.date, target),
                .reached = false,
            }),
        }
    }

    return out.toOwnedSlice(allocator);
}

// ── View 2: Return back-test ─────────────────────────────────

/// One row of back-test output: realized forward CAGR for one
/// (anchor_date, horizon_years) pair, alongside the spreadsheet's
/// claimed `expected_return` at that anchor.
///
/// `realized_cagr` is `null` when no future data point exists at
/// or near `anchor + horizon` (i.e., the anchor is too recent
/// for that horizon).
///
/// `expected_return` is replicated from the anchor row so the
/// chart layer can plot expected and realized side-by-side
/// without re-joining against the source data.
pub const BacktestPoint = struct {
    anchor_date: Date,
    horizon_years: u16,
    expected_return: f64,
    realized_cagr: ?f64,
};

/// Tolerance window around `anchor + horizon` years when
/// matching a future data point. ±2 weeks per spec — wider
/// than 1 week (catches week-of-the-month drift) but narrow
/// enough that the matched point is still meaningfully "N
/// years later."
const horizon_match_tolerance_days: i32 = 14;

/// Compute realized forward CAGR for each (anchor row, horizon)
/// pair. Anchors lacking `expected_return` are skipped (no
/// claim to back-test). Anchors with `expected_return` but no
/// matching future data point at any requested horizon emit
/// rows with `realized_cagr = null` for that horizon.
///
/// `points` must be date-ascending (the importer guarantees
/// this via `parseImportedValues`). `horizons` lists the
/// forward windows to evaluate, in years (typical: 1, 3, 5).
///
/// When `real_mode` is true, both the anchor and matched-future
/// `liquid` values are deflated to a common reference year (the
/// year of the most recent anchor in `points`) before the CAGR
/// is computed. Uses the Shiller annual CPI series via
/// `milestones.deflate`. The `expected_return` on the anchor
/// row is left as-is — it's a return rate not a level, and the
/// source spreadsheet captured it as nominal.
///
/// Caller owns the returned slice. Output order is
/// `(anchor_index, horizon_index)` — anchors in input order,
/// horizons in `horizons` order. Skipped anchors produce zero
/// output rows; partially-skipped anchors produce one row per
/// horizon with `realized_cagr = null` only for the
/// horizons that lack matching future data.
pub fn returnBacktest(
    allocator: std.mem.Allocator,
    points: []const HistoryPoint,
    horizons: []const u16,
    real_mode: bool,
    cpi: []const milestones.YearCpi,
) ![]BacktestPoint {
    var out: std.ArrayList(BacktestPoint) = .empty;
    errdefer out.deinit(allocator);

    if (points.len == 0 or horizons.len == 0) return out.toOwnedSlice(allocator);

    // Pick the reference year for `--real` deflation: the year
    // of the most recent anchor. All values normalize to that
    // year's dollars.
    const ref_year: u16 = blk: {
        const last_year = points[points.len - 1].date.year();
        break :blk @intCast(@max(last_year, 0));
    };

    for (points) |anchor| {
        const expected = anchor.expected_return orelse continue;

        for (horizons) |h| {
            const matched = matchForwardPoint(points, anchor.date, h);
            const realized: ?f64 = if (matched) |fp|
                computeCagr(anchor, fp, h, real_mode, ref_year, cpi)
            else
                null;

            try out.append(allocator, .{
                .anchor_date = anchor.date,
                .horizon_years = h,
                .expected_return = expected,
                .realized_cagr = realized,
            });
        }
    }

    return out.toOwnedSlice(allocator);
}

/// Find a `HistoryPoint` whose `date` is within
/// `±horizon_match_tolerance_days` of `anchor + horizon` years.
/// Returns the closest matching point, or null if no point is
/// within tolerance.
///
/// Linear scan — `points` is at most a few hundred rows, no
/// indexing optimization needed.
fn matchForwardPoint(
    points: []const HistoryPoint,
    anchor_date: Date,
    horizon_years: u16,
) ?HistoryPoint {
    const target = anchor_date.addYears(horizon_years);
    var best: ?HistoryPoint = null;
    var best_dist: i32 = std.math.maxInt(i32);

    for (points) |p| {
        const diff: i32 = @intCast(@abs(p.date.days - target.days));
        if (diff > horizon_match_tolerance_days) continue;
        if (diff < best_dist) {
            best = p;
            best_dist = diff;
        }
    }
    return best;
}

/// Compute the CAGR of `from.liquid` → `to.liquid` over
/// `horizon_years`. When `real_mode` is true, both endpoints
/// are deflated to `ref_year` dollars first. Returns null if
/// the math would produce a non-finite result (e.g. zero or
/// negative liquid value at either endpoint).
fn computeCagr(
    from: HistoryPoint,
    to: HistoryPoint,
    horizon_years: u16,
    real_mode: bool,
    ref_year: u16,
    cpi: []const milestones.YearCpi,
) ?f64 {
    var v0 = from.liquid;
    var v1 = to.liquid;

    if (real_mode) {
        const from_year: u16 = @intCast(@max(from.date.year(), 0));
        const to_year: u16 = @intCast(@max(to.date.year(), 0));
        v0 = milestones.deflate(v0, from_year, ref_year, cpi);
        v1 = milestones.deflate(v1, to_year, ref_year, cpi);
    }

    if (v0 <= 0 or v1 <= 0) return null;
    const ratio = v1 / v0;
    const exponent = 1.0 / @as(f64, @floatFromInt(horizon_years));
    return std.math.pow(f64, ratio, exponent) - 1.0;
}

/// Pivoted view of `BacktestPoint`s: one record per anchor with
/// realized CAGRs for the 1y/3y/5y horizons in dedicated optional
/// columns. The chart renderer plots all four series side-by-side;
/// the table renderers (CLI + TUI scroll fallback) emit one row
/// per anchor with four columns. Both consumers want the pivoted
/// shape, so the pivot lives here rather than in either renderer.
pub const BacktestAnchor = struct {
    anchor_date: Date,
    expected: f64,
    realized_1y: ?f64,
    realized_3y: ?f64,
    realized_5y: ?f64,
};

/// Pivot a sorted `[]BacktestPoint` (output of `returnBacktest`,
/// ordered by `(anchor_index, horizon_index)`) into one
/// `BacktestAnchor` per distinct `anchor_date`. Rows for horizons
/// outside `{1, 3, 5}` are dropped — the wider chart isn't
/// designed to show them. Caller owns the returned slice.
pub fn pivotByAnchor(
    allocator: std.mem.Allocator,
    rows: []const BacktestPoint,
) ![]BacktestAnchor {
    var out: std.ArrayList(BacktestAnchor) = .empty;
    errdefer out.deinit(allocator);

    var i: usize = 0;
    while (i < rows.len) {
        const anchor_date = rows[i].anchor_date;
        const expected = rows[i].expected_return;
        var r1: ?f64 = null;
        var r3: ?f64 = null;
        var r5: ?f64 = null;
        while (i < rows.len and rows[i].anchor_date.eql(anchor_date)) : (i += 1) {
            switch (rows[i].horizon_years) {
                1 => r1 = rows[i].realized_cagr,
                3 => r3 = rows[i].realized_cagr,
                5 => r5 = rows[i].realized_cagr,
                else => {},
            }
        }
        try out.append(allocator, .{
            .anchor_date = anchor_date,
            .expected = expected,
            .realized_1y = r1,
            .realized_3y = r3,
            .realized_5y = r5,
        });
    }

    return out.toOwnedSlice(allocator);
}

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

const testing = std.testing;

fn pt(year: i16, month: u8, day: u8, liquid: f64, exp_ret: ?f64, proj: ?ProjectedRetirement) HistoryPoint {
    return .{
        .date = Date.fromYmd(year, month, day),
        .liquid = liquid,
        .expected_return = exp_ret,
        .projected_retirement = proj,
    };
}

test "convergencePoints: empty input returns empty" {
    const out = try convergencePoints(testing.allocator, &.{});
    defer testing.allocator.free(out);
    try testing.expectEqual(@as(usize, 0), out.len);
}

test "convergencePoints: skips rows with null projected_retirement" {
    const points = [_]HistoryPoint{
        pt(2020, 1, 1, 1000, null, null),
        pt(2020, 1, 8, 1010, null, .{ .date = Date.fromYmd(2030, 1, 1) }),
        pt(2020, 1, 15, 1020, null, null),
    };
    const out = try convergencePoints(testing.allocator, &points);
    defer testing.allocator.free(out);
    try testing.expectEqual(@as(usize, 1), out.len);
    try testing.expect(out[0].observation_date.eql(Date.fromYmd(2020, 1, 8)));
}

test "convergencePoints: years_until_retirement is yearsBetween" {
    const points = [_]HistoryPoint{
        pt(2020, 1, 1, 1000, null, .{ .date = Date.fromYmd(2030, 1, 1) }),
    };
    const out = try convergencePoints(testing.allocator, &points);
    defer testing.allocator.free(out);
    try testing.expectEqual(@as(usize, 1), out.len);
    try testing.expectApproxEqAbs(@as(f64, 10.0), out[0].years_until_retirement, 0.01);
    try testing.expect(!out[0].reached);
}

test "convergencePoints: reached emits zero years and reached=true" {
    const points = [_]HistoryPoint{
        pt(2025, 6, 15, 5000000, null, .reached),
    };
    const out = try convergencePoints(testing.allocator, &points);
    defer testing.allocator.free(out);
    try testing.expectEqual(@as(usize, 1), out.len);
    try testing.expectEqual(@as(f64, 0.0), out[0].years_until_retirement);
    try testing.expect(out[0].reached);
    // For reached rows, projected_date == observation_date so
    // the chart can plot them on the y=0 axis at the anchor x.
    try testing.expect(out[0].projected_date.eql(out[0].observation_date));
}

test "convergencePoints: preserves source date order" {
    // Importer guarantees ascending; we should preserve that.
    const points = [_]HistoryPoint{
        pt(2020, 1, 1, 1000, null, .{ .date = Date.fromYmd(2030, 1, 1) }),
        pt(2021, 1, 1, 1100, null, .{ .date = Date.fromYmd(2031, 1, 1) }),
        pt(2022, 1, 1, 1200, null, .{ .date = Date.fromYmd(2030, 6, 1) }),
    };
    const out = try convergencePoints(testing.allocator, &points);
    defer testing.allocator.free(out);
    try testing.expectEqual(@as(usize, 3), out.len);
    try testing.expectApproxEqAbs(@as(f64, 10.0), out[0].years_until_retirement, 0.01);
    try testing.expectApproxEqAbs(@as(f64, 10.0), out[1].years_until_retirement, 0.01);
    // 2022-01-01 → 2030-06-01 ≈ 8.4 years
    try testing.expectApproxEqAbs(@as(f64, 8.42), out[2].years_until_retirement, 0.05);
}

test "returnBacktest: empty input returns empty" {
    const out = try returnBacktest(testing.allocator, &.{}, &.{ 1, 3, 5 }, false, &.{});
    defer testing.allocator.free(out);
    try testing.expectEqual(@as(usize, 0), out.len);
}

test "returnBacktest: empty horizons returns empty" {
    const points = [_]HistoryPoint{
        pt(2020, 1, 1, 1000, 0.10, null),
    };
    const out = try returnBacktest(testing.allocator, &points, &.{}, false, &.{});
    defer testing.allocator.free(out);
    try testing.expectEqual(@as(usize, 0), out.len);
}

test "returnBacktest: skips rows lacking expected_return" {
    const points = [_]HistoryPoint{
        pt(2020, 1, 1, 1000, null, null),
        pt(2021, 1, 1, 1100, null, null),
    };
    const out = try returnBacktest(testing.allocator, &points, &.{1}, false, &.{});
    defer testing.allocator.free(out);
    try testing.expectEqual(@as(usize, 0), out.len);
}

test "returnBacktest: 1y CAGR matches hand-computed value" {
    const points = [_]HistoryPoint{
        // Anchor 2020-01-01 with claimed 10%; future point one
        // year later at $1100 → realized 10%.
        pt(2020, 1, 1, 1000, 0.10, null),
        pt(2021, 1, 2, 1100, null, null), // close to 2021-01-01 (+1 day, within 14-day tolerance)
    };
    const out = try returnBacktest(testing.allocator, &points, &.{1}, false, &.{});
    defer testing.allocator.free(out);
    try testing.expectEqual(@as(usize, 1), out.len);
    try testing.expectEqualDeep(Date.fromYmd(2020, 1, 1), out[0].anchor_date);
    try testing.expectEqual(@as(u16, 1), out[0].horizon_years);
    try testing.expectApproxEqAbs(@as(f64, 0.10), out[0].expected_return, 0.001);
    try testing.expect(out[0].realized_cagr != null);
    try testing.expectApproxEqAbs(@as(f64, 0.10), out[0].realized_cagr.?, 0.005);
}

test "returnBacktest: realized null when no future data point in tolerance" {
    const points = [_]HistoryPoint{
        // Only one row, no future data — 1y horizon can't match.
        pt(2025, 6, 1, 1000, 0.08, null),
    };
    const out = try returnBacktest(testing.allocator, &points, &.{1}, false, &.{});
    defer testing.allocator.free(out);
    try testing.expectEqual(@as(usize, 1), out.len);
    try testing.expectEqual(@as(?f64, null), out[0].realized_cagr);
}

test "returnBacktest: emits one row per horizon per qualifying anchor" {
    const points = [_]HistoryPoint{
        pt(2018, 1, 1, 1000, 0.10, null),
        pt(2019, 1, 1, 1100, null, null), // +1y match
        pt(2021, 1, 1, 1331, null, null), // +3y match (1.10^3 = 1.331)
        pt(2023, 1, 1, 1611, null, null), // +5y match (1.10^5 ≈ 1.611)
    };
    const out = try returnBacktest(testing.allocator, &points, &.{ 1, 3, 5 }, false, &.{});
    defer testing.allocator.free(out);
    // One anchor × three horizons = 3 rows.
    try testing.expectEqual(@as(usize, 3), out.len);
    try testing.expectEqual(@as(u16, 1), out[0].horizon_years);
    try testing.expectEqual(@as(u16, 3), out[1].horizon_years);
    try testing.expectEqual(@as(u16, 5), out[2].horizon_years);
    // Each horizon's realized CAGR is ~10%.
    try testing.expectApproxEqAbs(@as(f64, 0.10), out[0].realized_cagr.?, 0.005);
    try testing.expectApproxEqAbs(@as(f64, 0.10), out[1].realized_cagr.?, 0.005);
    try testing.expectApproxEqAbs(@as(f64, 0.10), out[2].realized_cagr.?, 0.005);
}

test "returnBacktest: tolerance picks the closest point within ±14 days" {
    const points = [_]HistoryPoint{
        pt(2020, 1, 1, 1000, 0.10, null),
        // Two candidates both within tolerance for the 1y window;
        // closer one (2021-01-01, exact match) should win over
        // the +10-day candidate.
        pt(2021, 1, 11, 1200, null, null),
        pt(2021, 1, 1, 1100, null, null),
    };
    const out = try returnBacktest(testing.allocator, &points, &.{1}, false, &.{});
    defer testing.allocator.free(out);
    try testing.expectEqual(@as(usize, 1), out.len);
    // Closer match (2021-01-01) wins → realized = 1100/1000 - 1 = 10%.
    try testing.expectApproxEqAbs(@as(f64, 0.10), out[0].realized_cagr.?, 0.005);
}

test "returnBacktest: real mode deflates endpoints to reference year" {
    const points = [_]HistoryPoint{
        pt(2020, 1, 1, 1000, 0.10, null),
        pt(2021, 1, 1, 1100, null, null),
    };
    // Synthetic CPI: 2020 had 5% inflation. With real mode
    // and 2021 as ref year:
    //   v0 (2020) inflated to 2021 dollars: 1000 * 1.05 = 1050
    //   v1 (2021) stays at 1100
    //   CAGR = 1100/1050 - 1 ≈ 4.76%  (vs 10% nominal)
    const cpi = [_]milestones.YearCpi{
        .{ .year = 2020, .cpi = 0.05 },
    };
    const out = try returnBacktest(testing.allocator, &points, &.{1}, true, &cpi);
    defer testing.allocator.free(out);
    try testing.expectEqual(@as(usize, 1), out.len);
    try testing.expectApproxEqAbs(@as(f64, 0.0476), out[0].realized_cagr.?, 0.001);
}

test "returnBacktest: zero or negative liquid yields null realized" {
    const points = [_]HistoryPoint{
        pt(2020, 1, 1, 0, 0.10, null), // zero starting value
        pt(2021, 1, 1, 1100, null, null),
    };
    const out = try returnBacktest(testing.allocator, &points, &.{1}, false, &.{});
    defer testing.allocator.free(out);
    try testing.expectEqual(@as(?f64, null), out[0].realized_cagr);
}

test "pivotByAnchor: empty input returns empty" {
    const out = try pivotByAnchor(testing.allocator, &.{});
    defer testing.allocator.free(out);
    try testing.expectEqual(@as(usize, 0), out.len);
}

test "pivotByAnchor: groups three horizons into one anchor" {
    const date = Date.fromYmd(2020, 1, 1);
    const rows = [_]BacktestPoint{
        .{ .anchor_date = date, .horizon_years = 1, .expected_return = 0.10, .realized_cagr = 0.12 },
        .{ .anchor_date = date, .horizon_years = 3, .expected_return = 0.10, .realized_cagr = 0.08 },
        .{ .anchor_date = date, .horizon_years = 5, .expected_return = 0.10, .realized_cagr = null },
    };
    const out = try pivotByAnchor(testing.allocator, &rows);
    defer testing.allocator.free(out);
    try testing.expectEqual(@as(usize, 1), out.len);
    try testing.expect(out[0].anchor_date.eql(date));
    try testing.expectEqual(@as(f64, 0.10), out[0].expected);
    try testing.expectEqual(@as(?f64, 0.12), out[0].realized_1y);
    try testing.expectEqual(@as(?f64, 0.08), out[0].realized_3y);
    try testing.expectEqual(@as(?f64, null), out[0].realized_5y);
}

test "pivotByAnchor: preserves anchor order" {
    const d1 = Date.fromYmd(2020, 1, 1);
    const d2 = Date.fromYmd(2021, 6, 15);
    const rows = [_]BacktestPoint{
        .{ .anchor_date = d1, .horizon_years = 1, .expected_return = 0.10, .realized_cagr = 0.12 },
        .{ .anchor_date = d2, .horizon_years = 1, .expected_return = 0.08, .realized_cagr = 0.05 },
    };
    const out = try pivotByAnchor(testing.allocator, &rows);
    defer testing.allocator.free(out);
    try testing.expectEqual(@as(usize, 2), out.len);
    try testing.expect(out[0].anchor_date.eql(d1));
    try testing.expect(out[1].anchor_date.eql(d2));
}

test "pivotByAnchor: drops horizons outside {1, 3, 5}" {
    const date = Date.fromYmd(2020, 1, 1);
    const rows = [_]BacktestPoint{
        .{ .anchor_date = date, .horizon_years = 1, .expected_return = 0.10, .realized_cagr = 0.12 },
        .{ .anchor_date = date, .horizon_years = 10, .expected_return = 0.10, .realized_cagr = 0.07 }, // dropped
    };
    const out = try pivotByAnchor(testing.allocator, &rows);
    defer testing.allocator.free(out);
    try testing.expectEqual(@as(usize, 1), out.len);
    try testing.expectEqual(@as(?f64, 0.12), out[0].realized_1y);
    try testing.expectEqual(@as(?f64, null), out[0].realized_3y);
    try testing.expectEqual(@as(?f64, null), out[0].realized_5y);
}
