const std = @import("std");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const Money = @import("../Money.zig");
const theme = @import("theme.zig");
const tui = @import("../tui.zig");
const framework = @import("tab_framework.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;

// ── Tab-local action enum ─────────────────────────────────────
//
// Toggle the Sector breakdown's display granularity between
// coarse (4 macro buckets — Equity / Fixed Income / Cash /
// Other) and fine (one row per bucket label, the default).
// Coarse delegates to the same 4-bucket shape as the Asset
// Category section.

pub const Action = enum { cycle_sector_granularity };

// ── Tab-private state ─────────────────────────────────────────

pub const State = struct {
    /// Whether `init`/`activate` has populated `result`. Internal
    /// short-circuit for `activate`; App never reads this.
    loaded: bool = false,
    /// Computed analysis output. Owned by State; freed in
    /// `deinit` and `reload`.
    result: ?zfin.analysis.AnalysisResult = null,
    /// Sector display granularity. Toggled via `cycle_sector_granularity`
    /// action. Default `fine` matches the CLI default
    /// (`zfin analysis` without `--sector-detail`).
    sector_granularity: zfin.analysis.Granularity = .fine,
};

// ── Tab framework contract ────────────────────────────────────

pub const meta: framework.TabMeta(Action) = .{
    .label = "Analysis",
    .default_bindings = &.{
        .{ .action = .cycle_sector_granularity, .key = .{ .codepoint = 'm' } },
    },
    .action_labels = std.enums.EnumArray(Action, []const u8).init(.{
        .cycle_sector_granularity = "Toggle sector granularity (coarse / fine)",
    }),
    .status_hints = &.{
        .cycle_sector_granularity,
    },
};

pub const tab = struct {
    pub const ActionT = Action;
    pub const StateT = State;

    pub fn init(state: *State, app: *App) !void {
        _ = app;
        state.* = .{};
    }

    pub fn deinit(state: *State, app: *App) void {
        if (state.result) |*ar| ar.deinit(app.allocator);
        state.* = .{};
    }

    pub fn activate(state: *State, app: *App) !void {
        if (tab.isDisabled(app)) return;
        if (state.loaded) return;
        loadData(state, app);
    }

    pub const deactivate = framework.noopDeactivate(State);

    /// Force re-fetch on user request. Frees the analysis result
    /// and invalidates the shared `account_map` + `classification_map`
    /// on PortfolioData so the next load re-reads `accounts.srf`
    /// and `metadata.srf` from disk (the user may have edited
    /// either).
    pub fn reload(state: *State, app: *App) !void {
        if (state.result) |*ar| ar.deinit(app.allocator);
        state.result = null;
        state.loaded = false;
        app.portfolio.invalidateAccountMap();
        app.portfolio.invalidateClassificationMap();
        loadData(state, app);
    }

    pub const tick = framework.noopTick(State);

    pub fn handleAction(state: *State, app: *App, action: Action) void {
        _ = app;
        switch (action) {
            .cycle_sector_granularity => {
                // Binary toggle: coarse ↔ fine. The display layer
                // re-aggregates `result.sector` through the new
                // granularity on the next render.
                state.sector_granularity = switch (state.sector_granularity) {
                    .coarse => .fine,
                    .fine => .coarse,
                };
            },
        }
    }

    /// Analysis requires a loaded portfolio file (the breakdown
    /// is computed from `app.portfolio.summary.allocations`).
    /// Derived directly from `app.portfolio.file` rather than
    /// stored as a field, so it can't go stale and App never has
    /// to reach across into the tab's State to set it.
    pub fn isDisabled(app: *App) bool {
        return app.portfolio.file == null;
    }

    /// Drop cached analysis result on portfolio reload. The
    /// `result` holds pointers into the previous portfolio's
    /// memory (allocations/symbols), so we have to invalidate
    /// it before the underlying data is freed.
    ///
    /// `classification_map` and `account_map` live on
    /// PortfolioData and are reset by pd's own load path —
    /// nothing to free here for those.
    ///
    /// Deliberately does NOT eager-rebuild, even when this tab
    /// is active. The reload broadcast fires BEFORE pd.reload
    /// resets the portfolio arena; rebuilding now would store
    /// borrowed pointers (e.g. BreakdownItem.label borrowing
    /// from ClassificationEntry.bucket) that get freed seconds
    /// later when the arena resets, producing use-after-free
    /// crashes on the next draw. The orchestrator
    /// (portfolio_tab.reloadPortfolioFile) calls
    /// `app.loadTabData()` AFTER pd.reload completes, which
    /// re-activates the active tab against fresh data.
    pub fn onPortfolioReload(state: *State, app: *App) void {
        if (state.result) |*ar| ar.deinit(app.allocator);
        state.result = null;
        // classification_map lives on PortfolioData now; the
        // reload's broadcast already invalidated it via pd's own
        // reset path. Nothing to free here.
        state.loaded = false;
    }
};

// ── Data loading ──────────────────────────────────────────────

fn loadData(state: *State, app: *App) void {
    state.loaded = true;

    // PortfolioData was loaded at App init; sync fields are
    // populated. If they're still null, no portfolio is loaded
    // (welcome screen) and we exit silently.
    const pf = app.portfolio.file orelse return;
    const summary_ptr = if (app.portfolio.summary) |*s| s else return;

    loadDataFinish(state, app, pf, summary_ptr.*);
}

fn loadDataFinish(state: *State, app: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void {
    // classificationMap() blocks on the classification-map
    // worker; first call may briefly wait while metadata.srf
    // parses. Returns null when metadata.srf is missing.
    const cm_ptr = app.portfolio.classificationMap() orelse {
        app.setStatus("No classification data. Run: zfin enrich <portfolio.srf> > metadata.srf");
        return;
    };

    // Free previous result
    if (state.result) |*ar| ar.deinit(app.allocator);

    // accountMap() blocks on the worker future — first call may
    // briefly wait if the worker is still finishing. After this,
    // subsequent calls are sync.
    const acct_map_opt: ?zfin.analysis.AccountMap = if (app.portfolio.accountMap()) |amp| amp.* else null;
    state.result = zfin.analysis.analyzePortfolio(
        app.allocator,
        summary.allocations,
        cm_ptr.*,
        pf,
        summary.total_value,
        acct_map_opt,
        app.today, // live mode in TUI → resolves to app.today
    ) catch {
        app.setStatus("Error computing analysis");
        return;
    };
}

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

pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine {
    // Compute equity/fixed-income/cash split from classification + portfolio
    var stock_pct: f64 = 0;
    var bond_pct: f64 = 0;
    var cash_pct: f64 = 0;
    var total_value: f64 = 0;
    if (app.portfolio.summary) |summary| {
        total_value = summary.total_value;
        if (app.portfolio.file) |pf| {
            const benchmark = @import("../analytics/benchmark.zig");
            const cm_entries: []const zfin.classification.ClassificationEntry =
                if (app.portfolio.classificationMap()) |cm| cm.entries else &.{};
            const split = benchmark.deriveAllocationSplit(
                summary.allocations,
                cm_entries,
                summary.total_value,
                pf.totalCash(app.today),
                pf.totalCdFaceValue(app.today),
            );
            stock_pct = split.stock_pct;
            bond_pct = split.bond_pct;
            cash_pct = split.cash_pct;
        }
    }
    // accountMap() blocks on its worker; first call may briefly
    // wait. Returns null when no portfolio is loaded or
    // accounts.srf is missing.
    const acct_map_opt: ?zfin.analysis.AccountMap = if (app.portfolio.accountMap()) |amp| amp.* else null;
    return renderAnalysisLines(arena, app.theme, state.result, stock_pct, bond_pct, cash_pct, total_value, state.sector_granularity, acct_map_opt);
}

/// Render analysis tab content. Pure function — no App dependency.
pub fn renderAnalysisLines(
    arena: std.mem.Allocator,
    th: theme.Theme,
    analysis_result: ?zfin.analysis.AnalysisResult,
    stock_pct: f64,
    bond_pct: f64,
    cash_pct: f64,
    total_value: f64,
    sector_granularity: zfin.analysis.Granularity,
    account_map: ?zfin.analysis.AccountMap,
) ![]const StyledLine {
    var lines: std.ArrayList(StyledLine) = .empty;

    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{ .text = "  Portfolio Analysis", .style = th.headerStyle() });
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

    const result = analysis_result orelse {
        try lines.append(arena, .{ .text = "  No analysis data. Ensure metadata.srf exists alongside portfolio.", .style = th.mutedStyle() });
        try lines.append(arena, .{ .text = "  Run: zfin enrich <portfolio.srf> > metadata.srf", .style = th.mutedStyle() });
        return lines.toOwnedSlice(arena);
    };

    // Equities / Fixed Income / Cash header summary. The Other
    // bucket (derivatives, real property) is excluded from this
    // summary but appears as its own row in the Asset Category
    // breakdown.
    if (stock_pct > 0 or bond_pct > 0 or cash_pct > 0) {
        try lines.append(arena, .{
            .text = try std.fmt.allocPrint(arena, "  Equities {d:.1}% ({f})  /  Fixed Income {d:.1}% ({f})  /  Cash {d:.1}% ({f})", .{
                stock_pct * 100,
                Money.from(stock_pct * total_value),
                bond_pct * 100,
                Money.from(bond_pct * total_value),
                cash_pct * 100,
                Money.from(cash_pct * total_value),
            }),
            .style = th.mutedStyle(),
        });
        try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    }

    const bar_width: usize = 30;
    const label_width: usize = 24;

    // Re-aggregate the Sector breakdown at the chosen granularity.
    // The arena allocator owns the result for this frame; freed
    // when the frame ends.
    const collapsed_sector = try zfin.analysis.collapseBreakdownAtGranularity(
        arena,
        result.sector,
        sector_granularity,
        total_value,
    );
    // Produce a per-frame copy of the result with the rebucketed
    // sector slice. Other fields are aliased; we don't free the
    // original sector slice — that lives on `state.result` and
    // gets freed at tab.deinit time.
    const display_result: zfin.analysis.AnalysisResult = .{
        .asset_category = result.asset_category,
        .sector = collapsed_sector,
        .geo = result.geo,
        .account = result.account,
        .tax_type = result.tax_type,
        .unclassified = result.unclassified,
        .total_value = result.total_value,
    };

    const sections = zfin.analysis.breakdownSections(&display_result);

    for (sections, 0..) |sec, si| {
        if (si > 0 and sec.items.len == 0) continue;
        if (si > 0) try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
        // Indent the title (renderer-level, not baked into the
        // section's title string). For the Sector section,
        // append the current granularity in parens so the user
        // knows what the `g` hot-key cycled to.
        const title_text = if (std.mem.eql(u8, sec.title, "Sector"))
            try std.fmt.allocPrint(arena, "  Sector ({s} — press 'm' to cycle)", .{granularityLabel(sector_granularity)})
        else
            try std.fmt.allocPrint(arena, "  {s}", .{sec.title});
        try lines.append(arena, .{ .text = title_text, .style = th.headerStyle() });
        try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
        for (sec.items) |item| {
            const text = try fmtBreakdownLine(arena, item, bar_width, label_width);
            try lines.append(arena, .{ .text = text, .style = th.contentStyle(), .alt_text = null, .alt_style = th.barFillStyle(), .alt_start = 2 + label_width + 1, .alt_end = 2 + label_width + 1 + bar_width });
        }
    }

    if (result.unclassified.len > 0) {
        try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
        try lines.append(arena, .{ .text = "  Unclassified (not in metadata.srf)", .style = th.warningStyle() });
        for (result.unclassified) |sym| {
            const text = try std.fmt.allocPrint(arena, "    {s}", .{sym});
            try lines.append(arena, .{ .text = text, .style = th.mutedStyle() });
        }
    }

    // Umbrella-insurance exposure block at the bottom (mirrors
    // the CLI's `display`). User scrolls to see it. Suppressed
    // when account_map is unavailable — the shielding decision
    // requires per-account tax_type info.
    if (account_map) |am| {
        try renderUmbrellaSection(arena, th, &lines, result.account, am);
    }

    return lines.toOwnedSlice(arena);
}

/// Append the Umbrella exposure block to `lines`. Mirrors
/// `commands/analysis.zig:printUmbrellaSection`.
pub fn renderUmbrellaSection(
    arena: std.mem.Allocator,
    th: theme.Theme,
    lines: *std.ArrayList(StyledLine),
    account_breakdown: []const zfin.analysis.BreakdownItem,
    account_map: zfin.analysis.AccountMap,
) !void {
    const umbrella = zfin.analysis.umbrellaExposure(account_breakdown, account_map);
    if (umbrella.total_liquid <= 0) return;

    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{ .text = "  Umbrella exposure", .style = th.headerStyle() });
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

    {
        const text = try std.fmt.allocPrint(arena, "    Total liquid:                              {f}", .{Money.from(umbrella.total_liquid)});
        try lines.append(arena, .{ .text = text, .style = th.contentStyle() });
    }
    {
        const text = try std.fmt.allocPrint(arena, "    Shielded (retirement accounts):            {f}", .{Money.from(umbrella.shielded_value)});
        try lines.append(arena, .{ .text = text, .style = th.contentStyle() });
    }
    {
        const text = try std.fmt.allocPrint(arena, "    Exposed (taxable + non-shielded pre-tax):  {f} ({d:.1}%)", .{ Money.from(umbrella.exposed_value), umbrella.exposed_pct * 100 });
        try lines.append(arena, .{ .text = text, .style = th.warningStyle() });
    }
    try lines.append(arena, .{ .text = "                                               ↑ approximate umbrella target", .style = th.mutedStyle() });
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{ .text = "    Note: IRA protections vary by state. Analysis assumes", .style = th.mutedStyle() });
    try lines.append(arena, .{ .text = "    protection. Override per-account using `shielded:bool:false`", .style = th.mutedStyle() });
    try lines.append(arena, .{ .text = "    in accounts.srf.", .style = th.mutedStyle() });
}

