const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = cli.fmt;
const Money = @import("../Money.zig");

pub const ParsedArgs = struct {
    symbol: []const u8,
};

pub const meta: framework.Meta = .{
    .name = "perf",
    .group = .symbol_lookup,
    .synopsis = "Show 1y/3y/5y/10y trailing returns (Morningstar-style)",
    .uppercase_first_arg = true,
    .help =
    \\Usage: zfin perf <SYMBOL>
    \\
    \\Show Morningstar-style trailing returns for a symbol — 1Y,
    \\3Y, 5Y, 10Y price-only and total-return CAGR plus risk
    \\metrics (Sharpe, max drawdown, vol). Total returns require
    \\POLYGON_API_KEY (for dividend history); price-only
    \\returns work without it.
    \\
    \\Two return tables are produced:
    \\  - As-of: returns through the latest cached close.
    \\  - Month-end: returns through the most recent calendar
    \\    month-end (matches how mutual funds quote their stats).
    \\
    \\Examples:
    \\  zfin perf VTI       # total-market index, 30+ years of data
    \\  zfin perf NVDA      # individual stock
    \\
    ,
    .user_errors = error{ MissingSymbol, UnexpectedArg },
};

pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
    if (cmd_args.len < 1) {
        cli.stderrPrint(ctx.io, "Error: 'perf' requires a symbol argument\n");
        return error.MissingSymbol;
    }
    if (cmd_args.len > 1) {
        cli.stderrPrint(ctx.io, "Error: 'perf' takes a single symbol argument\n");
        return error.UnexpectedArg;
    }
    return .{ .symbol = cmd_args[0] };
}

pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
    const svc = ctx.svc orelse return error.MissingDataService;
    const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
    const result = svc.getTrailingReturns(parsed.symbol, opts) catch |err| switch (err) {
        zfin.DataError.NoApiKey => {
            cli.stderrPrint(ctx.io, "Error: No API key set. Get a free key at https://tiingo.com or https://twelvedata.com\n");
            return;
        },
        else => {
            cli.stderrPrint(ctx.io, "Error fetching data.\n");
            return;
        },
    };
    defer ctx.allocator.free(result.candles);
    defer if (result.dividends) |d| zfin.Dividend.freeSlice(ctx.allocator, d);

    if (result.source == .cached) cli.stderrPrint(ctx.io, "(using cached data)\n");

    const c = result.candles;
    const end_date = c[c.len - 1].date;
    const month_end = ctx.today.lastDayOfPriorMonth();

    const out = ctx.out;
    const color = ctx.color;
    try cli.printBold(out, color, "\nTrailing Returns for {s}\n", .{parsed.symbol});
    try out.print("========================================\n", .{});
    try cli.setFg(out, color, cli.CLR_MUTED);
    try out.print("Data points: {d} (", .{c.len});
    try out.print("{f}", .{c[0].date});
    try out.print(" to ", .{});
    try out.print("{f}", .{end_date});
    try cli.reset(out, color);
    try out.print(")\nLatest close: {f}\n", .{Money.from(c[c.len - 1].close)});

    // `dividends != null` indicates we got explicit dividend records
    // from the provider. When false we still display total return
    // (synthesized from adj_close, which most providers bake dividends
    // into), but we surface a hint that explicit dividend data is
    // missing.
    const has_explicit_divs = result.dividends != null;
    const has_total = result.asof_total != null;

    // -- As-of-date returns --
    try cli.printBold(out, color, "\nAs-of {f}:\n", .{end_date});
    try printReturnsTable(out, result.asof_price, if (has_total) result.asof_total else null, color);

    // -- Month-end returns --
    try cli.printBold(out, color, "\nMonth-end ({f}):\n", .{month_end});
    try printReturnsTable(out, result.me_price, if (has_total) result.me_total else null, color);

    if (!has_explicit_divs) {
        try cli.printFg(out, color, cli.CLR_MUTED, "\nSet POLYGON_API_KEY for total returns with dividend reinvestment.\n", .{});
    }

    // -- Risk metrics --
    const tr = zfin.risk.trailingRisk(c);
    try printRiskTable(out, tr, color);

    try out.print("\n", .{});
}

