//! `src/views/history.zig` — view models for the portfolio history UX.
//!
//! Sits alongside `views/portfolio_sections.zig` in the views layer,
//! which owns renderer-agnostic display data consumed by both CLI and
//! TUI. Column widths, format strings, computed values, and style
//! decisions are defined here; renderers are thin adapters that map
//! `StyleIntent` to platform-specific styles and emit pre-formatted
//! text.
//!
//! Contract: no IO, no writer, no ANSI, no `vaxis.Cell.Style`. Functions
//! consume `timeline` types and format primitives from `src/format.zig`,
//! and return formatted strings or structured data.
//!
//! Why this layer exists: the CLI and TUI render the same sections
//! (windows block, recent-snapshots table) from the same data. Before
//! this module, both reimplemented the formatting in parallel and
//! drifted (column widths didn't match, labels differed, row styling
//! diverged). Centralizing the row-cell layout here guarantees parity.

const std = @import("std");
const timeline = @import("../analytics/timeline.zig");
const fmt = @import("../format.zig");
const Money = @import("../Money.zig");
const StyleIntent = fmt.StyleIntent;

// ── Column widths (shared by CLI + TUI) ──────────────────────

/// Width of the leftmost label column in the windows block.
/// Fits "10 years" + padding. `{s:<12}` (left-aligned).
pub const windows_label_width: usize = 12;

/// Width of the Δ (signed money) column. Fits worst-case like
/// `"+$99,999,999.99"` (15 chars) with slack. Right-aligned.
pub const windows_delta_width: usize = 18;

/// Width of the % column. Fits `"+999.99%"` with slack. Right-aligned.
pub const windows_pct_width: usize = 10;

/// Width of the annualized % (CAGR) column. Same shape as
/// `windows_pct_width`. Right-aligned.
pub const windows_ann_width: usize = 10;

/// Width of the date column in the recent-snapshots table.
/// ISO date `YYYY-MM-DD` is exactly 10 chars.
pub const table_date_width: usize = 10;

/// Width of each `"$value (±Δ)"` composite cell in the table.
/// Composed of two sub-columns:
///   - value: right-aligned dollar amount (`value_subcol_width`)
///   - delta: left-aligned signed delta (`delta_subcol_width`)
/// separated by a single space. Both sub-cells are padded
/// independently so leading-digit columns line up vertically
/// across rows of varying magnitude.
///
/// Worst case sizes:
///   value: `$99,999,999.99` = 14 chars
///   delta: `(+$9,999,999.99)` = 16 chars
/// Composite total = 14 + 1 + 16 = 31 chars.
pub const value_subcol_width: usize = 14;
pub const delta_subcol_width: usize = 16;
pub const table_cell_width: usize = value_subcol_width + 1 + delta_subcol_width;

// ── Windows block row ────────────────────────────────────────

/// One row's worth of pre-formatted cells for the windows block.
/// Strings reference caller-owned buffers passed into
/// `buildWindowRowCells`; do not outlive them.
///
/// `style` is a semantic intent — the renderer maps it to the
/// platform's actual color. Zero-delta and missing-anchor rows both
/// resolve to `.muted` because neither deserves a green/red shout in
/// practice; no caller today needs to distinguish them.
pub const WindowRowCells = struct {
    label: []const u8,
    delta_str: []const u8,
    pct_str: []const u8,
    /// Annualized (CAGR) percentage, formatted with sign and `%`.
    /// Same `"n/a"` fallback as `pct_str` when input is null —
    /// missing anchor or non-finite math.
    ann_str: []const u8,
    style: StyleIntent,
};

/// Render a WindowStat into displayable cells. `delta_buf`,
/// `pct_buf`, and `ann_buf` are caller-owned stack buffers the
/// returned strings borrow from — they must outlive the returned
/// struct.
///
/// Missing anchors (null `delta_abs`) produce `"n/a"` in both string
/// columns. A zero delta produces `"$0.00"` / `"0.00%"` and `.muted`
/// style. Positive/negative deltas map to `.positive` / `.negative`.
///
/// The pct columns never show a trailing-whitespace oddity: sign and
/// digits sit flush (`"+0.41%"`, not `"+    0.41%"`). Column alignment
/// is the caller's job via `windows_pct_width` / `windows_ann_width`.
pub fn buildWindowRowCells(
    row: timeline.WindowStat,
    delta_buf: *[32]u8,
    pct_buf: *[16]u8,
    ann_buf: *[16]u8,
) WindowRowCells {
    const style: StyleIntent = blk: {
        const d = row.delta_abs orelse break :blk .muted;
        if (d > 0) break :blk .positive;
        if (d < 0) break :blk .negative;
        break :blk .muted; // zero delta → muted (same as missing)
    };

    const delta_str: []const u8 = if (row.delta_abs) |d|
        std.fmt.bufPrint(delta_buf, "{f}", .{Money.from(d).signed()}) catch "$?"
    else
        "n/a";

    const pct_str: []const u8 = if (row.delta_pct) |p|
        fmtSignedPercentBuf(pct_buf, p)
    else
        // delta_abs missing OR start_value was 0 (division by zero).
        // Both read as "pct undefined" for the user.
        "n/a";

    const ann_str: []const u8 = if (row.annualized_pct) |a|
        fmtSignedPercentBuf(ann_buf, a)
    else
        "—";

    return .{
        .label = row.label,
        .delta_str = delta_str,
        .pct_str = pct_str,
        .ann_str = ann_str,
        .style = style,
    };
}