/// Display label for a granularity tier, used in the Sector
/// section title.
fn granularityLabel(g: zfin.analysis.Granularity) []const u8 {
    return switch (g) {
        .coarse => "coarse",
        .fine => "fine",
    };
}

pub fn fmtBreakdownLine(arena: std.mem.Allocator, item: zfin.analysis.BreakdownItem, bar_width: usize, label_width: usize) ![]const u8 {
    const pct = item.weight * 100.0;
    const bar = try buildBlockBar(arena, item.weight, bar_width);
    // Apply the shared sector-label abbreviation (so e.g. "Communication
    // Services" becomes "Comm. Services" — same rule the review tab
    // uses), then display-column-truncate to the cell width. Padding
    // to display columns (rather than byte length) keeps multibyte
    // sector names like "Comm. Services" aligned with neighbours.
    const abbrev = zfin.analysis.abbreviateSector(item.label);
    const trimmed = fmt.truncateToCols(abbrev, label_width);
    const have_cols = fmt.displayCols(trimmed);
    const pad_cols = if (have_cols < label_width) label_width - have_cols else 0;
    const padded_label = try arena.alloc(u8, trimmed.len + pad_cols);
    @memcpy(padded_label[0..trimmed.len], trimmed);
    if (pad_cols > 0) @memset(padded_label[trimmed.len..], ' ');
    return std.fmt.allocPrint(arena, "  {s} {s} {d:>5.1}%  {f}", .{
        padded_label, bar, pct, Money.from(item.value),
    });
}

