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

const App = tui.App;
const StyledLine = tui.StyledLine;
const glyph = tui.glyph;

/// Per-symbol chart state for the quote tab. Tracks the active
/// timeframe, transmitted Kitty image (when supported), cached
/// indicator overlays (SMA/Bollinger/etc), and last-rendered
/// data fingerprints used to decide when to re-render.
pub const ChartState = struct {
    timeframe: chart.Timeframe = .@"1Y",
    image_id: ?u32 = null, // currently transmitted Kitty image ID
    image_width: u16 = 0, // image width in cells
    image_height: u16 = 0, // image height in cells
    // SAFETY: paired with `symbol_len`; only `symbol[0..symbol_len]` is read.
    symbol: [16]u8 = undefined, // symbol the chart was rendered for
    symbol_len: usize = 0,
    timeframe_rendered: ?chart.Timeframe = null, // timeframe the chart was rendered for
    timeframe_row: ?usize = null, // screen row of the timeframe selector (for mouse clicks)
    dirty: bool = true, // needs re-render
    price_min: f64 = 0,
    price_max: f64 = 0,
    rsi_latest: ?f64 = null,

    // Cached indicator data (persists across frames to avoid recomputation)
    cached_indicators: ?chart.CachedIndicators = null,
    cache_candle_count: usize = 0, // candle count when cache was computed
    cache_timeframe: ?chart.Timeframe = null, // timeframe when cache was computed
    cache_last_close: f64 = 0, // last candle's close when cache was computed

    /// Free cached indicator memory.
    pub fn freeCache(self: *ChartState, alloc: std.mem.Allocator) void {
        if (self.cached_indicators) |*cache| {
            cache.deinit(alloc);
            self.cached_indicators = null;
        }
        self.cache_candle_count = 0;
        self.cache_timeframe = null;
        self.cache_last_close = 0;
    }

    /// Check if cache is valid for the given candle data and timeframe.
    pub fn isCacheValid(self: *const ChartState, candles: []const zfin.Candle, timeframe: chart.Timeframe) bool {
        if (self.cached_indicators == null) return false;
        if (self.cache_timeframe == null or self.cache_timeframe.? != timeframe) return false;

        // Slice candles to timeframe (same logic as renderChart)
        const max_days = timeframe.tradingDays();
        const n = @min(candles.len, max_days);
        const data = candles[candles.len - n ..];

        if (data.len != self.cache_candle_count) return false;
        if (data.len == 0) return false;

        // Check if last close changed (detects data refresh)
        const last_close = data[data.len - 1].close;
        if (@abs(last_close - self.cache_last_close) > 0.0001) return false;

        return true;
    }
};

// ── Tab-local action enum ─────────────────────────────────────
//
// Quote tab cycles the chart timeframe with `[` and `]` (chart-
// next / chart-prev). These bindings only fire on the quote tab
// today; the surrounding `if (active_tab == .quote)` gate will
// disappear when scoped keymaps land in step 3.

pub const Action = enum {
    chart_timeframe_next,
    chart_timeframe_prev,
};

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

pub const State = struct {
    /// Stored real-time quote (only fetched on manual refresh; not
    /// auto-refetched on every redraw).
    live: ?zfin.Quote = null,
    /// Unix-epoch seconds for the live-quote fetch — drives the
    /// "data Xs ago" header readout.
    timestamp: i64 = 0,
    /// Pixel-chart state (Kitty graphics + Bollinger bands +
    /// indicator cache + timeframe selection). Lives here because
    /// only the quote tab uses it; perf renders its own braille
    /// chart from `app.symbol_data.candles` directly.
    chart: ChartState = .{},
};

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

