//! `zfin review` — per-holding performance and risk dashboard.
//!
//! The CLI surface for the `review` view. Loads the portfolio + sibling
//! files (metadata.srf, accounts.srf), fetches per-symbol prices and
//! cached candles/dividends, builds the renderer-agnostic
//! `views/review.zig` view, and renders it as a wide ANSI table.
//!
//! The TUI has a peer surface (`tui/review_tab.zig`) consuming the same
//! view module — both renderers stay in sync by definition.

const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const review_view = @import("../views/review.zig");
const observations_view = @import("../views/observations_view.zig");
const observations = @import("../analytics/observations.zig");
const Journal = @import("../data/Journal.zig");
const portfolio_risk = @import("../analytics/portfolio_risk.zig");

pub const ParsedArgs = struct {
    sort: ?review_view.SortField = null,
    sort_dir: review_view.SortDirection = .desc,
    /// Whether to render acknowledged findings in the findings
    /// table. Default false (active findings only).
    show_acked: bool = false,
    /// Which observation checks to run + display. `.all` runs every
    /// registered check; `.fast` runs only short-running ones (none
    /// in M2 — every check is fast). `.none` skips the engine
    /// entirely (don't render the findings section).
    checks: ChecksMode = .all,
};

pub const ChecksMode = enum {
    all,
    fast,
    none,
};

pub const meta: framework.Meta = .{
    .name = "review",
    .group = .portfolio,
    .synopsis = "Per-holding performance and risk dashboard",
    .help =
    \\Usage: zfin review [opts]
    \\
    \\Show one row per portfolio holding with sector, tax-status,
    \\trailing returns (1Y/3Y/5Y/10Y month-end total return), risk
    \\metrics (3Y+10Y vol/Sharpe + 5Y MaxDD), and a true correlation-
    \\aware portfolio totals row at the bottom.
    \\
    \\Default sort: grouped by sector (alphabetical), then weight
    \\descending within each sector group.
    \\
    \\Options:
    \\  --sort FIELD             Sort by FIELD (overrides default grouping):
    \\                             sector, symbol, weight, tax,
    \\                             1y, 3y, 5y, 10y,
    \\                             3y-vol, 10y-vol,
    \\                             3y-sharpe, 10y-sharpe,
    \\                             5y-maxdd
    \\  --asc                    Sort ascending (default: descending for
    \\                           numeric fields, ascending for symbol/sector)
    \\  --checks=MODE            Observation engine mode: all (default),
    \\                           fast (skip long-running checks), none
    \\                           (suppress findings section).
    \\  --show-acked             Include already-acknowledged findings
    \\                           in the findings table.
    \\
    \\Reads classifications from `metadata.srf` and account tax types
    \\from `accounts.srf`. Tax% is the share of each holding's market
    \\value held in taxable accounts.
    \\
    ,
    .uppercase_first_arg = false,
    .user_errors = error{ UnexpectedArg, InvalidSortField, InvalidChecksMode },
};

pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
    var parsed: ParsedArgs = .{};
    var i: usize = 0;
    while (i < cmd_args.len) : (i += 1) {
        const arg = cmd_args[i];
        if (std.mem.eql(u8, arg, "--sort") and i + 1 < cmd_args.len) {
            i += 1;
            const value = cmd_args[i];
            const field = review_view.parseSortField(value) orelse {
                cli.stderrPrint(ctx.io, "Error: --sort must be one of: ");
                cli.stderrPrint(ctx.io, joinSortFields());
                cli.stderrPrint(ctx.io, "\n");
                return error.InvalidSortField;
            };
            parsed.sort = field;
        } else if (std.mem.eql(u8, arg, "--asc")) {
            parsed.sort_dir = .asc;
        } else if (std.mem.eql(u8, arg, "--desc")) {
            parsed.sort_dir = .desc;
        } else if (std.mem.eql(u8, arg, "--show-acked")) {
            parsed.show_acked = true;
        } else if (std.mem.startsWith(u8, arg, "--checks=")) {
            const value = arg["--checks=".len..];
            parsed.checks = parseChecksMode(value) orelse {
                cli.stderrPrint(ctx.io, "Error: --checks must be one of: all, fast, none\n");
                return error.InvalidChecksMode;
            };
        } else {
            cli.stderrPrint(ctx.io, "Error: 'review' takes no positional arguments\n");
            return error.UnexpectedArg;
        }
    }
    return parsed;
}

fn parseChecksMode(s: []const u8) ?ChecksMode {
    if (std.mem.eql(u8, s, "all")) return .all;
    if (std.mem.eql(u8, s, "fast")) return .fast;
    if (std.mem.eql(u8, s, "none")) return .none;
    return null;
}