/// Build a bar using Unicode block elements for sub-character precision.
/// Wraps fmt.buildBlockBar into arena-allocated memory.
pub fn buildBlockBar(arena: std.mem.Allocator, weight: f64, total_chars: usize) ![]const u8 {
    var buf: [256]u8 = undefined;
    const result = fmt.buildBlockBar(&buf, weight, total_chars);
    return arena.dupe(u8, result);
}

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

const testing = std.testing;

test "buildBlockBar empty" {
    const bar = try buildBlockBar(testing.allocator, 0, 10);
    defer testing.allocator.free(bar);
    // All spaces
    try testing.expectEqual(@as(usize, 10), bar.len);
    try testing.expectEqualStrings("          ", bar);
}

test "buildBlockBar full" {
    const bar = try buildBlockBar(testing.allocator, 1.0, 5);
    defer testing.allocator.free(bar);
    // 5 full blocks, each 3 bytes UTF-8 (█ = E2 96 88)
    try testing.expectEqual(@as(usize, 15), bar.len);
    // Verify first block is █
    try testing.expectEqualStrings("\xe2\x96\x88", bar[0..3]);
}

test "buildBlockBar partial" {
    const bar = try buildBlockBar(testing.allocator, 0.5, 10);
    defer testing.allocator.free(bar);
    // 50% of 10 chars = 5 full blocks (no partial)
    // 5 full blocks (15 bytes) + 5 spaces = 20 bytes
    try testing.expectEqual(@as(usize, 20), bar.len);
}