pub const meta: framework.TabMeta(Action) = .{
    .label = "Quote",
    .default_bindings = &.{
        .{ .action = .chart_timeframe_next, .key = .{ .codepoint = ']' } },
        .{ .action = .chart_timeframe_prev, .key = .{ .codepoint = '[' } },
    },
    .action_labels = std.enums.EnumArray(Action, []const u8).init(.{
        .chart_timeframe_next = "Chart: next timeframe",
        .chart_timeframe_prev = "Chart: previous timeframe",
    }),
    .status_hints = &.{
        .chart_timeframe_next,
    },
};

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 {
        state.chart.freeCache(app.allocator);
        state.* = .{};
    }

    /// Quote loads its own data on activation (the live-quote
    /// fetch path lives in tui.zig after the tab switches because
    /// it depends on App.svc); no-op here. Chart redraws are
    /// triggered by the dirty flag on `state.chart`.
    /// Quote and performance share `app.symbol_data` (candles +
    /// dividends). Performance owns the loader; quote piggybacks
    /// by delegating its activate to performance's. This keeps
    /// `loadTabData`'s dispatch uniform — every tab activates its
    /// own state — while preserving the historical "switching to
    /// quote populates shared candle data" behavior.
    pub fn activate(state: *State, app: *App) !void {
        _ = state;
        const perf_module = @import("performance_tab.zig");
        try perf_module.tab.activate(&app.states.performance, app);
    }

    pub const deactivate = framework.noopDeactivate(State);

    /// Refresh: delegate to performance.reload, which owns the
    /// shared candle/dividend data and svc invalidation. Quote's
    /// chart-state (dirty + freeCache) is also reset by
    /// performance.reload — see the comment there for why.
    /// Quote-only state (live quote + timestamp) is reset here
    /// because performance doesn't know about it.
    pub fn reload(state: *State, app: *App) !void {
        state.live = null;
        state.timestamp = 0;
        const perf_module = @import("performance_tab.zig");
        try perf_module.tab.reload(&app.states.performance, app);
    }

    pub const tick = framework.noopTick(State);

    pub fn handleAction(state: *State, app: *App, action: Action) void {
        switch (action) {
            .chart_timeframe_next => {
                state.chart.timeframe = state.chart.timeframe.next();
                state.chart.dirty = true;
                app.setStatus(state.chart.timeframe.label());
            },
            .chart_timeframe_prev => {
                state.chart.timeframe = state.chart.timeframe.prev();
                state.chart.dirty = true;
                app.setStatus(state.chart.timeframe.label());
            },
        }
    }

    /// Mouse handling: clicks on the timeframe selector row switch
    /// the chart timeframe. Returns `true` if the click was on a
    /// timeframe label (consumed); `false` otherwise (unhandled).
    /// The caller (App's mouse dispatcher) handles wheel scroll,
    /// tab-bar clicks, and other global mouse semantics before
    /// routing here.
    pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool {
        if (mouse.button != .left) return false;
        if (mouse.type != .press) return false;
        const tf_row = state.chart.timeframe_row orelse return false;
        const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset;
        if (content_row != tf_row) return false;

        // Layout: "  Chart: [6M]  YTD  1Y  3Y  5Y   ([ ] to change)"
        // Prefix "  Chart: " is 9 chars. Each timeframe label takes
        // `label_len + 2` (brackets/spaces around the label) + 1 (gap).
        const col: usize = @intCast(mouse.col);
        const prefix_len: usize = 9;
        if (col < prefix_len) return false;

        const timeframes = [_]chart.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
        var x: usize = prefix_len;
        for (timeframes) |tf| {
            const lbl_len = tf.label().len;
            const slot_width = lbl_len + 2 + 1;
            if (col >= x and col < x + slot_width) {
                if (tf != state.chart.timeframe) {
                    state.chart.timeframe = tf;
                    app.setStatus(tf.label());
                }
                return true;
            }
            x += slot_width;
        }
        return false;
    }

    pub fn isDisabled(app: *App) bool {
        _ = app;
        return false;
    }

    /// Symbol-change reset. Drops the live quote, resets the
    /// fetch timestamp, marks the chart dirty so the next draw
    /// re-renders for the new symbol, and frees the indicator
    /// cache (Bollinger bands etc. are computed per-symbol).
    /// The candle data lives on `app.symbol_data` and is dropped
    /// centrally by the App.
    pub fn onSymbolChange(state: *State, app: *App) void {
        state.live = null;
        state.timestamp = 0;
        state.chart.dirty = true;
        state.chart.freeCache(app.allocator);
    }
};

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

/// Draw the quote tab content. Uses Kitty graphics for the chart when available,
/// falling back to braille sparkline otherwise.
pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
    _ = state;

    // Determine whether to use Kitty graphics
    const use_kitty = switch (app.chart_config.mode) {
        .braille => false,
        .kitty => true,
        .auto => if (app.vx_app) |va| va.vx.caps.kitty_graphics else false,
    };

    if (use_kitty and app.symbol_data.candles != null and app.symbol_data.candles.?.len >= 40) {
        drawWithKittyChart(app, arena, buf, width, height) catch {
            // On any failure, fall back to braille
            try app.drawStyledContent(arena, buf, width, height, try buildStyledLines(app, arena));
        };
    } else {
        // Fallback to styled lines with braille chart
        try app.drawStyledContent(arena, buf, width, height, try buildStyledLines(app, arena));
    }
}