/// Comma-joined valid sort fields for the user-facing error message.
/// Built once at comptime so we don't allocate at error time.
const joined_sort_fields: []const u8 = blk: {
    var s: []const u8 = "";
    for (review_view.sort_field_names, 0..) |name, idx| {
        if (idx > 0) s = s ++ ", ";
        s = s ++ name;
    }
    break :blk s;
};

fn joinSortFields() []const u8 {
    return joined_sort_fields;
}

pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
    const svc = ctx.svc orelse return error.MissingDataService;
    const io = ctx.io;
    const allocator = ctx.allocator;
    const out = ctx.out;
    const color = ctx.color;
    const as_of = ctx.today;

    var loaded = cli.loadPortfolio(ctx, as_of) orelse return;
    defer loaded.deinit(allocator);

    const portfolio = loaded.portfolio;
    const positions = loaded.positions;
    const syms = loaded.syms;
    const anchor_path = loaded.anchor();

    // Fetch fresh prices via the parallel loader so risk + return
    // figures reflect TTL-current data. Mirrors the analysis command.
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    if (syms.len > 0) {
        var load_result = cli.loadPortfolioPrices(io, svc, syms, &.{}, ctx.globals.refresh_policy, color);
        defer load_result.deinit();
        var it = load_result.prices.iterator();
        while (it.next()) |entry| {
            try prices.put(entry.key_ptr.*, entry.value_ptr.*);
        }
    }

    var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc, as_of) catch |err| switch (err) {
        error.NoAllocations, error.SummaryFailed => {
            cli.stderrPrint(io, "Error computing portfolio summary.\n");
            return;
        },
        else => return err,
    };
    defer pf_data.deinit(allocator);

    // Load classifications + account map from sibling files.
    const dir_end = if (std.mem.lastIndexOfScalar(u8, anchor_path, std.fs.path.sep)) |idx| idx + 1 else 0;
    const meta_path = std.fmt.allocPrint(allocator, "{s}metadata.srf", .{anchor_path[0..dir_end]}) catch return;
    defer allocator.free(meta_path);

    const meta_data = std.Io.Dir.cwd().readFileAlloc(io, meta_path, allocator, .limited(1024 * 1024)) catch {
        cli.stderrPrint(io, "Error: No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf\n");
        return;
    };
    defer allocator.free(meta_data);

    var cm = zfin.classification.parseClassificationFile(allocator, meta_data) catch {
        cli.stderrPrint(io, "Error: Cannot parse metadata.srf\n");
        return;
    };
    defer cm.deinit();

    var acct_map_opt: ?zfin.analysis.AccountMap = svc.loadAccountMap(allocator, anchor_path);
    defer if (acct_map_opt) |*am| am.deinit();

    // Per-symbol cached dividends so total-return windows include
    // dividend reinvestment when available. Cached-only — no
    // network — to keep the command fast on large portfolios.
    var dividend_map = std.StringHashMap([]const zfin.Dividend).init(allocator);
    defer {
        var it = dividend_map.iterator();
        while (it.next()) |entry| {
            zfin.Dividend.freeSlice(allocator, @constCast(entry.value_ptr.*));
        }
        dividend_map.deinit();
    }
    for (pf_data.summary.allocations) |a| {
        if (svc.getCachedDividends(allocator, a.symbol)) |divs| {
            try dividend_map.put(a.symbol, divs.data);
        }
    }

    var view = try review_view.buildReview(
        allocator,
        io,
        pf_data.summary,
        &pf_data.candle_map,
        &dividend_map,
        portfolio,
        cm,
        acct_map_opt,
        as_of,
        anchor_path,
    );
    defer view.deinit(allocator);

    // CLI is one-shot output — block until every async check
    // resolves so the rendered grid + findings are complete.
    // (The TUI renders progressively instead; see review_tab's
    // tick hook.)
    if (view.observations) |*panel| {
        for (panel.pending) |*pc| _ = pc.awaitResult(io);
    }

    // Sort: explicit --sort overrides the default grouping.
    if (parsed.sort) |field| {
        review_view.sortRows(view.rows, field, parsed.sort_dir);
    } else {
        review_view.sortGroupedByDefault(view.rows);
    }

    try render(allocator, io, out, color, view, anchor_path, parsed);
}

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

const Money = @import("../Money.zig");
const fmt = @import("../format.zig");

/// Column widths (display columns). Cells are right-aligned by
/// caller via `format.padLeftToCols`, so the same width spec
/// produces a correctly-padded cell whether the content is `12.3%`,
/// `+22.4%`, or the multibyte em-dash `—` (which Zig's `{s:>N}`
/// byte-padding under-pads by 2 cols).
///
/// Tax% lives at the END of the row, not the start: it's contextual
/// hint, not a primary metric, so it shouldn't anchor the eye. The
/// first numeric column the reader sees is Wt% (how big is this
/// position?), which is the right anchor for "what am I looking at."
const col_symbol: usize = 8;
const col_sector: usize = 20;
const col_weight: usize = 7;
const col_pct: usize = 8; // each return / vol cell
const col_sharpe: usize = 8;
const col_maxdd: usize = 10;
const col_tax: usize = 7;