test "fmtBreakdownLine formats correctly" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const item = zfin.analysis.BreakdownItem{
        .label = "US Stock",
        .weight = 0.65,
        .value = 130000,
    };
    const line = try fmtBreakdownLine(arena, item, 10, 12);
    // Should contain the label, percentage, and dollar amount
    try testing.expect(std.mem.indexOf(u8, line, "US Stock") != null);
    try testing.expect(std.mem.indexOf(u8, line, "65.0%") != null);
    try testing.expect(std.mem.indexOf(u8, line, "$130,000") != null);
}

test "renderAnalysisLines with data" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const th = theme.default_theme;

    // Use sector breakdown (the main fine-grained slice) as the
    // populated section for this test. asset_class is gone — its
    // role is subsumed by the bucket-driven `sector` field.
    // GICS-style labels are stable through `bucketSector` and
    // are the natural shape for direct GICS-tagged equities.
    var sector = [_]zfin.analysis.BreakdownItem{
        .{ .label = "Technology", .weight = 0.60, .value = 120000 },
        .{ .label = "Healthcare", .weight = 0.40, .value = 80000 },
    };
    const result = zfin.analysis.AnalysisResult{
        .asset_category = &.{},
        .sector = &sector,
        .geo = &.{},
        .account = &.{},
        .tax_type = &.{},
        .unclassified = &.{},
        .total_value = 200000,
    };
    const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.15, 0.05, 200000, .fine, null);
    // Should have header section + sector items
    try testing.expect(lines.len >= 5);
    // Find "Portfolio Analysis" header
    var found_header = false;
    var found_cash_in_summary = false;
    for (lines) |l| {
        if (std.mem.indexOf(u8, l.text, "Portfolio Analysis") != null) found_header = true;
        if (std.mem.indexOf(u8, l.text, "Cash 5.0%") != null) found_cash_in_summary = true;
    }
    try testing.expect(found_header);
    try testing.expect(found_cash_in_summary);
    // Find sector breakdown data
    var found_tech = false;
    for (lines) |l| {
        if (std.mem.indexOf(u8, l.text, "Technology") != null) found_tech = true;
    }
    try testing.expect(found_tech);
}