/// Format a signed percentage: `"+0.41%"`, `"-1.07%"`, `"0.00%"`.
/// Input is a ratio (0.0041 → "+0.41%"). Returns a slice of `buf`.
///
/// Sign sits flush against the digit — no internal whitespace
/// padding. Right-alignment in the column is the caller's job.
pub fn fmtSignedPercentBuf(buf: *[16]u8, ratio: f64) []const u8 {
    const pct = ratio * 100.0;
    const prefix: []const u8 = if (pct > 0) "+" else if (pct < 0) "-" else "";
    return std.fmt.bufPrint(buf, "{s}{d:.2}%", .{ prefix, @abs(pct) }) catch "?";
}

// ── Recent-snapshots table cell ──────────────────────────────

/// Format a composite `"$value (±Δ)"` cell for the recent-snapshots
/// table.
///
/// Two sub-columns aligned independently:
///   - value: right-aligned in `value_subcol_width` chars so digits
///     stack cleanly across rows of differing magnitude.
///   - delta: `(±$D.DD)` left-aligned in `delta_subcol_width` chars
///     so the closing `)` lands at a consistent column.
/// Separated by a single space.
///
/// `delta_opt = null` (first row) renders as `(—)` — the em-dash
/// signals "no prior row to compare against" without wasting a Δ
/// column.
///
/// `width` parameter is honored but the value/delta sub-widths are
/// fixed at the module-level constants. If `width >
/// table_cell_width`, additional left-padding is added (so the
/// caller's column is wider but the sub-columns stay aligned).
/// If `width < table_cell_width`, sub-columns may overflow the
/// caller's slot — caller's problem; defaults match.
pub fn fmtValueDeltaCell(
    buf: []u8,
    value: f64,
    delta_opt: ?f64,
    width: usize,
) []const u8 {
    var val_buf: [24]u8 = undefined;
    var delta_inner: [32]u8 = undefined;
    var delta_outer: [40]u8 = undefined;
    const val_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(value)}) catch "$?";
    const d_inner: []const u8 = if (delta_opt) |d|
        std.fmt.bufPrint(&delta_inner, "{f}", .{Money.from(d).signed()}) catch "$?"
    else
        "—";
    const d_str = std.fmt.bufPrint(&delta_outer, "({s})", .{d_inner}) catch return "?";

    // Build the composite manually so the sub-column widths are
    // honored exactly. value is right-aligned, delta is left-aligned.
    var inner_buf: [96]u8 = undefined;
    var pos: usize = 0;

    // Value sub-column: left-pad with spaces if shorter.
    if (val_str.len < value_subcol_width) {
        const lpad = value_subcol_width - val_str.len;
        @memset(inner_buf[pos .. pos + lpad], ' ');
        pos += lpad;
    }
    @memcpy(inner_buf[pos .. pos + val_str.len], val_str);
    pos += val_str.len;

    // Separator.
    inner_buf[pos] = ' ';
    pos += 1;

    // Delta sub-column: append, then right-pad with spaces.
    // Pad based on display width — `(—)` is 5 bytes / 3 display cols.
    @memcpy(inner_buf[pos .. pos + d_str.len], d_str);
    pos += d_str.len;
    const d_display = fmt.displayCols(d_str);
    if (d_display < delta_subcol_width) {
        const rpad = delta_subcol_width - d_display;
        @memset(inner_buf[pos .. pos + rpad], ' ');
        pos += rpad;
    }

    const inner = inner_buf[0..pos];

    if (inner.len >= width) {
        // Composite is at or beyond the caller's slot; emit as-is.
        const n = @min(inner.len, buf.len);
        @memcpy(buf[0..n], inner[0..n]);
        return buf[0..n];
    }

    // Caller wants a wider slot; left-pad.
    const pad = width - inner.len;
    if (pad + inner.len > buf.len) {
        const n = @min(inner.len, buf.len);
        @memcpy(buf[0..n], inner[0..n]);
        return buf[0..n];
    }
    @memset(buf[0..pad], ' ');
    @memcpy(buf[pad .. pad + inner.len], inner);
    return buf[0 .. pad + inner.len];
}