/// Total numeric-cell widths in display order. Used for separator
/// rendering and for the column-iter loop.
const col_widths = [_]usize{
    col_symbol, col_sector, col_weight,
    col_pct,    col_pct,    col_pct,
    col_pct,    col_pct,    col_pct,
    col_sharpe, col_sharpe, col_maxdd,
    col_tax,
};

fn render(
    allocator: std.mem.Allocator,
    io: std.Io,
    out: *std.Io.Writer,
    color: bool,
    view: review_view.ReviewView,
    anchor_path: []const u8,
    parsed: ParsedArgs,
) !void {
    try cli.printBold(out, color, "\nPortfolio Review ({s})\n", .{view.portfolio_path});
    try cli.setFg(out, color, cli.CLR_MUTED);
    try out.print("  As of {f}    Liquid: {f}    Holdings: {d}\n\n", .{
        view.as_of, Money.from(view.total_liquid), view.rows.len,
    });
    try cli.reset(out, color);

    // Status grid: per-check pass/warn/flag glyphs across the top.
    // Mirrors the TUI review tab so the "at a glance" experience
    // matches when the user pipes the CLI output.
    if (view.observations) |panel| {
        try renderStatusGrid(out, color, panel);
        try out.print("\n", .{});
    }

    // Header row. Column order matches `col_widths`.
    try cli.setFg(out, color, cli.CLR_MUTED);
    try out.print("  ", .{});
    try out.print("{s:<8}", .{"Symbol"});
    try out.print(" {s:<20}", .{"Sector"});
    try out.print(" {s:>7}", .{"Wt%"});
    try out.print(" {s:>8}", .{"1Y"});
    try out.print(" {s:>8}", .{"3Y"});
    try out.print(" {s:>8}", .{"5Y"});
    try out.print(" {s:>8}", .{"10Y"});
    try out.print(" {s:>8}", .{"3Y-Vol"});
    try out.print(" {s:>8}", .{"10Y-Vol"});
    try out.print(" {s:>8}", .{"3Y-SR"});
    try out.print(" {s:>8}", .{"10Y-SR"});
    try out.print(" {s:>10}", .{"5Y-MaxDD"});
    try out.print(" {s:>7}", .{"Tax%"});
    try out.print("\n", .{});
    try cli.reset(out, color);

    try writeSeparator(out, color);

    // Rows.
    for (view.rows) |r| {
        try renderRow(out, color, r);
    }

    try writeSeparator(out, color);

    // Totals row.
    try renderTotalsRow(out, color, view.totals);

    // Footnote about reweighting.
    if (anyReweightFlag(view.totals.reweight_flags)) {
        try cli.setFg(out, color, cli.CLR_MUTED);
        try out.print("\n  * Reweighted: at least one holding lacked full-window candle coverage.\n", .{});
        try out.print("    Affected metrics renormalized weights across participating holdings.\n", .{});
        try cli.reset(out, color);
    }

    // Findings section. Render unless `--checks=none` was passed.
    if (parsed.checks != .none) {
        try renderFindings(allocator, io, out, color, &view, anchor_path, parsed.show_acked);
    }

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

/// Render the per-check status grid to stdout. Layout mirrors
/// the TUI's `appendStatusGrid`: 3 cells per row, each cell
/// "<right-padded label> <glyph>". Color promoted to the row's
/// worst severity so multi-cell rows still draw the user's eye
/// to the bad ones.
fn renderStatusGrid(
    out: *std.Io.Writer,
    color: bool,
    panel: observations.CheckPanel,
) !void {
    if (panel.pending.len == 0) return;

    const status_label_cols: usize = 22;
    const cells_per_row: usize = 3;

    var i: usize = 0;
    while (i < panel.pending.len) {
        const end = @min(i + cells_per_row, panel.pending.len);

        // Row color = worst severity in the row.
        var worst: u8 = 0; // 0=pass/skipped, 1=warn, 2=flag/err
        var worst_color: [3]u8 = cli.CLR_MUTED;
        for (panel.pending[i..end]) |pc| {
            // The CLI awaits every check before rendering (see
            // run()), so .pending here would be a logic bug —
            // skip defensively rather than crash.
            const result = switch (pc.state) {
                .complete => |r| r,
                .pending => continue,
            };
            const rank: u8 = switch (result) {
                .pass, .skipped => 0,
                .warn => 1,
                .flag, .err => 2,
            };
            if (rank > worst) {
                worst = rank;
                worst_color = switch (result) {
                    .warn => cli.CLR_WARNING,
                    .flag, .err => cli.CLR_NEGATIVE,
                    else => cli.CLR_MUTED,
                };
            }
        }
        try cli.setFg(out, color, worst_color);

        try out.print("  ", .{});
        for (panel.pending[i..end], 0..) |pc, col| {
            if (col > 0) try out.print("  ", .{});

            const label = pc.check.label;
            const lbl_cols = label.len; // ASCII labels: byte count == display cols

            // Right-pad label.
            if (lbl_cols < status_label_cols) {
                var k: usize = 0;
                while (k < status_label_cols - lbl_cols) : (k += 1) try out.print(" ", .{});
            }
            try out.print("{s} ", .{label});

            const glyph: []const u8 = switch (pc.state) {
                .complete => |r| switch (r) {
                    .pass => "✅\u{FE0F}",
                    .warn => "⚠️",
                    .flag => "❌\u{FE0F}",
                    .skipped => "➖\u{FE0F}",
                    .err => "🛑\u{FE0F}",
                },
                .pending => "⏳\u{FE0F}", // see defensive note above
            };
            try out.print("{s}", .{glyph});
        }
        try out.print("\n", .{});
        try cli.reset(out, color);

        i = end;
    }
}

/// Render the findings section to stdout. Loads the journal from
/// the portfolio's directory (missing → empty), joins with the
/// observation panel via `observations_view.build`, and writes a
/// styled findings table similar to the TUI's. The CLI is read-only
/// — acks must come from the TUI.
fn renderFindings(
    allocator: std.mem.Allocator,
    io: std.Io,
    out: *std.Io.Writer,
    color: bool,
    view: *const review_view.ReviewView,
    anchor_path: []const u8,
    show_acked: bool,
) !void {
    const panel = if (view.observations) |*p| p else return;

    // Load the journal. Missing file ⇒ empty journal (first run).
    const dir_end = if (std.mem.lastIndexOfScalar(u8, anchor_path, std.fs.path.sep)) |idx| idx + 1 else 0;
    const journal_path = try std.fmt.allocPrint(allocator, "{s}acknowledgments.srf", .{anchor_path[0..dir_end]});
    defer allocator.free(journal_path);

    var j = Journal.load(allocator, io, journal_path) catch |err| blk: {
        cli.stderrPrint(io, "Warning: ");
        cli.stderrPrint(io, journal_path);
        cli.stderrPrint(io, ": ");
        cli.stderrPrint(io, @errorName(err));
        cli.stderrPrint(io, " — proceeding with empty journal.\n");
        const empty = try allocator.alloc(Journal.Entry, 0);
        break :blk Journal{ .allocator = allocator, .entries = empty };
    };
    defer j.deinit();

    var fv = try observations_view.build(allocator, panel, &j, show_acked);
    defer fv.deinit(allocator);

    try out.print("\n", .{});
    try cli.setFg(out, color, cli.CLR_HEADER);
    try out.print("  Findings ({d} active, {d} acked, {d} resolved)", .{
        fv.total_active,
        fv.total_acked,
        fv.total_resolved,
    });
    if (show_acked) try out.print("  [showing acked]", .{});
    try out.print("\n", .{});
    try cli.reset(out, color);
    try writeSeparator(out, color);

    if (fv.rows.len == 0) {
        try cli.setFg(out, color, cli.CLR_MUTED);
        if (fv.total_acked > 0 and !show_acked) {
            try out.print("  No active findings. Use --show-acked to see acknowledged.\n", .{});
        } else {
            try out.print("  No findings.\n", .{});
        }
        try cli.reset(out, color);
        return;
    }

    for (fv.rows) |row| {
        const glyph: []const u8 = switch (row.severity) {
            .warn => "⚠️",
            .flag => "❌\u{FE0F}",
            .err => "🛑\u{FE0F}",
        };
        const ansi: [3]u8 = if (row.is_acked)
            cli.CLR_MUTED
        else switch (row.severity) {
            .warn => cli.CLR_WARNING,
            .flag, .err => cli.CLR_NEGATIVE,
        };
        try cli.setFg(out, color, ansi);
        try out.print("  {s} {s}{s}\n", .{
            glyph,
            if (row.is_acked) "[acked] " else "",
            row.text,
        });
        try cli.reset(out, color);
    }
}

fn writeSeparator(out: *std.Io.Writer, color: bool) !void {
    try cli.setFg(out, color, cli.CLR_MUTED);
    try out.print("  ", .{});
    inline for (col_widths, 0..) |w, idx| {
        if (idx > 0) try out.print(" ", .{});
        var k: usize = 0;
        while (k < w) : (k += 1) try out.print("─", .{});
    }
    try out.print("\n", .{});
    try cli.reset(out, color);
}

fn anyReweightFlag(f: portfolio_risk.ReweightFlags) bool {
    return f.vol_3y or f.vol_10y or f.sharpe_3y or f.sharpe_10y or f.maxdd_5y or
        f.return_1y or f.return_3y or f.return_5y or f.return_10y;
}

fn renderRow(out: *std.Io.Writer, color: bool, r: review_view.ReviewRow) !void {
    try out.print("  ", .{});
    try out.print("{s:<8}", .{fmt.truncateToCols(r.symbol, col_symbol)});
    try out.print(" {s:<20}", .{fmt.truncateToCols(zfin.analysis.abbreviateSector(r.bucket), col_sector)});
    try out.print(" ", .{});
    try renderPctCell(out, color, .normal, r.weight, col_weight, false);
    try out.print(" ", .{});
    try renderSignedPctCell(out, color, review_view.returnIntent(r.return_1y), r.return_1y, col_pct, false);
    try out.print(" ", .{});
    try renderSignedPctCell(out, color, review_view.returnIntent(r.return_3y), r.return_3y, col_pct, false);
    try out.print(" ", .{});
    try renderSignedPctCell(out, color, review_view.returnIntent(r.return_5y), r.return_5y, col_pct, false);
    try out.print(" ", .{});
    try renderSignedPctCell(out, color, review_view.returnIntent(r.return_10y), r.return_10y, col_pct, false);
    try out.print(" ", .{});
    try renderPctCellOpt(out, color, review_view.volIntent(r.vol_3y), r.vol_3y, col_pct, false);
    try out.print(" ", .{});
    try renderPctCellOpt(out, color, review_view.volIntent(r.vol_10y), r.vol_10y, col_pct, false);
    try out.print(" ", .{});
    try renderSharpeCell(out, color, review_view.sharpeIntent(r.sharpe_3y), r.sharpe_3y, col_sharpe, false);
    try out.print(" ", .{});
    try renderSharpeCell(out, color, review_view.sharpeIntent(r.sharpe_10y), r.sharpe_10y, col_sharpe, false);
    try out.print(" ", .{});
    // MaxDD: same green/yellow/red scheme as Vol — magnitude
    // determines severity; a small drawdown isn't "bad", and a deep
    // one isn't "merely a drawdown" either.
    try renderPctCellOpt(out, color, review_view.maxddIntent(r.maxdd_5y), r.maxdd_5y, col_maxdd, false);
    try out.print(" ", .{});
    // Tax% is contextual; rendered in muted tone so it doesn't draw
    // the eye from the primary metrics. Null when account map is
    // missing for this holding.
    try renderPctCellOpt(out, color, .muted, r.tax_pct, col_tax, false);
    try out.print("\n", .{});
}

fn renderTotalsRow(out: *std.Io.Writer, color: bool, t: review_view.ReviewTotals) !void {
    try cli.printBold(out, color, "  {s:<8} {s:<20}", .{ "Total", "" });
    try out.print(" ", .{});
    try renderPctCell(out, color, .normal, t.weight, col_weight, false);
    try out.print(" ", .{});
    try renderSignedPctCell(out, color, review_view.returnIntent(t.return_1y), t.return_1y, col_pct, t.reweight_flags.return_1y);
    try out.print(" ", .{});
    try renderSignedPctCell(out, color, review_view.returnIntent(t.return_3y), t.return_3y, col_pct, t.reweight_flags.return_3y);
    try out.print(" ", .{});
    try renderSignedPctCell(out, color, review_view.returnIntent(t.return_5y), t.return_5y, col_pct, t.reweight_flags.return_5y);
    try out.print(" ", .{});
    try renderSignedPctCell(out, color, review_view.returnIntent(t.return_10y), t.return_10y, col_pct, t.reweight_flags.return_10y);
    try out.print(" ", .{});
    try renderPctCellOpt(out, color, review_view.volIntent(t.vol_3y), t.vol_3y, col_pct, t.reweight_flags.vol_3y);
    try out.print(" ", .{});
    try renderPctCellOpt(out, color, review_view.volIntent(t.vol_10y), t.vol_10y, col_pct, t.reweight_flags.vol_10y);
    try out.print(" ", .{});
    try renderSharpeCell(out, color, review_view.sharpeIntent(t.sharpe_3y), t.sharpe_3y, col_sharpe, t.reweight_flags.sharpe_3y);
    try out.print(" ", .{});
    try renderSharpeCell(out, color, review_view.sharpeIntent(t.sharpe_10y), t.sharpe_10y, col_sharpe, t.reweight_flags.sharpe_10y);
    try out.print(" ", .{});
    try renderPctCellOpt(out, color, review_view.maxddIntent(t.maxdd_5y), t.maxdd_5y, col_maxdd, t.reweight_flags.maxdd_5y);
    try out.print(" ", .{});
    try renderPctCellOpt(out, color, .muted, t.tax_pct, col_tax, false);
    try out.print("\n", .{});
}

// ── Cell renderers ────────────────────────────────────────────
//
// Each renderer formats the value into a stack buffer, pads to the
// target display width via `format.padLeftToCols` (so multibyte
// content like `—` aligns correctly — Zig's `{s:>N}` byte-padding
// would under-pad by two cols), then emits with the intent's color.

fn renderPctCellOpt(
    out: *std.Io.Writer,
    color: bool,
    intent: fmt.StyleIntent,
    v: ?f64,
    width: usize,
    flag: bool,
) !void {
    if (v) |val| {
        try renderPctCell(out, color, intent, val, width, flag);
    } else {
        try renderEmDashCell(out, color, width);
    }
}

fn renderPctCell(
    out: *std.Io.Writer,
    color: bool,
    intent: fmt.StyleIntent,
    v: f64,
    width: usize,
    flag: bool,
) !void {
    var content_buf: [32]u8 = undefined;
    const content = fmt.fmtPct(&content_buf, v, .{ .asterisk = flag });
    var pad_buf: [64]u8 = undefined;
    const padded = fmt.padLeftToCols(&pad_buf, content, width);
    try cli.printIntent(out, color, intent, "{s}", .{padded});
}

fn renderSignedPctCell(
    out: *std.Io.Writer,
    color: bool,
    intent: fmt.StyleIntent,
    v: ?f64,
    width: usize,
    flag: bool,
) !void {
    if (v == null) return renderEmDashCell(out, color, width);
    var content_buf: [32]u8 = undefined;
    const content = fmt.fmtPctOpt(&content_buf, v, .{ .signed = true, .asterisk = flag });
    var pad_buf: [64]u8 = undefined;
    const padded = fmt.padLeftToCols(&pad_buf, content, width);
    try cli.printIntent(out, color, intent, "{s}", .{padded});
}

fn renderSharpeCell(
    out: *std.Io.Writer,
    color: bool,
    intent: fmt.StyleIntent,
    v: ?f64,
    width: usize,
    flag: bool,
) !void {
    if (v == null) return renderEmDashCell(out, color, width);
    var content_buf: [32]u8 = undefined;
    const content = fmt.fmtSharpeOpt(&content_buf, v, .{ .asterisk = flag });
    var pad_buf: [64]u8 = undefined;
    const padded = fmt.padLeftToCols(&pad_buf, content, width);
    try cli.printIntent(out, color, intent, "{s}", .{padded});
}

fn renderEmDashCell(out: *std.Io.Writer, color: bool, width: usize) !void {
    var pad_buf: [64]u8 = undefined;
    const padded = fmt.padLeftToCols(&pad_buf, fmt.no_data_sentinel, width);
    try cli.printIntent(out, color, .muted, "{s}", .{padded});
}

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

const testing = std.testing;

test "parseArgs: empty args returns default" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{};
    const parsed = try parseArgs(&ctx, &args);
    try testing.expect(parsed.sort == null);
    try testing.expectEqual(review_view.SortDirection.desc, parsed.sort_dir);
}