test "renderAnalysisLines no data" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const th = theme.default_theme;

    const lines = try renderAnalysisLines(arena, th, null, 0, 0, 0, 0, .fine, null);
    try testing.expectEqual(@as(usize, 5), lines.len);
    try testing.expect(std.mem.indexOf(u8, lines[3].text, "No analysis data") != null);
}

test "tab.init produces zero-defaulted state" {
    var state: State = undefined;
    var dummy_app: tui.App = undefined; // intentionally undefined: init
    // for analysis doesn't touch app fields.

    try tab.init(&state, &dummy_app);
    try testing.expectEqual(false, state.loaded);
    try testing.expect(state.result == null);
    // classification_map lives on PortfolioData now (not on tab state).
    // Default sector granularity is fine (matches CLI default).
    try testing.expectEqual(zfin.analysis.Granularity.fine, state.sector_granularity);
}

test "onPortfolioReload clears state without eager rebuild" {
    // Regression for the use-after-free crash where eager-rebuild
    // inside onPortfolioReload stored borrowed pointers from the
    // about-to-be-freed portfolio arena. Contract: this hook
    // MUST clear state and set loaded=false, but MUST NOT call
    // activate() or otherwise rebuild from app.portfolio (which
    // is about to be reset by pd.reload).
    //
    // We can't easily verify "no eager rebuild" mechanically
    // without a full App harness, but we CAN verify the
    // post-conditions are exactly state-cleared.
    var state: State = .{
        .loaded = true,
        // result stays null — non-null would need allocator to free.
    };
    var dummy_app: tui.App = undefined; // not touched when result is null

    tab.onPortfolioReload(&state, &dummy_app);

    try testing.expectEqual(false, state.loaded);
    try testing.expect(state.result == null);
}

