const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = cli.fmt;
const Money = @import("../Money.zig");
const chart_export = @import("../chart_export.zig");
const tui_chart = @import("../tui/chart.zig");

pub const ParsedArgs = struct {
    symbol: []const u8,
    /// When set, render the chart as a PNG to this path instead of
    /// emitting any text output. The chart code already produces
    /// z2d-rendered pixel buffers for the TUI; this flag just lands
    /// the same pixels in a file via z2d's PNG exporter.
    export_chart: ?[]const u8 = null,
};

pub const meta: framework.Meta = .{
    .name = "quote",
    .group = .symbol_lookup,
    .synopsis = "Show latest quote with chart and 20-day history",
    .uppercase_first_arg = true,
    .help =
    \\Usage: zfin quote <SYMBOL> [--export-chart <PATH>]
    \\
    \\Show the latest real-time quote for a symbol (Yahoo / TwelveData)
    \\plus a braille price chart of the last 60 candles and a table
    \\of the last 20 trading days.
    \\
    \\If real-time fetch fails, falls back to the cached close. The
    \\Yahoo path is free and unauthenticated; TwelveData requires
    \\TWELVEDATA_API_KEY.
    \\
    \\Options:
    \\  --export-chart <PATH>  Render the price+Bollinger+RSI chart
    \\                         to a PNG file at the given path
    \\                         (1920x1080) and exit. No text output
    \\                         is emitted. Uses the TUI's default
    \\                         theme.
    \\
    \\Examples:
    \\  zfin quote AAPL
    \\  zfin quote spy   # symbols are case-insensitive
    \\  zfin quote AAPL --export-chart aapl.png
    \\
    ,
    .user_errors = error{ MissingSymbol, UnexpectedArg, MissingFlagValue },
};

/// Quote data extracted from the real-time API (or synthesized from candles).
pub const QuoteData = struct {
    price: f64,
    open: f64,
    high: f64,
    low: f64,
    volume: u64,
    prev_close: f64,
    date: zfin.Date,
};

pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
    var symbol: ?[]const u8 = null;
    var export_chart: ?[]const u8 = null;

    var i: usize = 0;
    while (i < cmd_args.len) : (i += 1) {
        const a = cmd_args[i];
        if (std.mem.eql(u8, a, "--export-chart")) {
            export_chart = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
        } else if (a.len > 0 and a[0] == '-') {
            // Reject ANY leading-dash token we don't recognize,
            // including single-dash ones like `-x`. Previously only
            // `--`-prefixed flags were caught, so `-x` slipped through
            // and became the symbol.
            cli.stderrPrint(ctx.io, "Error: 'quote': unexpected flag ");
            cli.stderrPrint(ctx.io, a);
            cli.stderrPrint(ctx.io, "\n");
            return error.UnexpectedArg;
        } else {
            if (symbol != null) {
                cli.stderrPrint(ctx.io, "Error: 'quote' takes a single symbol argument\n");
                return error.UnexpectedArg;
            }
            symbol = a;
        }
    }

    if (symbol == null) {
        cli.stderrPrint(ctx.io, "Error: 'quote' requires a symbol argument\n");
        return error.MissingSymbol;
    }
    return .{ .symbol = symbol.?, .export_chart = export_chart };
}

pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
    const svc = ctx.svc orelse return error.MissingDataService;
    const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
    // Fetch candle data for chart and history
    const candle_result = svc.getCandles(parsed.symbol, opts) catch |err| switch (err) {
        zfin.DataError.NoApiKey => {
            cli.stderrPrint(ctx.io, "Error: No API key configured for candle data.\n");
            return;
        },
        else => {
            cli.stderrPrint(ctx.io, "Error fetching candle data.\n");
            return;
        },
    };
    defer candle_result.deinit();
    const candles = candle_result.data;

    // PNG export short-circuits all text rendering. Use the
    // longest timeframe the candle history can support — falling
    // back to shorter ones until one fits — so the user gets the
    // most chart context without having to think about it.
    if (parsed.export_chart) |path| {
        const tf: tui_chart.Timeframe = blk: {
            const candidates = [_]tui_chart.Timeframe{ .@"5Y", .@"3Y", .@"1Y", .@"6M" };
            for (candidates) |c| {
                if (candles.len >= c.tradingDays()) break :blk c;
            }
            break :blk .@"6M"; // fallback; renderToSurface enforces >= 20 candles
        };
        chart_export.exportSymbolChart(ctx.io, ctx.allocator, candles, tf, path) catch |err| switch (err) {
            error.InsufficientData => {
                cli.stderrPrint(ctx.io, "Error: not enough candle history to render a chart (need >= 20 candles).\n");
                return;
            },
            else => {
                cli.stderrPrint(ctx.io, "Error: failed to write PNG.\n");
                return err;
            },
        };
        return;
    }

    // Fetch real-time quote via DataService
    var quote: ?QuoteData = null;
    if (svc.getQuote(parsed.symbol, opts)) |q| {
        quote = .{
            .price = q.close,
            .open = q.open,
            .high = q.high,
            .low = q.low,
            .volume = q.volume,
            .prev_close = q.previous_close,
            .date = if (candles.len > 0) candles[candles.len - 1].date else ctx.today,
        };
    } else |_| {}

    // Resolve a human-readable security name the same way the TUI
    // 'K' overlay and quote tab do: the curated `metadata.srf`
    // `name::` field first, then the ETF profile's fund name. The
    // name is copied into `name_buf` so its lifetime is independent
    // of the (transient) classification map / ETF fetch result.
    var name_buf: [256]u8 = undefined;
    var name: ?[]const u8 = null;
    {
        var cm_opt = loadClassificationMap(ctx);
        defer if (cm_opt) |*cm| cm.deinit();
        const cm_ptr: ?*const zfin.classification.ClassificationMap = if (cm_opt) |*cm| cm else null;
        if (zfin.classification.resolveSecurityName(parsed.symbol, cm_ptr, null)) |nm| {
            name = clampName(&name_buf, nm);
        }
    }
    if (name == null) {
        // Fallback: the ETF profile's fund name, gated on isEtf() so
        // it matches the TUI (which only retains fund-shaped
        // profiles in symbol_data). Plain stocks not in metadata.srf
        // therefore render symbol-only, exactly like the 'K' overlay.
        // Only fetched when metadata yielded nothing, so already-named
        // holdings don't pay for an EDGAR round-trip.
        if (svc.getEtfProfile(parsed.symbol, opts)) |etf_result| {
            defer etf_result.deinit();
            if (etf_result.data.isEtf()) {
                if (etf_result.data.name) |nm| name = clampName(&name_buf, nm);
            }
        } else |_| {}
    }

    try display(ctx.allocator, candles, quote, parsed.symbol, name, ctx.today, ctx.color, ctx.out);
}

/// Copy `s` (clamped to `buf`'s capacity) into `buf` and return the
/// written slice. Fund/security names fit easily in 256 bytes.
fn clampName(buf: []u8, s: []const u8) []const u8 {
    const n = @min(s.len, buf.len);
    @memcpy(buf[0..n], s[0..n]);
    return buf[0..n];
}

/// Quietly load the `metadata.srf` classification map that sits
/// beside the resolved portfolio anchor. Best-effort: returns null
/// (printing nothing) when there's no portfolio, no `metadata.srf`,
/// or it fails to parse. Unlike `cli.loadPortfolio` this never emits
/// "no portfolio" noise — `quote` works fine without one, and the
/// map is only used to enrich the header with a name. Caller owns
/// the returned map and must `deinit()` it.
fn loadClassificationMap(ctx: *framework.RunCtx) ?zfin.classification.ClassificationMap {
    var resolved = framework.resolvePatterns(
        ctx.io,
        ctx.allocator,
        ctx.config,
        ctx.globals.portfolio_patterns,
    ) catch return null;
    defer resolved.deinit();
    if (resolved.paths.len == 0) return null;

    // metadata.srf lives in the same directory as the portfolio
    // anchor (see AGENTS.md "Portfolio auto-detection").
    const anchor_path = resolved.paths[0];
    const dir_end = if (std.mem.lastIndexOfScalar(u8, anchor_path, std.fs.path.sep)) |idx| idx + 1 else 0;
    const meta_path = std.fmt.allocPrint(ctx.allocator, "{s}metadata.srf", .{anchor_path[0..dir_end]}) catch return null;
    defer ctx.allocator.free(meta_path);

    const meta_data = std.Io.Dir.cwd().readFileAlloc(ctx.io, meta_path, ctx.allocator, .limited(1024 * 1024)) catch return null;
    defer ctx.allocator.free(meta_data);

    return zfin.classification.parseClassificationFile(ctx.allocator, meta_data) catch null;
}

pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, name: ?[]const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void {
    const has_quote = quote != null;

    // Header. The security name (when resolved) renders between the
    // symbol and the price, matching the TUI quote tab.
    try cli.setBold(out, color);
    try out.print("\n{s}", .{symbol});
    if (name) |nm| {
        if (nm.len > 0) try out.print("  {s}", .{nm});
    }
    if (quote) |q| {
        try out.print("  {f}\n", .{Money.from(q.price)});
    } else if (candles.len > 0) {
        try out.print("  {f}  (close)\n", .{Money.from(candles[candles.len - 1].close)});
    } else {
        try out.print("\n", .{});
    }
    try cli.reset(out, color);
    try out.print("======================================================================\n", .{});

    // Quote details
    const price = if (quote) |q| q.price else if (candles.len > 0) candles[candles.len - 1].close else @as(f64, 0);
    const prev_close = if (quote) |q| q.prev_close else if (candles.len >= 2) candles[candles.len - 2].close else @as(f64, 0);

    if (candles.len > 0 or has_quote) {
        const latest_date = if (quote) |q| q.date else if (candles.len > 0) candles[candles.len - 1].date else as_of;
        const open_val = if (quote) |q| q.open else if (candles.len > 0) candles[candles.len - 1].open else @as(f64, 0);
        const high_val = if (quote) |q| q.high else if (candles.len > 0) candles[candles.len - 1].high else @as(f64, 0);
        const low_val = if (quote) |q| q.low else if (candles.len > 0) candles[candles.len - 1].low else @as(f64, 0);
        const vol_val = if (quote) |q| q.volume else if (candles.len > 0) candles[candles.len - 1].volume else @as(u64, 0);

        var vol_buf: [32]u8 = undefined;
        try out.print("  Date:          {f}\n", .{latest_date});
        try out.print("  Open:          ${d:.2}\n", .{open_val});
        try out.print("  High:          ${d:.2}\n", .{high_val});
        try out.print("  Low:           ${d:.2}\n", .{low_val});
        try out.print("  Volume:        {s}\n", .{fmt.fmtIntCommas(&vol_buf, vol_val)});

        if (prev_close > 0) {
            const change = price - prev_close;
            const pct = (change / prev_close) * 100.0;
            var chg_buf: [64]u8 = undefined;
            try cli.printGainLoss(out, color, change, "  Change:        {s}\n", .{fmt.fmtPriceChange(&chg_buf, change, pct)});
        }
    }

    // Braille chart (60 columns, 10 rows)
    if (candles.len >= 2) {
        try out.print("\n", .{});
        const chart_days: usize = @min(candles.len, 60);
        const chart_data = candles[candles.len - chart_days ..];
        var chart = fmt.computeBrailleChart(allocator, chart_data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null;
        if (chart) |*ch| {
            defer ch.deinit(allocator);
            try fmt.writeBrailleAnsi(out, ch, color, cli.CLR_MUTED, false);
        }
    }

    // Recent history table (last 20 candles)
    if (candles.len > 0) {
        try out.print("\n", .{});
        try cli.printBold(out, color, "  Recent History:\n", .{});
        try cli.printFg(out, color, cli.CLR_MUTED, "  {s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}\n", .{
            "Date", "Open", "High", "Low", "Close", "Volume",
        });

        const start_idx = if (candles.len > 20) candles.len - 20 else 0;
        for (candles[start_idx..]) |candle| {
            var row_buf: [128]u8 = undefined;
            const day_gain = candle.close >= candle.open;
            try cli.printGainLoss(out, color, if (day_gain) 1.0 else -1.0, "{s}\n", .{fmt.fmtCandleRow(&row_buf, candle)});
        }
        try out.print("\n  {d} trading days shown\n", .{candles[start_idx..].len});
    }

    try out.print("\n", .{});
}

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

test "parseArgs: accepts a single symbol" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"AAPL"};
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expectEqualStrings("AAPL", parsed.symbol);
}