test "parseArgs: --sort 3y-sharpe is accepted" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{ "--sort", "3y-sharpe" };
    const parsed = try parseArgs(&ctx, &args);
    try testing.expectEqual(review_view.SortField.sharpe_3y, parsed.sort.?);
}

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

test "parseArgs: --asc flips direction" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{ "--asc", "--sort", "weight" };
    const parsed = try parseArgs(&ctx, &args);
    try testing.expectEqual(review_view.SortDirection.asc, parsed.sort_dir);
    try testing.expectEqual(review_view.SortField.weight, parsed.sort.?);
}

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

test "parseArgs: --show-acked sets the flag" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"--show-acked"};
    const parsed = try parseArgs(&ctx, &args);
    try testing.expect(parsed.show_acked);
}

test "parseArgs: --checks=fast sets ChecksMode.fast" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"--checks=fast"};
    const parsed = try parseArgs(&ctx, &args);
    try testing.expectEqual(ChecksMode.fast, parsed.checks);
}

test "parseArgs: --checks=none sets ChecksMode.none" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"--checks=none"};
    const parsed = try parseArgs(&ctx, &args);
    try testing.expectEqual(ChecksMode.none, parsed.checks);
}

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

test "parseChecksMode: covers all variants" {
    try testing.expectEqual(ChecksMode.all, parseChecksMode("all").?);
    try testing.expectEqual(ChecksMode.fast, parseChecksMode("fast").?);
    try testing.expectEqual(ChecksMode.none, parseChecksMode("none").?);
    try testing.expect(parseChecksMode("nope") == null);
}