test "handleAction toggles sector granularity fine ↔ coarse" {
    var state: State = .{};
    var dummy_app: tui.App = undefined; // handleAction doesn't touch app

    // Default is fine.
    try testing.expectEqual(zfin.analysis.Granularity.fine, state.sector_granularity);
    // fine → coarse
    tab.handleAction(&state, &dummy_app, .cycle_sector_granularity);
    try testing.expectEqual(zfin.analysis.Granularity.coarse, state.sector_granularity);
    // coarse → fine (full cycle)
    tab.handleAction(&state, &dummy_app, .cycle_sector_granularity);
    try testing.expectEqual(zfin.analysis.Granularity.fine, state.sector_granularity);
}

test "renderAnalysisLines: granularity label appears in Sector section title" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const th = theme.default_theme;

    var sector = [_]zfin.analysis.BreakdownItem{
        .{ .label = "Equity / Corporate", .weight = 0.80, .value = 80_000 },
        .{ .label = "Debt / Corporate", .weight = 0.20, .value = 20_000 },
    };
    const result = zfin.analysis.AnalysisResult{
        .asset_category = &.{},
        .sector = &sector,
        .geo = &.{},
        .account = &.{},
        .tax_type = &.{},
        .unclassified = &.{},
        .total_value = 100_000,
    };

    // fine label
    {
        const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.20, 0.0, 100_000, .fine, null);
        var found_fine = false;
        for (lines) |l| {
            if (std.mem.indexOf(u8, l.text, "Sector (fine") != null) found_fine = true;
        }
        try testing.expect(found_fine);
    }
    // coarse label
    {
        const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.20, 0.0, 100_000, .coarse, null);
        var found_coarse = false;
        for (lines) |l| {
            if (std.mem.indexOf(u8, l.text, "Sector (coarse") != null) found_coarse = true;
        }
        try testing.expect(found_coarse);
    }
}

