const std = @import("std");
const zfin = @import("../root.zig");
const fmt = @import("../format.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 ─────────────────────────────────────
//
// Earnings tab has no tab-local keybinds today. Refresh is global
// (`r`); there's no per-tab UX beyond viewing the table. The empty
// enum is the explicit placeholder per the framework contract — no
// implicit defaults.

pub const Action = enum {};

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

pub const State = struct {
    /// Whether `init`/`activate` has populated `data` (or set
    /// `disabled` / `error_msg`). Distinct from "data is non-null"
    /// because we may have explicitly cached "this symbol has no
    /// earnings" without a payload.
    loaded: bool = false,
    /// Cached event list, oldest-last after the sort in `activate`.
    /// Owned by the State; freed in `deinit` and `reload`.
    data: ?[]zfin.EarningsEvent = null,
    /// Source-of-data unix-epoch timestamp captured at fetch time;
    /// drives the "data Xs ago" header readout.
    timestamp: i64 = 0,
    /// `true` when the symbol legitimately has no earnings data
    /// (ETF, index, …) — distinct from a fetch failure. Stops the
    /// tab from re-fetching every activation; surfaces a friendlier
    /// "not available" message.
    disabled: bool = false,
    /// Human-readable error message displayed inline in the content
    /// area when `data` is null and `disabled` is false.
    error_msg: ?[]const u8 = null,
};

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

pub const meta: framework.TabMeta(Action) = .{
    .label = "Earnings",
    // No tab-local bindings — refresh is global. Empty placeholder.
    .default_bindings = &.{},
    // One label per Action variant — also empty.
    .action_labels = std.enums.EnumArray(Action, []const u8).initFill(""),
    // Status-line hints — empty.
    .status_hints = &.{},
};

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

    /// One-time construction. State already has zero-initialized
    /// defaults via field defaults; nothing to allocate up front.
    pub fn init(state: *State, app: *App) !void {
        _ = app;
        state.* = .{};
    }

    /// One-time teardown. Free any allocated payloads.
    pub fn deinit(state: *State, app: *App) void {
        if (state.data) |e| zfin.EarningsEvent.freeSlice(app.allocator, e);
        state.* = .{};
    }

    /// Called when the earnings tab becomes the active tab. Lazy-
    /// loads on first activation per symbol; subsequent activations
    /// for the same symbol short-circuit on `loaded`.
    pub fn activate(state: *State, app: *App) !void {
        if (app.symbol.len == 0) return;
        if (state.disabled) return;
        if (state.loaded) return;
        loadData(state, app);
    }

    /// No-op — nothing transient to release on tab switch.
    pub const deactivate = framework.noopDeactivate(State);

    /// Force re-fetch on user request (refresh keybind, symbol
    /// change, etc). Invalidates the svc cache for earnings,
    /// frees current payload, clears flags, and re-runs the
    /// fetch path.
    pub fn reload(state: *State, app: *App) !void {
        if (app.symbol.len > 0) {
            app.svc.invalidate(app.symbol, .earnings);
        }
        // Clear every flag so loadData has the same starting
        // conditions as a fresh activation.
        if (state.data) |e| zfin.EarningsEvent.freeSlice(app.allocator, e);
        state.* = .{};
        loadData(state, app);
    }

    pub const tick = framework.noopTick(State);

    /// No tab-local actions — `Action` enum is empty, so this
    /// switch has no arms. Provided for contract completeness.
    pub fn handleAction(state: *State, app: *App, action: Action) void {
        _ = state;
        _ = app;
        switch (action) {}
    }

    /// Symbol-change reset. Drops cached payload + flags so the
    /// next `activate` re-fetches for the new symbol. Distinct
    /// from `reload` (no fetch is triggered here).
    pub fn onSymbolChange(state: *State, app: *App) void {
        if (state.data) |e| zfin.EarningsEvent.freeSlice(app.allocator, e);
        state.* = .{};
    }

    /// Earnings is disabled when the active symbol's data layer
    /// reported "no earnings for this symbol" (ETF/index). The
    /// flag is sticky for the symbol's session; cleared by
    /// `resetSymbolData` on App.
    pub fn isDisabled(app: *App) bool {
        return app.states.earnings.disabled;
    }
};

