//! `zfin milestones` — show portfolio threshold crossings.
//!
//! Given the merged history series (native `*-portfolio.srf`
//! snapshots take precedence over `imported_values.srf` on
//! overlapping dates), find the dates the portfolio first
//! reached each of a configured set of thresholds.
//!
//! Two threshold modes:
//!   - `--step 1M` (or `1000000`, `500K`, etc.) — fixed dollar
//!     multiples.
//!   - `--step 2x` — geometric multiples of the starting value
//!     ("doublings", "1.5x growth", etc.).
//!
//! Optional `--real` flag deflates the series to a reference
//! year (last full year in `shiller.annual_returns`) before
//! detecting crossings.
//!
//! No I/O beyond reading the data files; no network.

const std = @import("std");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = @import("../format.zig");
const Money = @import("../Money.zig");
const history = @import("../history.zig");
const Date = @import("../Date.zig");
const milestones = @import("../analytics/milestones.zig");
const shiller = @import("../data/shiller.zig");

pub const ParsedArgs = struct {
    /// Raw `--step` value (e.g. `"1M"`, `"2x"`). Resolved into a typed
    /// step inside `run` so `parseStep`'s detailed error reporting can
    /// surface to the user with the right framing.
    step_raw: []const u8,
    real: bool = false,
};

pub const meta: framework.Meta = .{
    .name = "milestones",
    .group = .portfolio,
    .synopsis = "Show portfolio threshold crossings (each $1M, doublings, etc.)",
    .help =
    \\Usage: zfin milestones --step <expr> [--real]
    \\
    \\Find the dates the portfolio first reached each of a
    \\configured set of thresholds. Two threshold modes:
    \\
    \\  Absolute dollar:  1M / 1m / 1500000 / 1.5M / 500K / 500k
    \\  Relative multiplier: 2x / 2X / 1.5x
    \\
    \\Rejects %, ≤0 dollar steps, ≤1.0x multipliers, NaN/Inf.
    \\
    \\Options:
    \\  --step <expr>     Threshold step (required).
    \\  --real            Deflate the series to the last full Shiller
    \\                    year before detecting crossings (CPI-adjusted).
    \\                    Default is nominal.
    \\
    \\Note: crossing dates are "first observed at," bounded by the
    \\source series cadence (typically weekly).
    \\
    ,
    .uppercase_first_arg = false,
    .user_errors = error{ InvalidStep, MissingStep, NoData, UnexpectedArg },
};

pub const RunError = error{
    UnexpectedArg,
    MissingStep,
    InvalidStep,
    NoData,
    OutOfMemory,
    WriteFailed,
} || std.Io.Reader.Error || std.Io.Writer.Error || std.fs.File.OpenError ||
    std.posix.RealPathError;

pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
    var step_str: ?[]const u8 = null;
    var want_real = false;

    var i: usize = 0;
    while (i < cmd_args.len) : (i += 1) {
        const a = cmd_args[i];
        if (std.mem.eql(u8, a, "--step")) {
            i += 1;
            if (i >= cmd_args.len) {
                cli.stderrPrint(ctx.io, "Error: --step requires an argument\n");
                return error.MissingStep;
            }
            step_str = cmd_args[i];
        } else if (std.mem.eql(u8, a, "--real")) {
            want_real = true;
        } else {
            cli.stderrPrint(ctx.io, "Error: unknown argument to 'milestones': ");
            cli.stderrPrint(ctx.io, a);
            cli.stderrPrint(ctx.io, "\n");
            return error.UnexpectedArg;
        }
    }

    const step_raw = step_str orelse {
        cli.stderrPrint(ctx.io, "Error: --step is required\n");
        cli.stderrPrint(ctx.io, meta.help);
        return error.MissingStep;
    };
    return .{ .step_raw = step_raw, .real = want_real };
}

pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
    const io = ctx.io;
    const allocator = ctx.allocator;
    const out = ctx.out;
    const color = ctx.color;
    const want_real = parsed.real;

    const step = milestones.parseStep(parsed.step_raw) catch |err| {
        var buf: [256]u8 = undefined;
        const msg = std.fmt.bufPrint(
            &buf,
            "Error: cannot parse --step '{s}': {s}\n",
            .{ parsed.step_raw, @errorName(err) },
        ) catch "Error: invalid --step\n";
        cli.stderrPrint(io, msg);
        return error.InvalidStep;
    };

    const pf = ctx.resolvePortfolioPath();
    defer pf.deinit(allocator);
    const portfolio_path = pf.path;

    // Load merged series.
    var series_owned = try loadMergedSeries(io, allocator, portfolio_path);
    defer series_owned.deinit(allocator);

    if (series_owned.points.len == 0) {
        cli.stderrPrint(io, "Error: no history data found. Did you import imported_values.srf?\n");
        return error.NoData;
    }

    // Inflation adjustment: deflate the series to the reference year.
    const reference_year: u16 = shiller.last_year;
    const cpi_view = try buildCpiView(allocator);
    defer allocator.free(cpi_view);

    const series = if (want_real) blk: {
        const deflated = try allocator.alloc(milestones.Point, series_owned.points.len);
        for (series_owned.points, 0..) |p, idx| {
            const yr: u16 = @intCast(p.date.year());
            deflated[idx] = .{
                .date = p.date,
                .value = milestones.deflate(p.value, yr, reference_year, cpi_view),
            };
        }
        break :blk deflated;
    } else series_owned.points;
    defer if (want_real) allocator.free(series);

    // Detect crossings.
    const crossings = try milestones.detectCrossings(allocator, series, step);
    defer allocator.free(crossings);

    // Render.
    try renderHeader(out, color, step, want_real, reference_year, series);
    if (crossings.len == 0) {
        try renderNoCrossings(out, color, series);
        return;
    }
    try renderTable(out, color, step, crossings);
}