/// Draw quote tab using Kitty graphics protocol for the chart.
fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
    const th = app.theme;
    const c = app.symbol_data.candles orelse return;

    // Build text header (symbol, price, change) — first few lines
    var lines: std.ArrayList(StyledLine) = .empty;
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

    // Symbol (+ resolved name) label, shared by the live and
    // close-of-day header variants below.
    const sym_label: []const u8 = if (quoteTabName(app)) |n|
        try std.fmt.allocPrint(arena, "{s}  {s}", .{ app.symbol, n })
    else
        app.symbol;

    // Symbol + price header
    if (app.states.quote.live) |q| {
        const price_str = try std.fmt.allocPrint(arena, "  {s}  ${d:.2}", .{ sym_label, q.close });
        try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
        if (q.previous_close > 0) {
            const change = q.close - q.previous_close;
            const pct = (change / q.previous_close) * 100.0;
            var chg_buf: [64]u8 = undefined;
            const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
            try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "  Change:  {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), .style = change_style });
        }
    } else if (c.len > 0) {
        const last = c[c.len - 1];
        const price_str = try std.fmt.allocPrint(arena, "  {s}  ${d:.2}  (close)", .{ sym_label, last.close });
        try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
        if (c.len >= 2) {
            const prev_close = c[c.len - 2].close;
            const change = last.close - prev_close;
            const pct = (change / prev_close) * 100.0;
            var chg_buf: [64]u8 = undefined;
            const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
            try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "  Change:  {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), .style = change_style });
        }
    }

    // Timeframe selector line
    {
        var tf_buf: [80]u8 = undefined;
        var tf_pos: usize = 0;
        const prefix = "  Chart: ";
        @memcpy(tf_buf[tf_pos..][0..prefix.len], prefix);
        tf_pos += prefix.len;
        const timeframes = [_]chart.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
        for (timeframes) |tf| {
            const lbl = tf.label();
            if (tf == app.states.quote.chart.timeframe) {
                tf_buf[tf_pos] = '[';
                tf_pos += 1;
                @memcpy(tf_buf[tf_pos..][0..lbl.len], lbl);
                tf_pos += lbl.len;
                tf_buf[tf_pos] = ']';
                tf_pos += 1;
            } else {
                tf_buf[tf_pos] = ' ';
                tf_pos += 1;
                @memcpy(tf_buf[tf_pos..][0..lbl.len], lbl);
                tf_pos += lbl.len;
                tf_buf[tf_pos] = ' ';
                tf_pos += 1;
            }
            tf_buf[tf_pos] = ' ';
            tf_pos += 1;
        }
        const hint = "  ([ ] to change)";
        @memcpy(tf_buf[tf_pos..][0..hint.len], hint);
        tf_pos += hint.len;
        app.states.quote.chart.timeframe_row = lines.items.len; // track which row the timeframe line is on
        try lines.append(arena, .{ .text = try arena.dupe(u8, tf_buf[0..tf_pos]), .style = th.mutedStyle() });
    }

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

    // Draw the text header
    const header_lines = try lines.toOwnedSlice(arena);
    try app.drawStyledContent(arena, buf, width, height, header_lines);

    // Calculate chart area (below the header, leaving room for details below)
    const header_rows: u16 = @intCast(@min(header_lines.len, height));
    const detail_rows: u16 = 10; // reserve rows for quote details below chart
    const chart_rows = height -| header_rows -| detail_rows;
    if (chart_rows < 8) return; // not enough space

    // Compute pixel dimensions from cell size
    // cell_size may be 0 if terminal hasn't reported pixel dimensions yet
    const cell_size = app.cellPixelSize();
    const cell_w: u32 = cell_size.width;
    const cell_h: u32 = cell_size.height;
    const label_cols: u16 = 10; // columns reserved for axis labels on the right
    const chart_cols = width -| 2 -| label_cols; // 1 col left margin + label area on right
    if (chart_cols == 0) return;
    const px_w: u32 = @as(u32, chart_cols) * cell_w;
    const px_h: u32 = @as(u32, chart_rows) * cell_h;

    if (px_w < 100 or px_h < 100) return;
    // Apply resolution cap from chart config
    const capped_w = @min(px_w, app.chart_config.max_width);
    const capped_h = @min(px_h, app.chart_config.max_height);

    // Check if we need to re-render the chart image
    const symbol_changed = app.states.quote.chart.symbol_len != app.symbol.len or
        !std.mem.eql(u8, app.states.quote.chart.symbol[0..app.states.quote.chart.symbol_len], app.symbol);
    const tf_changed = app.states.quote.chart.timeframe_rendered == null or app.states.quote.chart.timeframe_rendered.? != app.states.quote.chart.timeframe;

    if (app.states.quote.chart.dirty or symbol_changed or tf_changed) {
        // Free old image
        if (app.states.quote.chart.image_id) |old_id| {
            if (app.vx_app) |va| {
                va.vx.freeImage(va.tty.writer(), old_id);
            }
            app.states.quote.chart.image_id = null;
        }

        // If symbol changed, invalidate the indicator cache
        if (symbol_changed) {
            app.states.quote.chart.freeCache(app.allocator);
        }

        // Check if we can reuse cached indicators
        const cache_valid = app.states.quote.chart.isCacheValid(c, app.states.quote.chart.timeframe);

        // If cache is invalid, compute new indicators
        if (!cache_valid) {
            // Free old cache if it exists
            app.states.quote.chart.freeCache(app.allocator);

            // Compute and cache new indicators
            const new_cache = chart.computeIndicators(
                app.allocator,
                c,
                app.states.quote.chart.timeframe,
            ) catch |err| {
                app.states.quote.chart.dirty = false;
                var err_buf: [128]u8 = undefined;
                const msg = std.fmt.bufPrint(&err_buf, "Indicator computation failed: {s}", .{@errorName(err)}) catch "Indicator computation failed";
                app.setStatus(msg);
                return;
            };
            app.states.quote.chart.cached_indicators = new_cache;

            // Update cache metadata
            const max_days = app.states.quote.chart.timeframe.tradingDays();
            const n = @min(c.len, max_days);
            const data = c[c.len - n ..];
            app.states.quote.chart.cache_candle_count = data.len;
            app.states.quote.chart.cache_timeframe = app.states.quote.chart.timeframe;
            app.states.quote.chart.cache_last_close = if (data.len > 0) data[data.len - 1].close else 0;
        }

        // Render and transmit — use the app's main allocator, NOT the arena,
        // because z2d allocates large pixel buffers that would bloat the arena.
        if (app.vx_app) |va| {
            // Pass cached indicators to avoid recomputation during rendering
            const cached_ptr: ?*const chart.CachedIndicators = if (app.states.quote.chart.cached_indicators) |*ci| ci else null;

            const chart_result = chart.renderChart(
                app.io,
                app.allocator,
                c,
                app.states.quote.chart.timeframe,
                capped_w,
                capped_h,
                th,
                cached_ptr,
            ) catch |err| {
                app.states.quote.chart.dirty = false;
                var err_buf: [128]u8 = undefined;
                const msg = std.fmt.bufPrint(&err_buf, "Chart render failed: {s}", .{@errorName(err)}) catch "Chart render failed";
                app.setStatus(msg);
                return;
            };
            defer app.allocator.free(chart_result.rgb_data);

            // Base64-encode and transmit raw RGB data directly via Kitty protocol.
            // This avoids the PNG encode → file write → file read → PNG decode roundtrip.
            const base64_enc = std.base64.standard.Encoder;
            const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch {
                app.states.quote.chart.dirty = false;
                app.setStatus("Chart: base64 alloc failed");
                return;
            };
            defer app.allocator.free(b64_buf);
            const encoded = base64_enc.encode(b64_buf, chart_result.rgb_data);

            const img = va.vx.transmitPreEncodedImage(
                va.tty.writer(),
                encoded,
                chart_result.width,
                chart_result.height,
                .rgb,
            ) catch |err| {
                app.states.quote.chart.dirty = false;
                var err_buf: [128]u8 = undefined;
                const msg = std.fmt.bufPrint(&err_buf, "Image transmit failed: {s}", .{@errorName(err)}) catch "Image transmit failed";
                app.setStatus(msg);
                return;
            };

            app.states.quote.chart.image_id = img.id;
            app.states.quote.chart.image_width = @intCast(chart_cols);
            app.states.quote.chart.image_height = chart_rows;

            // Track what we rendered
            const sym_len = @min(app.symbol.len, 16);
            @memcpy(app.states.quote.chart.symbol[0..sym_len], app.symbol[0..sym_len]);
            app.states.quote.chart.symbol_len = sym_len;
            app.states.quote.chart.timeframe_rendered = app.states.quote.chart.timeframe;
            app.states.quote.chart.price_min = chart_result.price_min;
            app.states.quote.chart.price_max = chart_result.price_max;
            app.states.quote.chart.rsi_latest = chart_result.rsi_latest;
            app.states.quote.chart.dirty = false;
        }
    }

    // Place the image in the cell buffer
    if (app.states.quote.chart.image_id) |img_id| {
        // Place image at the first cell of the chart area
        const chart_row_start: usize = header_rows;
        const chart_col_start: usize = 1; // 1 col left margin
        const buf_idx = chart_row_start * @as(usize, width) + chart_col_start;
        if (buf_idx < buf.len) {
            buf[buf_idx] = .{
                .char = .{ .grapheme = " " },
                .style = th.contentStyle(),
                .image = .{
                    .img_id = img_id,
                    .options = .{
                        .size = .{
                            .rows = app.states.quote.chart.image_height,
                            .cols = app.states.quote.chart.image_width,
                        },
                        .scale = .contain,
                    },
                },
            };
        }

        // ── Axis labels (terminal text in the right margin) ───────────
        // The chart image uses layout fractions: price=72%, gap=8%, RSI=20%
        // Map these to terminal rows to position labels.
        const img_rows = app.states.quote.chart.image_height;
        const label_col: usize = @as(usize, chart_col_start) + @as(usize, app.states.quote.chart.image_width) + 1;
        const label_style = th.mutedStyle();

        if (label_col + 8 <= width and img_rows >= 4 and app.states.quote.chart.price_max > app.states.quote.chart.price_min) {
            // Price axis labels — evenly spaced across the price panel (top 72%)
            const price_panel_rows = @as(f64, @floatFromInt(img_rows)) * 0.72;
            const n_price_labels: usize = 5;
            for (0..n_price_labels) |i| {
                const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_price_labels - 1));
                const price_val = app.states.quote.chart.price_max - frac * (app.states.quote.chart.price_max - app.states.quote.chart.price_min);
                const row_f = @as(f64, @floatFromInt(chart_row_start)) + frac * price_panel_rows;
                const row: usize = @intFromFloat(@round(row_f));
                if (row >= height) continue;

                var lbl_buf: [16]u8 = undefined;
                const lbl = std.fmt.bufPrint(&lbl_buf, "{f}", .{Money.from(price_val)}) catch "$?";
                const start_idx = row * @as(usize, width) + label_col;
                for (lbl, 0..) |ch, ci| {
                    const idx = start_idx + ci;
                    if (idx < buf.len and label_col + ci < width) {
                        buf[idx] = .{
                            .char = .{ .grapheme = glyph(ch) },
                            .style = label_style,
                        };
                    }
                }
            }

            // RSI axis labels — positioned within the RSI panel (bottom 20%, after 80% offset)
            const rsi_panel_start_f = @as(f64, @floatFromInt(img_rows)) * 0.80;
            const rsi_panel_h = @as(f64, @floatFromInt(img_rows)) * 0.20;
            const rsi_labels = [_]struct { val: f64, label: []const u8 }{
                .{ .val = 70, .label = "70" },
                .{ .val = 50, .label = "50" },
                .{ .val = 30, .label = "30" },
            };
            for (rsi_labels) |rl| {
                // RSI maps 0-100 top-to-bottom within the RSI panel
                const rsi_frac = 1.0 - (rl.val / 100.0);
                const row_f = @as(f64, @floatFromInt(chart_row_start)) + rsi_panel_start_f + rsi_frac * rsi_panel_h;
                const row: usize = @intFromFloat(@round(row_f));
                if (row >= height) continue;

                const start_idx = row * @as(usize, width) + label_col;
                for (rl.label, 0..) |ch, ci| {
                    const idx = start_idx + ci;
                    if (idx < buf.len and label_col + ci < width) {
                        buf[idx] = .{
                            .char = .{ .grapheme = glyph(ch) },
                            .style = label_style,
                        };
                    }
                }
            }
        }

        // Render quote details below the chart image as styled text
        const detail_start_row = header_rows + app.states.quote.chart.image_height;
        if (detail_start_row + 8 < height) {
            var detail_lines: std.ArrayList(StyledLine) = .empty;
            try detail_lines.append(arena, .{ .text = "", .style = th.contentStyle() });

            const latest = c[c.len - 1];
            const quote_data = app.states.quote.live;
            const price = if (quote_data) |q| q.close else latest.close;
            const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0);

            try buildDetailColumns(app, arena, &detail_lines, latest, quote_data, price, prev_close);

            // Write detail lines into the buffer below the image
            const detail_buf_start = detail_start_row * @as(usize, width);
            const remaining_height = height - @as(u16, @intCast(detail_start_row));
            const detail_slice = try detail_lines.toOwnedSlice(arena);
            if (detail_buf_start < buf.len) {
                try app.drawStyledContent(arena, buf[detail_buf_start..], width, remaining_height, detail_slice);
            }
        }
    }
}

