//! View models for portfolio sections (Options, CDs).
//! Produces 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.

const std = @import("std");
const Lot = @import("../models/portfolio.zig").Lot;
const Date = @import("../Date.zig");
const fmt = @import("../format.zig");
const Money = @import("../Money.zig");

// ── Options ───────────────────────────────────────────────────

/// Column layout for the Options section.
/// All format strings are derived from the width constants.
pub const OptionsLayout = struct {
    const cp = std.fmt.comptimePrint;
    pub const prefix = "    ";
    pub const symbol_w = 30;
    pub const qty_w = 6;
    pub const cost_w = 12;
    pub const premium_w = 14;
    pub const account_w = 10;

    pub const premium_col_start: usize = prefix.len + symbol_w + 1 + qty_w + 1 + cost_w + 1;

    pub const header = prefix ++ cp("{{s:<{d}}}", .{symbol_w}) ++ " " ++ cp("{{s:>{d}}}", .{qty_w}) ++ " " ++ cp("{{s:>{d}}}", .{cost_w}) ++ " " ++ cp("{{s:>{d}}}", .{premium_w}) ++ " {s}";
    pub const header_labels = .{ "Contract", "Qty", "Cost/Ctrct", "Premium", "Account" };

    pub const separator = prefix ++ cp("{{s:->{d}}}", .{symbol_w}) ++ " " ++ cp("{{s:->{d}}}", .{qty_w}) ++ " " ++ cp("{{s:->{d}}}", .{cost_w}) ++ " " ++ cp("{{s:->{d}}}", .{premium_w}) ++ " " ++ cp("{{s:->{d}}}", .{account_w});
    pub const separator_fills = .{ "", "", "", "", "" };

    pub const data_row = prefix ++ cp("{{s:<{d}}}", .{symbol_w}) ++ " " ++ cp("{{d:>{d}.0}}", .{qty_w}) ++ " " ++ cp("{{s:>{d}}}", .{cost_w}) ++ " " ++ cp("{{s:>{d}}}", .{premium_w}) ++ " {s}";
};

/// A styled text span for multi-style row rendering.
pub const StyledSpan = struct {
    text: []const u8,
    style: fmt.StyleIntent,
};

/// A single option row with pre-computed display values.
pub const Option = struct {
    lot: Lot,
    premium: f64,
    received: bool,
    is_expired: bool,
    row_style: fmt.StyleIntent,
    premium_style: fmt.StyleIntent,
    columns: [2]StyledSpan,
    premium_col_start: usize,
};