test "joinSortFields: contains all field names" {
    const joined = joinSortFields();
    for (review_view.sort_field_names) |name| {
        try testing.expect(std.mem.indexOf(u8, joined, name) != null);
    }
}

test "renderPctCell: writes percent representation, intent-aware" {
    var buf: [128]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try renderPctCell(&w, false, .normal, 0.1234, 8, false);
    const out = w.buffered();
    try testing.expect(std.mem.indexOf(u8, out, "12.3%") != null);
}

test "renderPctCell: reweight flag appends asterisk" {
    var buf: [128]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try renderPctCell(&w, false, .normal, 0.1234, 8, true);
    const out = w.buffered();
    try testing.expect(std.mem.indexOf(u8, out, "12.3%*") != null);
}

test "renderSignedPctCell: positive number shows + sign" {
    var buf: [128]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try renderSignedPctCell(&w, false, .positive, 0.1, 8, false);
    const out = w.buffered();
    try testing.expect(std.mem.indexOf(u8, out, "+10.0%") != null);
}

test "renderSignedPctCell: negative number shows - sign" {
    var buf: [128]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try renderSignedPctCell(&w, false, .negative, -0.05, 8, false);
    const out = w.buffered();
    try testing.expect(std.mem.indexOf(u8, out, "-5.0%") != null);
}