pub fn printReturnsTable(
    out: *std.Io.Writer,
    price: zfin.performance.TrailingReturns,
    total: ?zfin.performance.TrailingReturns,
    color: bool,
) !void {
    const has_total = total != null;

    try cli.setFg(out, color, cli.CLR_MUTED);
    if (has_total) {
        try out.print("{s:>22} {s:>14} {s:>14}\n", .{ "", "Price Only", "Total Return" });
        try out.print("{s:->22} {s:->14} {s:->14}\n", .{ "", "", "" });
    } else {
        try out.print("{s:>22} {s:>14}\n", .{ "", "Price Only" });
        try out.print("{s:->22} {s:->14}\n", .{ "", "" });
    }
    try cli.reset(out, color);

    const periods = [_]struct { label: []const u8, years: u16 }{
        .{ .label = "1-Year Return:", .years = 1 },
        .{ .label = "3-Year Return:", .years = 3 },
        .{ .label = "5-Year Return:", .years = 5 },
        .{ .label = "10-Year Return:", .years = 10 },
    };

    const price_arr = [_]?zfin.performance.PerformanceResult{
        price.one_year, price.three_year, price.five_year, price.ten_year,
    };

    const total_arr: [4]?zfin.performance.PerformanceResult = if (total) |t|
        .{ t.one_year, t.three_year, t.five_year, t.ten_year }
    else
        .{ null, null, null, null };

    for (periods, 0..) |period, i| {
        try out.print("  {s:<20}", .{period.label});

        var price_buf: [32]u8 = undefined;
        var total_buf: [32]u8 = undefined;
        const row = fmt.fmtReturnsRow(
            &price_buf,
            &total_buf,
            price_arr[i],
            if (has_total) total_arr[i] else null,
            period.years > 1,
        );

        if (price_arr[i] != null) {
            try cli.printGainLoss(out, color, if (row.price_positive) 1.0 else -1.0, " {s:>13}", .{row.price_str});
        } else {
            try cli.printFg(out, color, cli.CLR_MUTED, " {s:>13}", .{row.price_str});
        }

        if (has_total) {
            if (row.total_str) |ts| {
                try cli.printGainLoss(out, color, if (row.price_positive) 1.0 else -1.0, " {s:>13}", .{ts});
            } else {
                try cli.printFg(out, color, cli.CLR_MUTED, " {s:>13}", .{"N/A"});
            }
        }

        if (row.suffix.len > 0) {
            try cli.printFg(out, color, cli.CLR_MUTED, "{s}", .{row.suffix});
        }
        try out.print("\n", .{});
    }
}

pub fn printRiskTable(out: *std.Io.Writer, tr: zfin.risk.TrailingRisk, color: bool) !void {
    const risk_arr = [4]?zfin.risk.RiskMetrics{ tr.one_year, tr.three_year, tr.five_year, tr.ten_year };

    // Only show if at least one period has data
    var any = false;
    for (risk_arr) |r| {
        if (r != null) {
            any = true;
            break;
        }
    }
    if (!any) return;

    try cli.printBold(out, color, "\nRisk Metrics (monthly returns):\n", .{});

    try cli.setFg(out, color, cli.CLR_MUTED);
    try out.print("{s:>22} {s:>14} {s:>14} {s:>14}\n", .{ "", "Volatility", "Sharpe", "Max DD" });
    try out.print("{s:->22} {s:->14} {s:->14} {s:->14}\n", .{ "", "", "", "" });
    try cli.reset(out, color);

    const labels = [4][]const u8{ "1-Year:", "3-Year:", "5-Year:", "10-Year:" };
    const cell_width: usize = 13;

    for (0..4) |i| {
        try out.print("  {s:<20}", .{labels[i]});
        // Vol / Sharpe / MaxDD cells. Per-cell formatters share the
        // same `fmt.fmtPctOpt` / `fmt.fmtSharpeOpt` family the review
        // tab uses, so a vol number renders the same way everywhere.
        // Width-padding via `fmt.padLeftToCols` is display-column
        // aware so the em-dash sentinel aligns correctly when a
        // window has insufficient data.
        var vol_buf: [16]u8 = undefined;
        var sharpe_buf: [16]u8 = undefined;
        var dd_buf: [16]u8 = undefined;
        const vol_v: ?f64 = if (risk_arr[i]) |rm| rm.volatility else null;
        const sharpe_v: ?f64 = if (risk_arr[i]) |rm| rm.sharpe else null;
        const dd_v: ?f64 = if (risk_arr[i]) |rm| rm.max_drawdown else null;
        const vol_str = fmt.fmtPctOpt(&vol_buf, vol_v, .{});
        const sharpe_str = fmt.fmtSharpeOpt(&sharpe_buf, sharpe_v, .{});
        const dd_str = fmt.fmtPctOpt(&dd_buf, dd_v, .{});
        var pad_buf: [32]u8 = undefined;

        try out.print(" ", .{});
        try out.print("{s}", .{fmt.padLeftToCols(&pad_buf, vol_str, cell_width)});
        try out.print(" ", .{});
        try out.print("{s}", .{fmt.padLeftToCols(&pad_buf, sharpe_str, cell_width)});
        try out.print(" ", .{});
        if (risk_arr[i] != null) {
            try cli.printFg(out, color, cli.CLR_NEGATIVE, "{s}", .{fmt.padLeftToCols(&pad_buf, dd_str, cell_width)});
        } else {
            try cli.printFg(out, color, cli.CLR_MUTED, "{s}", .{fmt.padLeftToCols(&pad_buf, dd_str, cell_width)});
        }
        try out.print("\n", .{});
    }
}

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