/// Collection of prepared option rows. Owns all allocated text.
///
/// Rows are sorted by maturity ascending (then symbol), so expired
/// rows form a contiguous prefix: `items[0..expired_count]` are the
/// expired contracts and `items[expired_count..]` are the active ones
/// (including null-maturity lots, which sort last and are never
/// expired). Use `expiredItems()` / `activeItems()` rather than
/// re-deriving the split.
pub const Options = struct {
    items: []const Option,
    /// Count of leading expired rows (the contiguous prefix of
    /// `items` whose `is_expired` is true).
    expired_count: usize,
    allocator: std.mem.Allocator,

    /// Expired option rows (matured strictly before `as_of`).
    pub fn expiredItems(self: Options) []const Option {
        return self.items[0..self.expired_count];
    }

    /// Active option rows (not yet expired, including null-maturity).
    pub fn activeItems(self: Options) []const Option {
        return self.items[self.expired_count..];
    }

    /// Build sorted, filtered, display-ready option rows from raw lots.
    pub fn init(as_of: Date, allocator: std.mem.Allocator, lots: []const Lot, account_filter: ?[]const u8) !Options {
        var list: std.ArrayList(Option) = .empty;
        errdefer {
            for (list.items) |opt| allocator.free(opt.columns[0].text);
            list.deinit(allocator);
        }

        var tmp: std.ArrayList(Lot) = .empty;
        defer tmp.deinit(allocator);
        for (lots) |lot| {
            if (lot.security_type != .option) continue;
            if (account_filter) |af| {
                const la = lot.account orelse "";
                if (!std.mem.eql(u8, la, af)) continue;
            }
            try tmp.append(allocator, lot);
        }
        std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturityThenSymbolSortFn);

        var expired_count: usize = 0;
        for (tmp.items) |lot| {
            const qty = lot.shares;
            const cost_per = lot.open_price;
            const premium = @abs(qty) * cost_per * lot.multiplier;
            const is_expired = if (lot.maturity_date) |md| md.lessThan(as_of) else false;
            if (is_expired) expired_count += 1;
            const received = qty < 0;

            const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;
            const premium_style: fmt.StyleIntent = if (is_expired) .muted else if (received) .positive else .negative;

            var cost_buf: [24]u8 = undefined;
            var prem_val_buf: [24]u8 = undefined;
            const prem_money = std.fmt.bufPrint(&prem_val_buf, "{f}", .{Money.from(premium)}) catch "$?";
            var prem_buf: [20]u8 = undefined;
            const prem_str = if (received)
                std.fmt.bufPrint(&prem_buf, "+{s}", .{prem_money}) catch "?"
            else
                std.fmt.bufPrint(&prem_buf, "-{s}", .{prem_money}) catch "?";
            const acct = lot.account orelse "";

            const text = try std.fmt.allocPrint(allocator, OptionsLayout.data_row, .{
                lot.symbol,
                qty,
                std.fmt.bufPrint(&cost_buf, "{f}", .{Money.from(cost_per)}) catch "$?",
                prem_str,
                acct,
            });

            try list.append(allocator, .{
                .lot = lot,
                .premium = premium,
                .received = received,
                .is_expired = is_expired,
                .row_style = row_style,
                .premium_style = premium_style,
                .columns = .{
                    .{ .text = text, .style = row_style },
                    .{ .text = prem_str, .style = premium_style },
                },
                .premium_col_start = OptionsLayout.premium_col_start,
            });
        }
        return .{ .items = try list.toOwnedSlice(allocator), .expired_count = expired_count, .allocator = allocator };
    }

    pub fn deinit(self: *Options) void {
        for (self.items) |opt| self.allocator.free(opt.columns[0].text);
        self.allocator.free(self.items);
        self.items = &.{};
    }
};

// ── CDs ───────────────────────────────────────────────────────

/// Column layout for the Certificates of Deposit section.
pub const CDsLayout = struct {
    const cp = std.fmt.comptimePrint;
    pub const prefix = "    ";
    pub const cusip_w = 12;
    pub const face_w = 14;
    pub const rate_w = 7;
    pub const maturity_w = 10;
    pub const desc_w = 40;
    pub const account_w = 10;

    pub const header = prefix ++ cp("{{s:<{d}}}", .{cusip_w}) ++ " " ++ cp("{{s:>{d}}}", .{face_w}) ++ " " ++ cp("{{s:>{d}}}", .{rate_w}) ++ " " ++ cp("{{s:>{d}}}", .{maturity_w}) ++ "  {s}  {s}";
    pub const header_labels = .{ "CUSIP", "Face Value", "Rate", "Maturity", "Description", "Account" };

    pub const separator = prefix ++ cp("{{s:->{d}}}", .{cusip_w}) ++ " " ++ cp("{{s:->{d}}}", .{face_w}) ++ " " ++ cp("{{s:->{d}}}", .{rate_w}) ++ " " ++ cp("{{s:->{d}}}", .{maturity_w}) ++ "  " ++ cp("{{s:->{d}}}", .{desc_w}) ++ "  " ++ cp("{{s:->{d}}}", .{account_w});
    pub const separator_fills = .{ "", "", "", "", "", "" };

    pub const data_row = prefix ++ cp("{{s:<{d}}}", .{cusip_w}) ++ " " ++ cp("{{s:>{d}}}", .{face_w}) ++ " " ++ cp("{{s:>{d}}}", .{rate_w}) ++ " " ++ cp("{{s:>{d}}}", .{maturity_w}) ++ "  {s}  {s}";
};