test "renderSignedPctCell: null renders em-dash" {
    var buf: [128]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try renderSignedPctCell(&w, false, .muted, null, 8, false);
    const out = w.buffered();
    try testing.expect(std.mem.indexOf(u8, out, "—") != null);
}

test "renderPctCellOpt: null em-dashes; value pads to width" {
    var buf: [128]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try renderPctCellOpt(&w, false, .muted, null, 8, false);
    try testing.expect(std.mem.indexOf(u8, w.buffered(), "—") != null);

    var buf2: [128]u8 = undefined;
    var w2: std.Io.Writer = .fixed(&buf2);
    try renderPctCellOpt(&w2, false, .normal, 0.20, 8, false);
    try testing.expect(std.mem.indexOf(u8, w2.buffered(), "20.0%") != null);
}

test "renderSharpeCell: two-decimal precision" {
    var buf: [128]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try renderSharpeCell(&w, false, .positive, 1.234, 8, false);
    const out = w.buffered();
    try testing.expect(std.mem.indexOf(u8, out, "1.23") != null);
}

test "renderSharpeCell: null renders em-dash" {
    var buf: [128]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try renderSharpeCell(&w, false, .muted, null, 8, false);
    try testing.expect(std.mem.indexOf(u8, w.buffered(), "—") != null);
}