// ── Series loading ───────────────────────────────────────────

/// A lightweight owned merged series: imported values overlaid
/// with native snapshots (snapshots win on overlap), sorted
/// ascending by date, deduped.
const MergedSeries = struct {
    points: []milestones.Point,

    fn deinit(self: *MergedSeries, allocator: std.mem.Allocator) void {
        allocator.free(self.points);
        self.points = &.{};
    }
};

/// Thin wrapper over `history.loadTimeline` that projects the
/// shared `TimelineSeries` (rich `TimelinePoint` records with
/// liquid/illiquid/breakdowns/source) into the lightweight
/// `(date, liquid)` shape that milestone-detection consumes.
///
/// The merge logic — including snapshot-wins-on-overlap, sort
/// order, and `imported_values.srf` discovery — lives in
/// `history.loadTimeline` and `timeline.buildMergedSeries`.
/// Keeping milestones routed through that single source of
/// truth means future improvements (e.g. honoring more snapshot
/// metadata) propagate automatically.
fn loadMergedSeries(
    io: std.Io,
    allocator: std.mem.Allocator,
    portfolio_path: []const u8,
) !MergedSeries {
    var tl = try history.loadTimeline(io, allocator, portfolio_path);
    defer tl.deinit();

    const points = try allocator.alloc(milestones.Point, tl.series.points.len);
    for (tl.series.points, 0..) |p, i| {
        points[i] = .{ .date = p.as_of_date, .value = p.liquid };
    }
    return .{ .points = points };
}

// ── CPI view builder ─────────────────────────────────────────

fn buildCpiView(
    allocator: std.mem.Allocator,
) ![]milestones.YearCpi {
    const data = shiller.annual_returns;
    const view = try allocator.alloc(milestones.YearCpi, data.len);
    for (data, 0..) |yr, i| {
        view[i] = .{ .year = yr.year, .cpi = yr.cpi_inflation };
    }
    return view;
}

// ── Rendering ────────────────────────────────────────────────

fn renderHeader(
    out: *std.Io.Writer,
    color: bool,
    step: milestones.Step,
    want_real: bool,
    reference_year: u16,
    series: []const milestones.Point,
) !void {
    try cli.setBold(out, color);
    switch (step) {
        .absolute => |s| {
            if (want_real) {
                try out.print(
                    "Milestones — step {f} (real, reference year: {d})\n",
                    .{ Money.from(s), reference_year },
                );
            } else {
                try out.print(
                    "Milestones — step {f} (nominal)\n",
                    .{Money.from(s)},
                );
            }
        },
        .relative => |f| {
            const start = series[0].value;
            const real_str = if (want_real) " (real)" else "";
            try out.print(
                "Milestones — step {d}x from {f} ({f}){s}\n",
                .{ f, Money.from(start), series[0].date, real_str },
            );
        },
    }
    try cli.reset(out, color);
    try out.writeAll("\n");
}

fn renderNoCrossings(
    out: *std.Io.Writer,
    color: bool,
    series: []const milestones.Point,
) !void {
    var max_v: f64 = series[0].value;
    for (series) |p| {
        if (p.value > max_v) max_v = p.value;
    }
    const start_v = series[0].value;
    try cli.setStyleIntent(out, color, .muted);
    try out.print(
        "  No milestones reached. Series max: {f} (start: {f}).\n",
        .{ Money.from(max_v), Money.from(start_v) },
    );
    try cli.reset(out, color);
}