/// Source-of-data hint shown in the quote tab's header line.
/// Determines which sub-form of the header is rendered.
pub const QuoteHeaderSource = union(enum) {
    /// Live quote with a "refreshed Xs ago" suffix.
    live: []const u8,
    /// Close-of-day data with a date.
    close: zfin.Date,
    /// No timing info — just the symbol.
    none,
};

/// Format the quote tab's header line. Pure function over
/// (arena, symbol, name, source). The three branches mirror the
/// live / close-of-day / no-data paths in the live builder. When
/// `name` is non-null and non-empty, it's rendered between the
/// symbol and the timing suffix (e.g. "AAPL  Apple Inc.  (live …)").
pub fn formatQuoteHeader(
    arena: std.mem.Allocator,
    symbol: []const u8,
    name: ?[]const u8,
    source: QuoteHeaderSource,
) ![]const u8 {
    const name_part: []const u8 = if (name) |n|
        (if (n.len > 0) try std.fmt.allocPrint(arena, "  {s}", .{n}) else "")
    else
        "";
    return switch (source) {
        .live => |ago| std.fmt.allocPrint(arena, "  {s}{s}  (live, ~15 min delay, refreshed {s})", .{ symbol, name_part, ago }),
        .close => |date| std.fmt.allocPrint(arena, "  {s}{s}  (as of close on {f})", .{ symbol, name_part, date }),
        .none => std.fmt.allocPrint(arena, "  {s}{s}", .{ symbol, name_part }),
    };
}