test "parseArgs: missing symbol errors" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{};
    try std.testing.expectError(error.MissingSymbol, parseArgs(&ctx, &args));
}

test "parseArgs: extra args error" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{ "AAPL", "extra" };
    try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}

test "parseArgs: --export-chart captures the path" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{ "AAPL", "--export-chart", "aapl.png" };
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expectEqualStrings("AAPL", parsed.symbol);
    try std.testing.expectEqualStrings("aapl.png", parsed.export_chart.?);
}

test "parseArgs: single-dash unknown flag is rejected (not swallowed as symbol)" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"-x"};
    try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}

test "parseArgs: --export-chart without a value is rejected" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{ "AAPL", "--export-chart" };
    try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
}

test "parseArgs: --export-chart followed by a flag does not swallow the flag" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{ "AAPL", "--export-chart", "--bogus" };
    try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
}

test "display with candles only" {
    var buf: [8192]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const candles = [_]zfin.Candle{
        .{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
        .{ .date = .{ .days = 20001 }, .open = 153.0, .high = 158.0, .low = 152.0, .close = 156.0, .adj_close = 156.0, .volume = 45_000_000 },
    };
    try display(std.testing.allocator, &candles, null, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w);
    const out = w.buffered();
    try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "(close)") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "Recent History") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "2 trading days shown") != null);
}

test "display with quote data" {
    var buf: [8192]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const candles = [_]zfin.Candle{};
    const quote: QuoteData = .{
        .price = 175.50,
        .open = 174.00,
        .high = 176.00,
        .low = 173.50,
        .volume = 60_000_000,
        .prev_close = 172.00,
        .date = .{ .days = 20001 },
    };
    try display(std.testing.allocator, &candles, quote, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w);
    const out = w.buffered();
    try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "Change") != null);
    // Should NOT have "(close)" since we have a real quote
    try std.testing.expect(std.mem.indexOf(u8, out, "(close)") == null);
}

test "display renders the security name when provided" {
    var buf: [8192]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const candles = [_]zfin.Candle{
        .{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
    };
    try display(std.testing.allocator, &candles, null, "AAPL", "Apple Inc.", zfin.Date.fromYmd(2026, 5, 8), false, &w);
    const out = w.buffered();
    // Name appears between the symbol and the price.
    try std.testing.expect(std.mem.indexOf(u8, out, "AAPL  Apple Inc.") != null);
}

test "display omits an empty name" {
    var buf: [8192]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const candles = [_]zfin.Candle{
        .{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
    };
    try display(std.testing.allocator, &candles, null, "AAPL", "", zfin.Date.fromYmd(2026, 5, 8), false, &w);
    const out = w.buffered();
    // No double-space orphan where the name would have gone.
    try std.testing.expect(std.mem.indexOf(u8, out, "AAPL  $") != null);
}

test "display no ANSI without color" {
    var buf: [8192]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const candles = [_]zfin.Candle{
        .{ .date = .{ .days = 20000 }, .open = 100.0, .high = 105.0, .low = 99.0, .close = 103.0, .adj_close = 103.0, .volume = 1_000_000 },
    };
    try display(std.testing.allocator, &candles, null, "SPY", null, zfin.Date.fromYmd(2026, 5, 8), false, &w);
    const out = w.buffered();
    try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}