// ── Resolution label ─────────────────────────────────────────

/// Format the resolution label shown in the recent-snapshots title:
///   `"(auto - daily)"` when `override` is null (auto; resolved to
///     `effective`)
///   `"(daily)"` / `"(weekly)"` / `"(monthly)"` when the user has
///     explicitly picked a resolution.
///
/// Returns a slice of `buf`.
pub fn fmtResolutionLabel(
    buf: []u8,
    override: ?timeline.Resolution,
    effective: timeline.Resolution,
) []const u8 {
    return if (override == null)
        std.fmt.bufPrint(buf, "(auto - {s})", .{effective.label()}) catch "?"
    else
        std.fmt.bufPrint(buf, "({s})", .{effective.label()}) catch "?";
}

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

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

fn makeWindowStat(
    period: ?@import("../analytics/valuation.zig").HistoricalPeriod,
    label: []const u8,
    start_value: ?f64,
    end_value: f64,
) timeline.WindowStat {
    const delta_abs: ?f64 = if (start_value) |s| end_value - s else null;
    const delta_pct: ?f64 = blk: {
        const s = start_value orelse break :blk null;
        if (s == 0) break :blk null;
        break :blk (end_value - s) / s;
    };
    return .{
        .period = period,
        .label = label,
        .short_label = label,
        .anchor_date = if (start_value != null) Date.fromYmd(2026, 4, 17) else null,
        .start_value = start_value,
        .end_value = end_value,
        .delta_abs = delta_abs,
        .delta_pct = delta_pct,
        // Tests in this file don't exercise the annualized math —
        // that's covered by `computeWindowSet` tests in
        // analytics/timeline.zig. Pass the same value as
        // `delta_pct` so the renderer-side formatting path is
        // exercised.
        .annualized_pct = delta_pct,
    };
}

// ── buildWindowRowCells ──

test "buildWindowRowCells: positive delta" {
    var db: [32]u8 = undefined;
    var pb: [16]u8 = undefined;
    var ab: [16]u8 = undefined;
    const row = makeWindowStat(null, "1 day", 1000, 1100);
    const cells = buildWindowRowCells(row, &db, &pb, &ab);
    try testing.expectEqualStrings("1 day", cells.label);
    try testing.expectEqualStrings("+$100.00", cells.delta_str);
    try testing.expectEqualStrings("+10.00%", cells.pct_str);
    try testing.expectEqualStrings("+10.00%", cells.ann_str);
    try testing.expectEqual(StyleIntent.positive, cells.style);
}

test "buildWindowRowCells: negative delta" {
    var db: [32]u8 = undefined;
    var pb: [16]u8 = undefined;
    var ab: [16]u8 = undefined;
    const row = makeWindowStat(null, "1 day", 1000, 900);
    const cells = buildWindowRowCells(row, &db, &pb, &ab);
    try testing.expectEqualStrings("-$100.00", cells.delta_str);
    try testing.expectEqualStrings("-10.00%", cells.pct_str);
    try testing.expectEqual(StyleIntent.negative, cells.style);
}

test "buildWindowRowCells: zero delta renders muted" {
    var db: [32]u8 = undefined;
    var pb: [16]u8 = undefined;
    var ab: [16]u8 = undefined;
    const row = makeWindowStat(null, "1 day", 1000, 1000);
    const cells = buildWindowRowCells(row, &db, &pb, &ab);
    try testing.expectEqualStrings("$0.00", cells.delta_str);
    try testing.expectEqualStrings("0.00%", cells.pct_str);
    try testing.expectEqual(StyleIntent.muted, cells.style);
}

test "buildWindowRowCells: missing anchor renders muted with n/a strings" {
    var db: [32]u8 = undefined;
    var pb: [16]u8 = undefined;
    var ab: [16]u8 = undefined;
    const row = makeWindowStat(null, "1 year", null, 1100);
    const cells = buildWindowRowCells(row, &db, &pb, &ab);
    try testing.expectEqualStrings("n/a", cells.delta_str);
    try testing.expectEqualStrings("n/a", cells.pct_str);
    try testing.expectEqualStrings("—", cells.ann_str);
    try testing.expectEqual(StyleIntent.muted, cells.style);
}

test "buildWindowRowCells: zero start_value → pct n/a, delta present" {
    var db: [32]u8 = undefined;
    var pb: [16]u8 = undefined;
    var ab: [16]u8 = undefined;
    const row = makeWindowStat(null, "All-time", 0, 100);
    const cells = buildWindowRowCells(row, &db, &pb, &ab);
    // Delta still present (100 - 0 = 100), pct is undefined so renders n/a.
    try testing.expectEqualStrings("+$100.00", cells.delta_str);
    try testing.expectEqualStrings("n/a", cells.pct_str);
    try testing.expectEqual(StyleIntent.positive, cells.style);
}