/// Resolve the active symbol's display name using the shared
/// policy (metadata.srf `name::`, then the ETF profile's fund
/// name). Mirrors the 'K' overlay and the CLI `quote` command, so
/// the three surfaces always agree on the name they show. The ETF
/// profile is loaded lazily on the performance tab, so the fallback
/// only kicks in once that data is present; the metadata name is
/// always available when enriched.
fn quoteTabName(app: *App) ?[]const u8 {
    const cm = app.portfolio.classificationMap();
    const fallback: ?[]const u8 = if (app.symbol_data.etf_profile) |p| p.name else null;
    return zfin.classification.resolveSecurityName(app.symbol, cm, fallback);
}

fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
    const th = app.theme;
    var lines: std.ArrayList(StyledLine) = .empty;

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

    if (app.symbol.len == 0) {
        try lines.append(arena, .{ .text = "  No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
        return lines.toOwnedSlice(arena);
    }

    var ago_buf: [16]u8 = undefined;
    const name = quoteTabName(app);
    if (app.states.quote.live != null and app.states.quote.timestamp > 0) {
        // wall-clock required: per-frame "now" for the data-age readout.
        const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds();
        const ago_str = fmt.fmtTimeAgo(&ago_buf, app.states.quote.timestamp, now_s);
        try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, name, .{ .live = ago_str }), .style = th.headerStyle() });
    } else if (app.symbol_data.candleLastDate()) |d| {
        try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, name, .{ .close = d }), .style = th.headerStyle() });
    } else {
        try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, name, .none), .style = th.headerStyle() });
    }
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

    // Use stored real-time quote if available (fetched on manual refresh)
    const quote_data = app.states.quote.live;

    const c = app.symbol_data.candles orelse {
        if (quote_data) |q| {
            // No candle data but have a quote - show it
            try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "  Price:   {f}", .{Money.from(q.close)}), .style = th.contentStyle() });
            {
                var chg_buf: [64]u8 = undefined;
                const change_style = if (q.change >= 0) th.positiveStyle() else th.negativeStyle();
                try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "  Change:  {s}", .{fmt.fmtPriceChange(&chg_buf, q.change, q.percent_change)}), .style = change_style });
            }
            return lines.toOwnedSlice(arena);
        }
        try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "  No data. Run: zfin perf {s}", .{app.symbol}), .style = th.mutedStyle() });
        return lines.toOwnedSlice(arena);
    };
    if (c.len == 0) {
        try lines.append(arena, .{ .text = "  No candle data.", .style = th.mutedStyle() });
        return lines.toOwnedSlice(arena);
    }

    // Use real-time quote price if available, otherwise latest candle
    const price = if (quote_data) |q| q.close else c[c.len - 1].close;
    const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0);
    const latest = c[c.len - 1];

    try buildDetailColumns(app, arena, &lines, latest, quote_data, price, prev_close);

    // Braille sparkline chart of recent 60 trading days
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    const chart_days: usize = @min(c.len, 60);
    const chart_data = c[c.len - chart_days ..];
    try tui.renderBrailleToStyledLines(arena, &lines, chart_data, th);

    // Recent history table
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{ .text = "  Recent History:", .style = th.headerStyle() });
    try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "  {s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}", .{ "Date", "Open", "High", "Low", "Close", "Volume" }), .style = th.mutedStyle() });

    const start_idx = if (c.len > 20) c.len - 20 else 0;
    for (c[start_idx..]) |candle| {
        var row_buf: [128]u8 = undefined;
        const day_change = if (candle.close >= candle.open) th.positiveStyle() else th.negativeStyle();
        try lines.append(arena, .{ .text = try arena.dupe(u8, fmt.fmtCandleRow(&row_buf, candle)), .style = day_change });
    }

    return lines.toOwnedSlice(arena);
}