// ── Data loading ──────────────────────────────────────────────
//
// Internal helper invoked by both `activate` (lazy first-load)
// and `reload` (explicit refresh). Sets `loaded`, populates
// `data` / `disabled` / `error_msg` based on the data-service
// result, and posts a status message.

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

    const result = app.svc.getEarnings(app.symbol, .{}) catch |err| {
        switch (err) {
            zfin.DataError.NoApiKey => {
                state.error_msg = "No API key. Set FMP_API_KEY (free at financialmodelingprep.com)";
                app.setStatus("No API key. Set FMP_API_KEY");
            },
            zfin.DataError.FetchFailed => {
                state.disabled = true;
                app.setStatus("No earnings data (ETF/index?)");
            },
            else => {
                state.error_msg = "Error loading earnings data. Press r to retry.";
                app.setStatus("Error loading earnings");
            },
        }
        return;
    };
    state.data = result.data;
    state.timestamp = result.timestamp;

    // Sort newest-first — this is what users expect on earnings tables
    // everywhere (Yahoo, Morningstar, etc.) and keeps the most relevant
    // quarter on the first visible row.
    if (result.data.len > 1) {
        std.mem.sort(zfin.EarningsEvent, result.data, {}, struct {
            fn f(_: void, a: zfin.EarningsEvent, b: zfin.EarningsEvent) bool {
                return a.date.days > b.date.days;
            }
        }.f);
    }

    if (result.data.len == 0) {
        state.disabled = true;
        app.setStatus("No earnings data available (ETF/index?)");
        return;
    }
    app.setStatus(if (result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh");
}

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

pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine {
    // wall-clock required: per-frame "now" for the earnings
    // "data Xs ago" readout. Captured here so the pure renderer below
    // stays free of io.
    const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds();
    return renderEarningsLines(arena, app.theme, app.symbol, state.disabled, state.data, state.timestamp, state.error_msg, now_s);
}

/// Format the "earnings not available" message shown for ETFs and
/// indexes (which don't report earnings).
pub fn formatEarningsDisabled(arena: std.mem.Allocator, symbol: []const u8) ![]const u8 {
    return std.fmt.allocPrint(arena, "  Earnings not available for {s} (ETF/index)", .{symbol});
}

/// Format the earnings tab's header line. When `data_ago` is
/// non-null, includes a "(data Xs ago)" suffix; otherwise renders
/// just the title and symbol.
pub fn formatEarningsHeader(
    arena: std.mem.Allocator,
    symbol: []const u8,
    data_ago: ?[]const u8,
) ![]const u8 {
    return if (data_ago) |ago|
        std.fmt.allocPrint(arena, "  Earnings: {s}  (data {s})", .{ symbol, ago })
    else
        std.fmt.allocPrint(arena, "  Earnings: {s}", .{symbol});
}

/// Render earnings tab content. Pure function — no App dependency.
///
/// `now_s` is the unix-epoch-seconds reference point for the
/// "data Xs ago" age readout. Caller captures it once per frame via
/// `std.Io.Timestamp.now(io, .real).toSeconds()` and passes it in.
pub fn renderEarningsLines(
    arena: std.mem.Allocator,
    th: theme.Theme,
    symbol: []const u8,
    earnings_disabled: bool,
    earnings_data: ?[]const zfin.EarningsEvent,
    earnings_timestamp: i64,
    earnings_error: ?[]const u8,
    now_s: i64,
) ![]const StyledLine {
    var lines: std.ArrayList(StyledLine) = .empty;

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

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

    var earn_ago_buf: [16]u8 = undefined;
    const earn_ago = fmt.fmtTimeAgo(&earn_ago_buf, earnings_timestamp, now_s);
    const header_text = if (earn_ago.len > 0)
        try formatEarningsHeader(arena, symbol, earn_ago)
    else
        try formatEarningsHeader(arena, symbol, null);
    try lines.append(arena, .{ .text = header_text, .style = th.headerStyle() });
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

    const ev = earnings_data orelse {
        if (earnings_error) |err_msg| {
            try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "  {s}", .{err_msg}), .style = th.warningStyle() });
        } else {
            try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "  No data. Run: zfin earnings {s}", .{symbol}), .style = th.mutedStyle() });
        }
        return lines.toOwnedSlice(arena);
    };
    if (ev.len == 0) {
        try lines.append(arena, .{ .text = "  No earnings events found.", .style = th.mutedStyle() });
        return lines.toOwnedSlice(arena);
    }

    try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "  {s:>12} {s:>4} {s:>12} {s:>12} {s:>12} {s:>10}", .{
        "Date", "Q", "EPS Est", "EPS Act", "Surprise", "Surprise %",
    }), .style = th.mutedStyle() });

    for (ev) |e| {
        var row_buf: [128]u8 = undefined;
        const row = fmt.fmtEarningsRow(&row_buf, e);

        const text = try std.fmt.allocPrint(arena, "  {s}", .{row.text});
        const row_style = if (row.is_future) th.mutedStyle() else if (row.is_positive) th.positiveStyle() else th.negativeStyle();

        try lines.append(arena, .{ .text = text, .style = row_style });
    }

    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "  {d} earnings event(s)", .{ev.len}), .style = th.mutedStyle() });

    return lines.toOwnedSlice(arena);
}

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