test "parseArgs: accepts a single symbol" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"VTI"};
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expectEqualStrings("VTI", parsed.symbol);
}

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

test "parseArgs: extra args error" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{ "VTI", "extra" };
    try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}

test "printReturnsTable price-only with no data" {
    var buf: [4096]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const empty: zfin.performance.TrailingReturns = .{
        .one_year = null,
        .three_year = null,
        .five_year = null,
        .ten_year = null,
    };
    try printReturnsTable(&w, empty, null, false);
    const out = w.buffered();
    // Should contain header and N/A for all periods
    try std.testing.expect(std.mem.indexOf(u8, out, "Price Only") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "N/A") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "1-Year") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "10-Year") != null);
}

test "printReturnsTable price-only no ANSI without color" {
    var buf: [4096]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const empty: zfin.performance.TrailingReturns = .{
        .one_year = null,
        .three_year = null,
        .five_year = null,
        .ten_year = null,
    };
    try printReturnsTable(&w, empty, null, false);
    const out = w.buffered();
    // No ANSI escape sequences when color=false
    try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}

test "printReturnsTable with total return columns" {
    var buf: [4096]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const empty: zfin.performance.TrailingReturns = .{
        .one_year = null,
        .three_year = null,
        .five_year = null,
        .ten_year = null,
    };
    try printReturnsTable(&w, empty, empty, false);
    const out = w.buffered();
    // Should contain both column headers
    try std.testing.expect(std.mem.indexOf(u8, out, "Price Only") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "Total Return") != null);
}

test "printReturnsTable with actual returns" {
    var buf: [4096]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const returns: zfin.performance.TrailingReturns = .{
        .one_year = .{ .total_return = 0.15, .annualized_return = null, .from = .{ .days = 0 }, .to = .{ .days = 365 } },
        .three_year = null,
        .five_year = null,
        .ten_year = null,
    };
    try printReturnsTable(&w, returns, null, false);
    const out = w.buffered();
    // 1-Year should show a value, not N/A
    // Check that the line with "1-Year" does NOT have N/A right after it
    // (crude check: the output should have fewer N/A occurrences than with all nulls)
    try std.testing.expect(std.mem.indexOf(u8, out, "1-Year") != null);
    // 3-year should still show N/A
    try std.testing.expect(std.mem.indexOf(u8, out, "ann.") != null);
}

test "printRiskTable: all-null returns silently (no output)" {
    var buf: [2048]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const empty: zfin.risk.TrailingRisk = .{};
    try printRiskTable(&w, empty, false);
    try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
}

test "printRiskTable: with one period populated, header + data row appear" {
    var buf: [4096]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const tr: zfin.risk.TrailingRisk = .{
        .one_year = .{
            .volatility = 0.18,
            .sharpe = 1.25,
            .max_drawdown = 0.12,
            .sample_size = 12,
        },
    };
    try printRiskTable(&w, tr, false);
    const out = w.buffered();
    try std.testing.expect(std.mem.indexOf(u8, out, "Risk Metrics") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "Volatility") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "Sharpe") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "Max DD") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "1-Year") != null);
    // Volatility renders as "18.0%"
    try std.testing.expect(std.mem.indexOf(u8, out, "18.0%") != null);
    // Sharpe renders as "1.25"
    try std.testing.expect(std.mem.indexOf(u8, out, "1.25") != null);
    // Max DD renders as "12.0%"
    try std.testing.expect(std.mem.indexOf(u8, out, "12.0%") != null);
}

test "printRiskTable: missing periods render as em-dash placeholders" {
    var buf: [4096]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const tr: zfin.risk.TrailingRisk = .{
        .three_year = .{
            .volatility = 0.20,
            .sharpe = 0.80,
            .max_drawdown = 0.25,
            .sample_size = 36,
        },
    };
    try printRiskTable(&w, tr, false);
    const out = w.buffered();
    // 1-year, 5-year, 10-year all missing; em-dashes appear
    try std.testing.expect(std.mem.indexOf(u8, out, "—") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "3-Year") != null);
}

test "printRiskTable: no ANSI escapes when color=false" {
    var buf: [4096]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const tr: zfin.risk.TrailingRisk = .{
        .one_year = .{
            .volatility = 0.15,
            .sharpe = 1.0,
            .max_drawdown = 0.10,
            .sample_size = 12,
        },
    };
    try printRiskTable(&w, tr, false);
    try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") == null);
}