// ── Quote detail columns (price/OHLCV | ETF stats | sectors | holdings) ──

const Column = struct {
    texts: std.ArrayList([]const u8),
    styles: std.ArrayList(vaxis.Style),
    width: usize, // fixed column width for padding

    fn init() Column {
        return .{
            .texts = .empty,
            .styles = .empty,
            .width = 0,
        };
    }

    fn add(app: *Column, arena: std.mem.Allocator, text: []const u8, style: vaxis.Style) !void {
        try app.texts.append(arena, text);
        try app.styles.append(arena, style);
    }

    fn len(app: *const Column) usize {
        return app.texts.items.len;
    }
};

fn buildDetailColumns(
    app: *App,
    arena: std.mem.Allocator,
    lines: *std.ArrayList(StyledLine),
    latest: zfin.Candle,
    quote_data: ?zfin.Quote,
    price: f64,
    prev_close: f64,
) !void {
    const th = app.theme;
    var vol_buf: [32]u8 = undefined;

    // Column 1: Price/OHLCV
    var col1 = Column.init();
    col1.width = 30;
    try col1.add(arena, try std.fmt.allocPrint(arena, "  Date:    {f}", .{latest.date}), th.contentStyle());
    try col1.add(arena, try std.fmt.allocPrint(arena, "  Price:   {f}", .{Money.from(price)}), th.contentStyle());
    try col1.add(arena, try std.fmt.allocPrint(arena, "  Open:    ${d:.2}", .{if (quote_data) |q| q.open else latest.open}), th.mutedStyle());
    try col1.add(arena, try std.fmt.allocPrint(arena, "  High:    ${d:.2}", .{if (quote_data) |q| q.high else latest.high}), th.mutedStyle());
    try col1.add(arena, try std.fmt.allocPrint(arena, "  Low:     ${d:.2}", .{if (quote_data) |q| q.low else latest.low}), th.mutedStyle());
    try col1.add(arena, try std.fmt.allocPrint(arena, "  Volume:  {s}", .{fmt.fmtIntCommas(&vol_buf, if (quote_data) |q| q.volume else latest.volume)}), th.mutedStyle());
    if (prev_close > 0) {
        const change = price - prev_close;
        const pct = (change / prev_close) * 100.0;
        var chg_buf: [64]u8 = undefined;
        const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
        try col1.add(arena, try std.fmt.allocPrint(arena, "  Change:  {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), change_style);
    }

    // Columns 2-4: ETF profile (only for actual ETFs)
    var col2 = Column.init(); // ETF stats
    col2.width = 22;
    var col3 = Column.init(); // Sectors
    col3.width = 26;
    var col4 = Column.init(); // Top holdings
    col4.width = 30;

    if (app.symbol_data.etf_profile) |profile| {
        // Col 2: ETF key stats
        try col2.add(arena, "ETF Profile", th.headerStyle());
        if (profile.expense_ratio) |er| {
            try col2.add(arena, try std.fmt.allocPrint(arena, "  Expense:  {d:.2}%", .{er * 100.0}), th.contentStyle());
        }
        if (profile.net_assets) |na| {
            try col2.add(arena, try std.fmt.allocPrint(arena, "  Assets:   ${s}", .{std.mem.trimEnd(u8, &fmt.fmtLargeNum(na), &.{' '})}), th.contentStyle());
        }
        if (profile.dividend_yield) |dy| {
            try col2.add(arena, try std.fmt.allocPrint(arena, "  Yield:    {d:.2}%", .{dy * 100.0}), th.contentStyle());
        }
        if (profile.total_holdings) |th_val| {
            try col2.add(arena, try std.fmt.allocPrint(arena, "  Holdings: {d}", .{th_val}), th.mutedStyle());
        }

        // Col 3: Sector allocation
        if (profile.sectors) |sectors| {
            if (sectors.len > 0) {
                try col3.add(arena, "Sectors", th.headerStyle());
                const show = @min(sectors.len, 7);
                for (sectors[0..show]) |sec| {
                    var title_buf: [64]u8 = undefined;
                    const title_name = fmt.toTitleCase(&title_buf, sec.name);
                    const name = if (title_name.len > 20) title_name[0..20] else title_name;
                    try col3.add(arena, try std.fmt.allocPrint(arena, "  {d:>5.1}% {s}", .{ sec.weight * 100.0, name }), th.contentStyle());
                }
            }
        }

        // Col 4: Top holdings
        if (profile.holdings) |holdings| {
            if (holdings.len > 0) {
                try col4.add(arena, "Top Holdings", th.headerStyle());
                const show = @min(holdings.len, 7);
                for (holdings[0..show]) |h| {
                    const sym_str = h.symbol orelse "--";
                    try col4.add(arena, try std.fmt.allocPrint(arena, "  {s:>6} {d:>5.1}%", .{ sym_str, h.weight * 100.0 }), th.contentStyle());
                }
            }
        }
    }

    // Merge all columns into grapheme-based StyledLines
    const gap: usize = 3;
    const bg_style = vaxis.Style{ .fg = theme.Theme.vcolor(th.text), .bg = theme.Theme.vcolor(th.bg) };
    const cols = [_]*const Column{ &col1, &col2, &col3, &col4 };
    var max_rows: usize = 0;
    for (cols) |col| max_rows = @max(max_rows, col.len());

    // Total max width for allocation
    const max_width = col1.width + gap + col2.width + gap + col3.width + gap + col4.width + 4;

    for (0..max_rows) |ri| {
        const graphemes = try arena.alloc([]const u8, max_width);
        const col_styles = try arena.alloc(vaxis.Style, max_width);
        var pos: usize = 0;

        for (cols, 0..) |col, ci| {
            if (ci > 0 and col.len() == 0) continue; // skip empty columns entirely
            if (ci > 0) {
                // Gap between columns
                for (0..gap) |_| {
                    if (pos < max_width) {
                        graphemes[pos] = " ";
                        col_styles[pos] = bg_style;
                        pos += 1;
                    }
                }
            }

            if (ri < col.len()) {
                const text = col.texts.items[ri];
                const style = col.styles.items[ri];
                // Write text characters
                for (0..@min(text.len, col.width)) |ci2| {
                    if (pos < max_width) {
                        graphemes[pos] = glyph(text[ci2]);
                        col_styles[pos] = style;
                        pos += 1;
                    }
                }
                // Pad to column width
                if (text.len < col.width) {
                    for (0..col.width - text.len) |_| {
                        if (pos < max_width) {
                            graphemes[pos] = " ";
                            col_styles[pos] = bg_style;
                            pos += 1;
                        }
                    }
                }
            } else {
                // Empty row in this column - pad full width
                for (0..col.width) |_| {
                    if (pos < max_width) {
                        graphemes[pos] = " ";
                        col_styles[pos] = bg_style;
                        pos += 1;
                    }
                }
            }
        }

        try lines.append(arena, .{
            .text = "",
            .style = bg_style,
            .graphemes = graphemes[0..pos],
            .cell_styles = col_styles[0..pos],
        });
    }
}

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

const testing = std.testing;
const Date = zfin.Date;

test "formatQuoteHeader: live source includes refreshed-ago string" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const text = try formatQuoteHeader(arena, "AAPL", null, .{ .live = "5s ago" });
    try testing.expectEqualStrings("  AAPL  (live, ~15 min delay, refreshed 5s ago)", text);
}

test "formatQuoteHeader: close source includes ISO date" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const text = try formatQuoteHeader(arena, "VTI", null, .{ .close = Date.fromYmd(2024, 3, 15) });
    try testing.expectEqualStrings("  VTI  (as of close on 2024-03-15)", text);
}

test "formatQuoteHeader: none source renders just the symbol" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const text = try formatQuoteHeader(arena, "BRK.B", null, .none);
    try testing.expectEqualStrings("  BRK.B", text);
}

test "formatQuoteHeader: name renders between symbol and suffix" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    try testing.expectEqualStrings(
        "  AAPL  Apple Inc.  (live, ~15 min delay, refreshed 5s ago)",
        try formatQuoteHeader(arena, "AAPL", "Apple Inc.", .{ .live = "5s ago" }),
    );
    try testing.expectEqualStrings(
        "  VTI  Vanguard Total Stock Market ETF  (as of close on 2024-03-15)",
        try formatQuoteHeader(arena, "VTI", "Vanguard Total Stock Market ETF", .{ .close = Date.fromYmd(2024, 3, 15) }),
    );
    try testing.expectEqualStrings(
        "  BRK.B  Berkshire Hathaway",
        try formatQuoteHeader(arena, "BRK.B", "Berkshire Hathaway", .none),
    );
}

test "formatQuoteHeader: empty name is omitted" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const text = try formatQuoteHeader(arena, "AAPL", "", .none);
    try testing.expectEqualStrings("  AAPL", text);
}