const testing = std.testing;

test "renderEarningsLines with earnings 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 events = [_]zfin.EarningsEvent{.{
        .symbol = "AAPL",
        .date = try zfin.Date.parse("2025-01-15"),
        .quarter = 4,
        .estimate = 1.50,
        .actual = 1.65,
    }};
    const lines = try renderEarningsLines(arena, th, "AAPL", false, &events, 0, null, 1_700_000_000);
    // blank + header + blank + col_header + data_row + blank + count = 7
    try testing.expectEqual(@as(usize, 7), lines.len);
    try testing.expect(std.mem.indexOf(u8, lines[1].text, "AAPL") != null);
    try testing.expect(std.mem.indexOf(u8, lines[3].text, "EPS Est") != null);
    // Data row should contain the date
    try testing.expect(std.mem.indexOf(u8, lines[4].text, "2025-01-15") != null);
}

test "renderEarningsLines no symbol" {
    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 renderEarningsLines(arena, th, "", false, null, 0, null, 1_700_000_000);
    try testing.expectEqual(@as(usize, 2), lines.len);
    try testing.expect(std.mem.indexOf(u8, lines[1].text, "No symbol") != null);
}

test "renderEarningsLines disabled" {
    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 renderEarningsLines(arena, th, "VTI", true, null, 0, null, 1_700_000_000);
    try testing.expectEqual(@as(usize, 2), lines.len);
    try testing.expect(std.mem.indexOf(u8, lines[1].text, "ETF/index") != null);
}

test "renderEarningsLines 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 renderEarningsLines(arena, th, "AAPL", false, null, 0, null, 1_700_000_000);
    try testing.expectEqual(@as(usize, 4), lines.len);
    try testing.expect(std.mem.indexOf(u8, lines[3].text, "No data") != null);
}

test "renderEarningsLines with error message" {
    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 renderEarningsLines(arena, th, "AAPL", false, null, 0, "No API key. Set FMP_API_KEY", 1_700_000_000);
    try testing.expectEqual(@as(usize, 4), lines.len);
    try testing.expect(std.mem.indexOf(u8, lines[3].text, "FMP_API_KEY") != null);
}

test "tab.init / deinit are idempotent" {
    var state: State = undefined;
    var dummy_app: tui.App = undefined; // intentionally undefined: init/deinit
    // for earnings don't touch app.

    try tab.init(&state, &dummy_app);
    // After init, state should be defaulted.
    try testing.expectEqual(false, state.loaded);
    try testing.expectEqual(false, state.disabled);
    try testing.expect(state.data == null);
    try testing.expect(state.error_msg == null);

    // deinit on a default state should be safe (no-op-ish).
    // We can't fully exercise deinit because app.allocator isn't
    // initialized; the `if (state.data) |e|` branch is what'd
    // require the allocator, and `data` is null here. So this
    // verifies the no-allocation deinit path.
    tab.deinit(&state, &dummy_app);
    try testing.expect(state.data == null);
}

test "formatEarningsDisabled: includes symbol" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const text = try formatEarningsDisabled(arena, "VTI");
    try testing.expectEqualStrings("  Earnings not available for VTI (ETF/index)", text);
}

test "formatEarningsHeader: with data-ago suffix" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const text = try formatEarningsHeader(arena, "AAPL", "2h ago");
    try testing.expectEqualStrings("  Earnings: AAPL  (data 2h ago)", text);
}

test "formatEarningsHeader: without data-ago suffix" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const text = try formatEarningsHeader(arena, "MSFT", null);
    try testing.expectEqualStrings("  Earnings: MSFT", text);
}