test "renderAnalysisLines: umbrella section appears at the bottom when account_map provided" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const th = theme.default_theme;

    var account = [_]zfin.analysis.BreakdownItem{
        .{ .label = "Sample IRA", .weight = 0.60, .value = 60_000 },
        .{ .label = "Sample Brokerage", .weight = 0.40, .value = 40_000 },
    };
    const result = zfin.analysis.AnalysisResult{
        .asset_category = &.{},
        .sector = &.{},
        .geo = &.{},
        .account = &account,
        .tax_type = &.{},
        .unclassified = &.{},
        .total_value = 100_000,
    };

    var am = try zfin.analysis.parseAccountsFile(testing.allocator,
        \\#!srfv1
        \\account::Sample IRA,tax_type::traditional
        \\account::Sample Brokerage,tax_type::taxable
    );
    defer am.deinit();

    const lines = try renderAnalysisLines(arena, th, result, 0.80, 0.0, 0.0, 100_000, .fine, am);

    var found_header = false;
    var found_total_liquid = false;
    var found_shielded = false;
    var found_exposed = false;
    var found_note = false;
    for (lines) |l| {
        if (std.mem.indexOf(u8, l.text, "Umbrella exposure") != null) found_header = true;
        if (std.mem.indexOf(u8, l.text, "Total liquid") != null) found_total_liquid = true;
        if (std.mem.indexOf(u8, l.text, "Shielded") != null and std.mem.indexOf(u8, l.text, "$60,000") != null) found_shielded = true;
        if (std.mem.indexOf(u8, l.text, "Exposed") != null and std.mem.indexOf(u8, l.text, "40.0%") != null) found_exposed = true;
        if (std.mem.indexOf(u8, l.text, "IRA protections vary by state") != null) found_note = true;
    }
    try testing.expect(found_header);
    try testing.expect(found_total_liquid);
    try testing.expect(found_shielded);
    try testing.expect(found_exposed);
    try testing.expect(found_note);
}

test "renderAnalysisLines: umbrella section absent when account_map is null" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const th = theme.default_theme;

    var account = [_]zfin.analysis.BreakdownItem{
        .{ .label = "Sample IRA", .weight = 1.0, .value = 100_000 },
    };
    const result = zfin.analysis.AnalysisResult{
        .asset_category = &.{},
        .sector = &.{},
        .geo = &.{},
        .account = &account,
        .tax_type = &.{},
        .unclassified = &.{},
        .total_value = 100_000,
    };

    const lines = try renderAnalysisLines(arena, th, result, 1.0, 0, 0, 100_000, .fine, null);

    for (lines) |l| {
        try testing.expect(std.mem.indexOf(u8, l.text, "Umbrella exposure") == null);
    }
}

test "renderAnalysisLines: umbrella respects shielded:bool:false override (DCP case)" {
    // The load-bearing test for the user's DCP scenario: a
    // pre-tax account with `tax_type::traditional` that's NOT
    // ERISA-shielded. Override flips it from shielded → exposed.
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const th = theme.default_theme;

    var account = [_]zfin.analysis.BreakdownItem{
        .{ .label = "Sample IRA", .weight = 0.40, .value = 1_000_000 },
        .{ .label = "Sample DCP", .weight = 0.60, .value = 1_500_000 },
    };
    const result = zfin.analysis.AnalysisResult{
        .asset_category = &.{},
        .sector = &.{},
        .geo = &.{},
        .account = &account,
        .tax_type = &.{},
        .unclassified = &.{},
        .total_value = 2_500_000,
    };

    var am = try zfin.analysis.parseAccountsFile(testing.allocator,
        \\#!srfv1
        \\account::Sample IRA,tax_type::traditional
        \\account::Sample DCP,tax_type::traditional,shielded:bool:false
    );
    defer am.deinit();

    const lines = try renderAnalysisLines(arena, th, result, 1.0, 0, 0, 2_500_000, .fine, am);

    // IRA shielded ($1M); DCP exposed ($1.5M). Total $2.5M.
    var found_shielded_1m = false;
    var found_exposed_1_5m = false;
    var found_60_pct = false;
    for (lines) |l| {
        if (std.mem.indexOf(u8, l.text, "Shielded") != null and std.mem.indexOf(u8, l.text, "$1,000,000") != null) found_shielded_1m = true;
        if (std.mem.indexOf(u8, l.text, "Exposed") != null and std.mem.indexOf(u8, l.text, "$1,500,000") != null) found_exposed_1_5m = true;
        if (std.mem.indexOf(u8, l.text, "60.0%") != null) found_60_pct = true;
    }
    try testing.expect(found_shielded_1m);
    try testing.expect(found_exposed_1_5m);
    try testing.expect(found_60_pct);
}