fn renderTable(
    out: *std.Io.Writer,
    color: bool,
    step: milestones.Step,
    crossings: []const milestones.Crossing,
) !void {
    const is_relative = step == .relative;

    // Header row.
    try cli.setBold(out, color);
    if (is_relative) {
        try out.writeAll("  Multiple    Threshold        Date Crossed    Days Since Prev   Days Since First\n");
        try out.writeAll("  ────────    ──────────       ─────────────   ───────────────   ────────────────\n");
    } else {
        try out.writeAll("  Milestone        Date Crossed    Days Since Prev   Days Since First\n");
        try out.writeAll("  ─────────        ─────────────   ───────────────   ────────────────\n");
    }
    try cli.reset(out, color);

    var has_start_row = false;
    for (crossings) |c| {
        var date_buf: [10]u8 = undefined;
        const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{c.date}) catch "????-??-??";
        var money_buf: [32]u8 = undefined;
        const money_str = std.fmt.bufPrint(&money_buf, "{f}", .{Money.from(c.threshold)}) catch "$?";

        // The "days since prev" cell holds either "N days" (ASCII)
        // or the em-dash sentinel "—" (3 bytes / 1 display col).
        // Zig's `{s: <N}` pads by bytes and under-pads multibyte
        // content; `fmt.padRightToCols` accounts for display width.
        const ds_prev_target_cols: usize = 16;
        var prev_cell_buf: [48]u8 = undefined;
        const ds_prev_cell = blk: {
            const inner: []const u8 = if (c.days_since_prev) |d|
                std.fmt.bufPrint(&prev_cell_buf, "{d} days", .{d}) catch "?"
            else inner_dash: {
                const dash = "—";
                @memcpy(prev_cell_buf[0..dash.len], dash);
                break :inner_dash prev_cell_buf[0..dash.len];
            };
            break :blk fmt.padRightToCols(&prev_cell_buf, inner, ds_prev_target_cols);
        };

        const star: []const u8 = if (c.is_start) " *" else "  ";
        if (c.is_start) has_start_row = true;

        if (is_relative) {
            // Multiple expressed relative to crossing index. The
            // synthetic starting row is "1x"; subsequent are
            // computed via factor, but the simplest faithful
            // rendering is to label by `factor^(index-1)` —
            // which the analytics already encoded in `threshold`.
            // We render the *index* as `Nx` rendering for clarity.
            // For the synthetic row, that's "1x"; for subsequent
            // rows it's `factor^(index-1)x`. We display via a
            // computed multiple from `threshold / start`.
            const start_threshold = crossings[0].threshold;
            const multiple = if (start_threshold > 0) c.threshold / start_threshold else 0;
            try out.print(
                "  {d: <8.4}x   {s: <16} {s: <16}{s}  {d} days{s}\n",
                .{ multiple, money_str, date_str, ds_prev_cell, c.days_since_first, star },
            );
        } else {
            try out.print(
                "  {s: <16} {s: <16}{s}  {d} days{s}\n",
                .{ money_str, date_str, ds_prev_cell, c.days_since_first, star },
            );
        }
    }

    if (has_start_row) {
        try out.writeAll("\n");
        try cli.setStyleIntent(out, color, .muted);
        try out.writeAll("  * starting value, not a true crossing.\n");
        try cli.reset(out, color);
    }
}

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

test "parseArgs: --step and --real" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{ "--step", "1M", "--real" };
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expectEqualStrings("1M", parsed.step_raw);
    try std.testing.expect(parsed.real);
}

test "parseArgs: --step only" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{ "--step", "2x" };
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expectEqualStrings("2x", parsed.step_raw);
    try std.testing.expect(!parsed.real);
}

test "parseArgs: missing --step errors" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{};
    try std.testing.expectError(error.MissingStep, parseArgs(&ctx, &args));
}

test "parseArgs: --step without value errors" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"--step"};
    try std.testing.expectError(error.MissingStep, parseArgs(&ctx, &args));
}

test "parseArgs: unknown flag errors" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{ "--step", "1M", "--bogus" };
    try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}

test "loadMergedSeries: empty when no history" {
    const io = std.testing.io;
    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];

    // A portfolio path that has no sibling history dir.
    const fake_pf = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "portfolio.srf" });
    defer std.testing.allocator.free(fake_pf);

    var s = try loadMergedSeries(io, std.testing.allocator, fake_pf);
    defer s.deinit(std.testing.allocator);
    try std.testing.expectEqual(@as(usize, 0), s.points.len);
}

test "loadMergedSeries: imported values only" {
    const io = std.testing.io;
    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];

    // Create history/ dir and imported_values.srf.
    const hist_dir = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "history" });
    defer std.testing.allocator.free(hist_dir);
    try std.Io.Dir.cwd().createDirPath(io, hist_dir);

    const iv_path = try std.fs.path.join(std.testing.allocator, &.{ hist_dir, "imported_values.srf" });
    defer std.testing.allocator.free(iv_path);

    const iv_data =
        \\#!srfv1
        \\date::2014-07-03,liquid:num:1280000
        \\date::2015-01-09,liquid:num:1500000
        \\date::2020-06-01,liquid:num:3000000
        \\
    ;
    {
        var f = try std.Io.Dir.cwd().createFile(io, iv_path, .{});
        try f.writeStreamingAll(io, iv_data);
        f.close(io);
    }

    const fake_pf = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "portfolio.srf" });
    defer std.testing.allocator.free(fake_pf);

    var s = try loadMergedSeries(io, std.testing.allocator, fake_pf);
    defer s.deinit(std.testing.allocator);
    try std.testing.expectEqual(@as(usize, 3), s.points.len);
    try std.testing.expectEqual(Date.fromYmd(2014, 7, 3), s.points[0].date);
    try std.testing.expectEqual(@as(f64, 1_280_000), s.points[0].value);
    try std.testing.expectEqual(Date.fromYmd(2020, 6, 1), s.points[2].date);
}