// ── fmtSignedPercentBuf ──

test "fmtSignedPercentBuf: signs + zero + flush digits" {
    var buf: [16]u8 = undefined;
    try testing.expectEqualStrings("+0.41%", fmtSignedPercentBuf(&buf, 0.0041));
    try testing.expectEqualStrings("-1.07%", fmtSignedPercentBuf(&buf, -0.0107));
    try testing.expectEqualStrings("0.00%", fmtSignedPercentBuf(&buf, 0));
    // Pct larger than 100% still renders cleanly.
    try testing.expectEqualStrings("+123.45%", fmtSignedPercentBuf(&buf, 1.2345));
}

// ── fmtValueDeltaCell ──

test "fmtValueDeltaCell: sub-columns produce expected layout" {
    var buf: [64]u8 = undefined;
    const s = fmtValueDeltaCell(&buf, 1000, 50, table_cell_width);
    // Composite total = 14 + 1 + 16 = 31 chars.
    try testing.expectEqual(table_cell_width, s.len);

    // Value sub-column: right-aligned in 14 chars.
    //   "$1,000.00" = 9 chars → 5 leading spaces.
    try testing.expectEqualStrings("     $1,000.00", s[0..value_subcol_width]);

    // Separator space.
    try testing.expectEqual(@as(u8, ' '), s[value_subcol_width]);

    // Delta sub-column: left-aligned in 16 chars.
    //   "(+$50.00)" = 9 chars → 7 trailing spaces.
    const delta_part = s[value_subcol_width + 1 ..];
    try testing.expect(std.mem.startsWith(u8, delta_part, "(+$50.00)"));
    try testing.expectEqual(delta_subcol_width, delta_part.len);
}

test "fmtValueDeltaCell: null delta renders em-dash" {
    var buf: [64]u8 = undefined;
    const s = fmtValueDeltaCell(&buf, 1000, null, table_cell_width);
    // Em-dash bytes: 3 bytes / 1 display col. Cell display width
    // is `table_cell_width` (31), but byte length is 33.
    try testing.expectEqual(@as(usize, table_cell_width + 2), s.len);
    // Delta sub-column starts with `(—)`.
    const delta_part = s[value_subcol_width + 1 ..];
    try testing.expect(std.mem.startsWith(u8, delta_part, "(—)"));
}

test "fmtValueDeltaCell: negative delta" {
    var buf: [64]u8 = undefined;
    const s = fmtValueDeltaCell(&buf, 1000, -50, table_cell_width);
    const delta_part = s[value_subcol_width + 1 ..];
    try testing.expect(std.mem.startsWith(u8, delta_part, "(-$50.00)"));
}

test "fmtValueDeltaCell: large value and delta still align" {
    // Both values right-aligned in their sub-columns; closing
    // parens line up on the right of the cell.
    var buf_a: [64]u8 = undefined;
    const a = fmtValueDeltaCell(&buf_a, 8_633_578.46, 86_757.10, table_cell_width);
    var buf_b: [64]u8 = undefined;
    const b = fmtValueDeltaCell(&buf_b, 6_614_620.99, 1_340_130.92, table_cell_width);

    // Both should be exactly table_cell_width chars long.
    try testing.expectEqual(table_cell_width, a.len);
    try testing.expectEqual(table_cell_width, b.len);

    // Both should end at column `value_subcol_width + 1 +
    // delta_subcol_width` — which by construction is true since
    // both have length == table_cell_width.
}

test "fmtValueDeltaCell: caller width smaller than composite still emits full content" {
    // Caller asked for 10 cols; composite needs 31. Emit composite as-is.
    var buf: [64]u8 = undefined;
    const s = fmtValueDeltaCell(&buf, 1000, 50, 10);
    try testing.expect(s.len >= 31);
}

// ── fmtResolutionLabel ──

test "fmtResolutionLabel: auto reveals the effective resolution" {
    var buf: [32]u8 = undefined;
    try testing.expectEqualStrings("(auto - daily)", fmtResolutionLabel(&buf, null, .daily));
    try testing.expectEqualStrings("(auto - weekly)", fmtResolutionLabel(&buf, null, .weekly));
    try testing.expectEqualStrings("(auto - monthly)", fmtResolutionLabel(&buf, null, .monthly));
}

test "fmtResolutionLabel: explicit override hides the 'auto' prefix" {
    var buf: [32]u8 = undefined;
    try testing.expectEqualStrings("(daily)", fmtResolutionLabel(&buf, .daily, .daily));
    try testing.expectEqualStrings("(weekly)", fmtResolutionLabel(&buf, .weekly, .weekly));
    try testing.expectEqualStrings("(monthly)", fmtResolutionLabel(&buf, .monthly, .monthly));
}