test "renderRow: writes complete row with all fields" {
    var buf: [4096]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const r: review_view.ReviewRow = .{
        .symbol = "VTI",
        .bucket = "Diversified",
        .tax_pct = 0.40,
        .weight = 0.33,
        .return_1y = 0.15,
        .return_3y = 0.18,
        .return_5y = 0.12,
        .return_10y = 0.14,
        .vol_3y = 0.16,
        .vol_10y = 0.17,
        .sharpe_3y = 1.10,
        .sharpe_10y = 0.85,
        .maxdd_5y = 0.25,
    };
    try renderRow(&w, false, r);
    const out = w.buffered();
    try testing.expect(std.mem.indexOf(u8, out, "VTI") != null);
    try testing.expect(std.mem.indexOf(u8, out, "Diversified") != null);
    try testing.expect(std.mem.indexOf(u8, out, "+15.0%") != null);
    try testing.expect(std.mem.indexOf(u8, out, "1.10") != null);
    try testing.expect(std.mem.indexOf(u8, out, "25.0%") != null);
    // Tax% renders at the END of the row.
    try testing.expect(std.mem.indexOf(u8, out, "40.0%") != null);
}

test "renderRow: nulls render as em-dashes" {
    var buf: [4096]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const r: review_view.ReviewRow = .{
        .symbol = "NEW",
        .bucket = "Bonds",
        .tax_pct = null,
        .weight = 0.05,
        .return_1y = null,
        .return_3y = null,
        .return_5y = null,
        .return_10y = null,
        .vol_3y = null,
        .vol_10y = null,
        .sharpe_3y = null,
        .sharpe_10y = null,
        .maxdd_5y = null,
    };
    try renderRow(&w, false, r);
    const out = w.buffered();
    try testing.expect(std.mem.indexOf(u8, out, "NEW") != null);
    // Many em-dash cells expected on this row (returns + risk + tax%).
    try testing.expect(std.mem.count(u8, out, "—") >= 8);
}

test "renderTotalsRow: renders Total label and weighted aggregates" {
    var buf: [4096]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const t: review_view.ReviewTotals = .{
        .weight = 1.0,
        .return_1y = 0.20,
        .return_3y = 0.15,
        .return_5y = 0.13,
        .return_10y = 0.14,
        .vol_3y = 0.13,
        .vol_10y = 0.16,
        .sharpe_3y = 1.05,
        .sharpe_10y = 0.95,
        .maxdd_5y = 0.22,
        .tax_pct = 0.50,
        .reweight_flags = .{},
    };
    try renderTotalsRow(&w, false, t);
    const out = w.buffered();
    try testing.expect(std.mem.indexOf(u8, out, "Total") != null);
    try testing.expect(std.mem.indexOf(u8, out, "+20.0%") != null);
    try testing.expect(std.mem.indexOf(u8, out, "100.0%") != null);
}