/// A single CD row with pre-computed display values.
pub const CD = struct {
    lot: Lot,
    is_expired: bool,
    row_style: fmt.StyleIntent,
    text: []const u8,
};

/// Collection of prepared CD rows. Owns all allocated text.
///
/// Rows are sorted by maturity ascending, so matured rows form a
/// contiguous prefix: `items[0..expired_count]` are matured and
/// `items[expired_count..]` are still-active (including null-maturity
/// lots, which sort last and are never expired). Use `expiredItems()`
/// / `activeItems()` rather than re-deriving the split.
pub const CDs = struct {
    items: []const CD,
    /// Count of leading matured rows (the contiguous prefix of
    /// `items` whose `is_expired` is true).
    expired_count: usize,
    allocator: std.mem.Allocator,

    /// Matured CD rows (maturity strictly before `as_of`).
    pub fn expiredItems(self: CDs) []const CD {
        return self.items[0..self.expired_count];
    }

    /// Active CD rows (not yet matured, including null-maturity).
    pub fn activeItems(self: CDs) []const CD {
        return self.items[self.expired_count..];
    }

    /// Build sorted, filtered, display-ready CD rows from raw lots.
    pub fn init(as_of: Date, allocator: std.mem.Allocator, lots: []const Lot, account_filter: ?[]const u8) !CDs {
        var list: std.ArrayList(CD) = .empty;
        errdefer {
            for (list.items) |cd| allocator.free(cd.text);
            list.deinit(allocator);
        }

        var tmp: std.ArrayList(Lot) = .empty;
        defer tmp.deinit(allocator);
        for (lots) |lot| {
            if (lot.security_type != .cd) continue;
            if (account_filter) |af| {
                const la = lot.account orelse "";
                if (!std.mem.eql(u8, la, af)) continue;
            }
            try tmp.append(allocator, lot);
        }
        std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturitySortFn);

        var expired_count: usize = 0;
        for (tmp.items) |lot| {
            const is_expired = if (lot.maturity_date) |md| md.lessThan(as_of) else false;
            if (is_expired) expired_count += 1;
            const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;

            var face_buf: [24]u8 = undefined;
            var mat_buf: [10]u8 = undefined;
            const mat_str: []const u8 = if (lot.maturity_date) |md| (std.fmt.bufPrint(&mat_buf, "{f}", .{md}) catch "????-??-??") else "--";
            var rate_buf: [10]u8 = undefined;
            const rate_str: []const u8 = if (lot.rate) |r|
                std.fmt.bufPrint(&rate_buf, "{d:.2}%", .{r}) catch "--"
            else
                "--";
            const note_str: []const u8 = lot.note orelse "";
            const note_display = if (note_str.len > 40) note_str[0..40] else note_str;
            const acct = lot.account orelse "";

            const text = try std.fmt.allocPrint(allocator, CDsLayout.data_row, .{
                lot.symbol,
                std.fmt.bufPrint(&face_buf, "{f}", .{Money.from(lot.shares)}) catch "$?",
                rate_str,
                mat_str,
                note_display,
                acct,
            });

            try list.append(allocator, .{
                .lot = lot,
                .is_expired = is_expired,
                .row_style = row_style,
                .text = text,
            });
        }
        return .{ .items = try list.toOwnedSlice(allocator), .expired_count = expired_count, .allocator = allocator };
    }

    pub fn deinit(self: *CDs) void {
        for (self.items) |cd| self.allocator.free(cd.text);
        self.allocator.free(self.items);
        self.items = &.{};
    }
};

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

const testing = std.testing;