test "renderTotalsRow: reweight flag adds asterisk" {
    var buf: [4096]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const t: review_view.ReviewTotals = .{
        .weight = 1.0,
        .return_1y = null,
        .return_3y = null,
        .return_5y = null,
        .return_10y = null,
        .vol_3y = 0.13,
        .vol_10y = null,
        .sharpe_3y = null,
        .sharpe_10y = null,
        .maxdd_5y = null,
        .tax_pct = null,
        .reweight_flags = .{ .vol_3y = true },
    };
    try renderTotalsRow(&w, false, t);
    const out = w.buffered();
    try testing.expect(std.mem.indexOf(u8, out, "13.0%*") != null);
}

test "anyReweightFlag: detects any flag" {
    try testing.expectEqual(false, anyReweightFlag(.{}));
    try testing.expectEqual(true, anyReweightFlag(.{ .vol_3y = true }));
    try testing.expectEqual(true, anyReweightFlag(.{ .return_10y = true }));
    try testing.expectEqual(true, anyReweightFlag(.{ .maxdd_5y = true }));
}

test "render: emits header, separator, rows, and totals" {
    var buf: [16384]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    var rows = [_]review_view.ReviewRow{
        .{
            .symbol = "VTI",
            .bucket = "Diversified",
            .tax_pct = 1.0,
            .weight = 0.6,
            .return_1y = 0.15,
            .return_3y = 0.18,
            .return_5y = 0.12,
            .return_10y = 0.14,
            .vol_3y = 0.16,
            .vol_10y = 0.17,
            .sharpe_3y = 1.10,
            .sharpe_10y = 0.85,
            .maxdd_5y = 0.25,
        },
        .{
            .symbol = "BND",
            .bucket = "Bonds",
            .tax_pct = 0.0,
            .weight = 0.4,
            .return_1y = 0.04,
            .return_3y = 0.03,
            .return_5y = 0.02,
            .return_10y = 0.03,
            .vol_3y = 0.05,
            .vol_10y = 0.06,
            .sharpe_3y = 0.20,
            .sharpe_10y = 0.15,
            .maxdd_5y = 0.08,
        },
    };
    const view: review_view.ReviewView = .{
        .rows = rows[0..],
        .totals = .{
            .weight = 1.0,
            .return_1y = 0.10,
            .return_3y = 0.12,
            .return_5y = 0.08,
            .return_10y = 0.10,
            .vol_3y = 0.11,
            .vol_10y = 0.12,
            .sharpe_3y = 0.85,
            .sharpe_10y = 0.65,
            .maxdd_5y = 0.18,
            .tax_pct = 0.6,
            .reweight_flags = .{},
        },
        .as_of = zfin.Date.fromYmd(2026, 6, 4),
        .total_liquid = 1_000_000.0,
        .portfolio_path = "test_portfolio.srf",
    };
    try render(testing.allocator, std.testing.io, &w, false, view, "test_portfolio.srf", .{ .checks = .none });
    const out = w.buffered();
    try testing.expect(std.mem.indexOf(u8, out, "Portfolio Review") != null);
    try testing.expect(std.mem.indexOf(u8, out, "test_portfolio.srf") != null);
    try testing.expect(std.mem.indexOf(u8, out, "Symbol") != null);
    try testing.expect(std.mem.indexOf(u8, out, "VTI") != null);
    try testing.expect(std.mem.indexOf(u8, out, "BND") != null);
    try testing.expect(std.mem.indexOf(u8, out, "Total") != null);
    // Tax% header should be the LAST column header.
    const tax_idx = std.mem.indexOf(u8, out, "Tax%") orelse unreachable;
    const dd_idx = std.mem.indexOf(u8, out, "5Y-MaxDD") orelse unreachable;
    try testing.expect(tax_idx > dd_idx);
}

test "render: emits reweight footnote when any flag set" {
    var buf: [16384]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const view: review_view.ReviewView = .{
        .rows = &.{},
        .totals = .{
            .weight = 0.0,
            .return_1y = null,
            .return_3y = null,
            .return_5y = null,
            .return_10y = null,
            .vol_3y = null,
            .vol_10y = null,
            .sharpe_3y = null,
            .sharpe_10y = null,
            .maxdd_5y = null,
            .tax_pct = null,
            .reweight_flags = .{ .vol_10y = true },
        },
        .as_of = zfin.Date.fromYmd(2026, 6, 4),
        .total_liquid = 0,
        .portfolio_path = "x.srf",
    };
    try render(testing.allocator, std.testing.io, &w, false, view, "test_portfolio.srf", .{ .checks = .none });
    const out = w.buffered();
    try testing.expect(std.mem.indexOf(u8, out, "Reweighted") != null);
}