test "Options.init: expired rows form a prefix; active/expired slices split correctly" {
    const as_of = Date.fromYmd(2024, 6, 1);
    const lots = [_]Lot{
        // active (future maturity)
        .{ .symbol = "AAA 2024-12-01 C100", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 2.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 12, 1) },
        // expired (past maturity)
        .{ .symbol = "BBB 2024-01-01 C50", .shares = 1, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 1.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 1, 1) },
        // null-maturity option: never expired, sorts last
        .{ .symbol = "CCC", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 3.0, .security_type = .option },
        // non-option: ignored
        .{ .symbol = "ZZZ", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 5.0, .security_type = .stock },
    };
    var opts = try Options.init(as_of, testing.allocator, &lots, null);
    defer opts.deinit();

    try testing.expectEqual(@as(usize, 3), opts.items.len);
    try testing.expectEqual(@as(usize, 1), opts.expired_count);
    try testing.expectEqual(@as(usize, 1), opts.expiredItems().len);
    try testing.expectEqual(@as(usize, 2), opts.activeItems().len);
    try testing.expect(opts.expiredItems()[0].is_expired);
    try testing.expectEqualStrings("BBB 2024-01-01 C50", opts.expiredItems()[0].lot.symbol);
    for (opts.activeItems()) |a| try testing.expect(!a.is_expired);
}

test "Options.init: no expired items yields empty expired slice" {
    const as_of = Date.fromYmd(2024, 6, 1);
    const lots = [_]Lot{
        .{ .symbol = "AAA", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 2.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 12, 1) },
    };
    var opts = try Options.init(as_of, testing.allocator, &lots, null);
    defer opts.deinit();
    try testing.expectEqual(@as(usize, 0), opts.expired_count);
    try testing.expectEqual(@as(usize, 0), opts.expiredItems().len);
    try testing.expectEqual(@as(usize, 1), opts.activeItems().len);
}

test "CDs.init: matured rows form a prefix; active/expired slices split correctly" {
    const as_of = Date.fromYmd(2024, 6, 1);
    const lots = [_]Lot{
        .{ .symbol = "CD-ACTIVE", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2025, 1, 1), .rate = 4.5 },
        .{ .symbol = "CD-MATURED", .shares = 5000, .open_date = Date.fromYmd(2023, 1, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2024, 1, 1), .rate = 3.0 },
    };
    var cds = try CDs.init(as_of, testing.allocator, &lots, null);
    defer cds.deinit();

    try testing.expectEqual(@as(usize, 2), cds.items.len);
    try testing.expectEqual(@as(usize, 1), cds.expired_count);
    try testing.expectEqual(@as(usize, 1), cds.expiredItems().len);
    try testing.expectEqual(@as(usize, 1), cds.activeItems().len);
    try testing.expect(cds.expiredItems()[0].is_expired);
    try testing.expect(!cds.activeItems()[0].is_expired);
    try testing.expectEqualStrings("CD-MATURED", cds.expiredItems()[0].lot.symbol);
    try testing.expectEqualStrings("CD-ACTIVE", cds.activeItems()[0].lot.symbol);
}

test "CDs.init: all matured yields empty active slice" {
    const as_of = Date.fromYmd(2024, 6, 1);
    const lots = [_]Lot{
        .{ .symbol = "CD-OLD-1", .shares = 5000, .open_date = Date.fromYmd(2022, 1, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2023, 1, 1) },
        .{ .symbol = "CD-OLD-2", .shares = 7000, .open_date = Date.fromYmd(2022, 6, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2024, 1, 1) },
    };
    var cds = try CDs.init(as_of, testing.allocator, &lots, null);
    defer cds.deinit();
    try testing.expectEqual(@as(usize, 2), cds.expired_count);
    try testing.expectEqual(@as(usize, 2), cds.expiredItems().len);
    try testing.expectEqual(@as(usize, 0), cds.activeItems().len);
}
