//! Yahoo Finance provider -- free, no API key required.
//!
//! Uses the unofficial v8 chart API for both candles and quotes.
//! No rate limiting is applied since there's no documented limit,
//! but this API is unofficial and can break without notice.
//!
//! Yahoo has broader mutual fund coverage than TwelveData/Polygon,
//! making it useful as a fallback for symbols like VTTHX.

const std = @import("std");
const http = @import("../net/http.zig");
const Date = @import("../Date.zig");
const Candle = @import("../models/candle.zig").Candle;
const Quote = @import("../models/quote.zig").Quote;
const parseJsonFloat = @import("json_utils.zig").parseJsonFloat;
const optFloat = @import("json_utils.zig").optFloat;
const jsonStr = @import("json_utils.zig").jsonStr;

const base_url = "https://query1.finance.yahoo.com/v8/finance/chart";

pub const Yahoo = struct {
    client: http.Client,
    allocator: std.mem.Allocator,

    pub fn init(io: std.Io, allocator: std.mem.Allocator) Yahoo {
        return .{
            .client = http.Client.init(io, allocator),
            .allocator = allocator,
        };
    }

    pub fn deinit(self: *Yahoo) void {
        self.client.deinit();
    }

    /// Fetch daily candles for a symbol between two dates.
    /// Returns candles sorted oldest-first.
    pub fn fetchCandles(
        self: *Yahoo,
        allocator: std.mem.Allocator,
        symbol: []const u8,
        from: Date,
        to: Date,
    ) ![]Candle {
        const period1 = from.toEpoch();
        const period2 = to.toEpoch() + std.time.s_per_day; // inclusive end

        var p1_buf: [20]u8 = undefined;
        var p2_buf: [20]u8 = undefined;
        const p1_str = std.fmt.bufPrint(&p1_buf, "{d}", .{period1}) catch return error.ParseError;
        const p2_str = std.fmt.bufPrint(&p2_buf, "{d}", .{period2}) catch return error.ParseError;

        const symbol_url = try std.fmt.allocPrint(allocator, base_url ++ "/{s}", .{symbol});
        defer allocator.free(symbol_url);

        const url = try http.buildUrl(allocator, symbol_url, &.{
            .{ "interval", "1d" },
            .{ "period1", p1_str },
            .{ "period2", p2_str },
        });
        defer allocator.free(url);

        var response = try self.client.get(url);
        defer response.deinit();

        return parseChartCandles(allocator, response.body);
    }

    /// Fetch a quote snapshot for a symbol.
    /// Uses the chart API meta fields (regularMarketPrice, etc.).
    pub fn fetchQuote(
        self: *Yahoo,
        allocator: std.mem.Allocator,
        symbol: []const u8,
    ) !Quote {
        // Fetch just 1 day of data to get the meta block with quote info
        const symbol_url = try std.fmt.allocPrint(allocator, base_url ++ "/{s}", .{symbol});
        defer allocator.free(symbol_url);

        const url = try http.buildUrl(allocator, symbol_url, &.{
            .{ "interval", "1d" },
            .{ "range", "5d" },
        });
        defer allocator.free(url);

        var response = try self.client.get(url);
        defer response.deinit();

        return parseChartQuote(allocator, response.body, symbol);
    }
};

// -- JSON parsing --

/// Parse the chart API result array, returning the first result's chart data.
fn getChartResult(allocator: std.mem.Allocator, body: []const u8) !struct { parsed: std.json.Parsed(std.json.Value), result: std.json.ObjectMap } {
    const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch
        return error.ParseError;
    errdefer parsed.deinit();

    const chart = parsed.value.object.get("chart") orelse return error.ParseError;
    const chart_obj = switch (chart) {
        .object => |o| o,
        else => return error.ParseError,
    };

    // Check for error
    if (chart_obj.get("error")) |err_val| {
        if (err_val != .null) return error.RequestFailed;
    }

    const result_arr = chart_obj.get("result") orelse return error.ParseError;
    const results = switch (result_arr) {
        .array => |a| a.items,
        else => return error.ParseError,
    };

    if (results.len == 0) return error.ParseError;

    const result = switch (results[0]) {
        .object => |o| o,
        else => return error.ParseError,
    };

    return .{ .parsed = parsed, .result = result };
}

fn parseChartCandles(allocator: std.mem.Allocator, body: []const u8) ![]Candle {
    const chart = try getChartResult(allocator, body);
    const parsed = chart.parsed;
    defer parsed.deinit();
    const result = chart.result;

    // Get timestamps
    const ts_json = result.get("timestamp") orelse return error.ParseError;
    const timestamps = switch (ts_json) {
        .array => |a| a.items,
        else => return error.ParseError,
    };

    // Get quote data (open/high/low/close/volume arrays)
    const indicators = result.get("indicators") orelse return error.ParseError;
    const indicators_obj = switch (indicators) {
        .object => |o| o,
        else => return error.ParseError,
    };
    const quote_arr = indicators_obj.get("quote") orelse return error.ParseError;
    const quotes = switch (quote_arr) {
        .array => |a| a.items,
        else => return error.ParseError,
    };
    if (quotes.len == 0) return error.ParseError;
    const q = switch (quotes[0]) {
        .object => |o| o,
        else => return error.ParseError,
    };

    const opens = getFloatArray(q.get("open")) orelse return error.ParseError;
    const highs = getFloatArray(q.get("high")) orelse return error.ParseError;
    const lows = getFloatArray(q.get("low")) orelse return error.ParseError;
    const closes = getFloatArray(q.get("close")) orelse return error.ParseError;
    const volumes = getFloatArray(q.get("volume")) orelse return error.ParseError;

    // Get adjclose if available
    const adjcloses = blk: {
        const adjclose_section = indicators_obj.get("adjclose") orelse break :blk null;
        const adj_arr = switch (adjclose_section) {
            .array => |a| a.items,
            else => break :blk null,
        };
        if (adj_arr.len == 0) break :blk null;
        const adj_obj = switch (adj_arr[0]) {
            .object => |o| o,
            else => break :blk null,
        };
        break :blk getFloatArray(adj_obj.get("adjclose"));
    };

    var candles: std.ArrayList(Candle) = .empty;
    errdefer candles.deinit(allocator);

    // Yahoo returns oldest-first, which is what we want
    for (timestamps, 0..) |ts, i| {
        if (i >= opens.len or i >= closes.len) break;

        // Skip null entries (incomplete trading day)
        const close_val = closes[i];
        if (close_val == .null) continue;

        const epoch: i64 = switch (ts) {
            .integer => |v| v,
            else => continue,
        };

        const date = Date.fromEpoch(epoch);

        const close = parseJsonFloat(close_val);
        const adj = if (adjcloses) |ac| (if (i < ac.len) parseJsonFloat(ac[i]) else close) else close;

        try candles.append(allocator, .{
            .date = date,
            .open = parseJsonFloat(opens[i]),
            .high = parseJsonFloat(highs[i]),
            .low = parseJsonFloat(lows[i]),
            .close = close,
            .adj_close = adj,
            .volume = @intFromFloat(@max(0, parseJsonFloat(volumes[i]))),
        });
    }

    return candles.toOwnedSlice(allocator);
}

fn parseChartQuote(allocator: std.mem.Allocator, body: []const u8, symbol: []const u8) !Quote {
    const chart = try getChartResult(allocator, body);
    const parsed = chart.parsed;
    defer parsed.deinit();
    const result = chart.result;

    const meta = result.get("meta") orelse return error.ParseError;
    const m = switch (meta) {
        .object => |o| o,
        else => return error.ParseError,
    };

    const price = parseJsonFloat(m.get("regularMarketPrice"));

    // The day's open/high/low/volume AND the previous close come from
    // the indicators.quote arrays, not the meta block:
    //   - meta has no reliable regularMarketOpen, and its fiftyTwoWeek*
    //     fields are the 52-week range, not the day's.
    //   - meta.chartPreviousClose is the close before the *entire*
    //     requested range (range=5d here), so it is ~a week stale and
    //     turns a flat day into a huge bogus 1-day change. The correct
    //     previous close is the prior trading day's close from the
    //     close array.
    // Falls back to `price` (open/high/low), 0 (volume), and the meta
    // chartPreviousClose (previous close) when the arrays are absent or
    // hold a single bar (where chartPreviousClose is itself correct).
    const snap = parseDaySnapshot(result);

    const open = if (snap) |s| (s.open orelse price) else price;
    const high = if (snap) |s| (s.high orelse price) else price;
    const low = if (snap) |s| (s.low orelse price) else price;
    const volume: u64 = if (snap) |s| (s.volume orelse 0) else 0;
    const prev_close = (if (snap) |s| s.prev_close else null) orelse parseJsonFloat(m.get("chartPreviousClose"));

    const change = price - prev_close;
    const pct = if (prev_close != 0) (change / prev_close) * 100.0 else 0;

    var quote: Quote = .{
        .symbol = symbol,
        .exchange = "",
        .datetime = "",
        .close = price,
        .open = open,
        .high = high,
        .low = low,
        .volume = volume,
        .previous_close = prev_close,
        .change = change,
        .percent_change = pct,
        .average_volume = 0,
        .fifty_two_week_low = parseJsonFloat(m.get("fiftyTwoWeekLow")),
        .fifty_two_week_high = parseJsonFloat(m.get("fiftyTwoWeekHigh")),
    };
    // Yahoo's chart `meta` carries the security's display name in
    // `longName` (preferred) / `shortName`. Copy it into the quote's
    // inline buffer before the parsed JSON is freed on return. This is
    // the same live source as the price, so the name and price always
    // describe the same security even after a ticker is recycled.
    if (jsonStr(m.get("longName")) orelse jsonStr(m.get("shortName"))) |raw| {
        const trimmed = std.mem.trim(u8, raw, " \t\r\n");
        if (trimmed.len > 0) quote.setName(trimmed);
    }
    return quote;
}

fn getFloatArray(val: ?std.json.Value) ?[]const std.json.Value {
    const v = val orelse return null;
    return switch (v) {
        .array => |a| a.items,
        else => null,
    };
}

/// Optional float at array index `i`: null when out of bounds or JSON null.
fn optFloatAt(arr: []const std.json.Value, i: usize) ?f64 {
    if (i >= arr.len) return null;
    return optFloat(arr[i]);
}

/// The current trading day's OHLCV plus the prior day's close, pulled
/// from a chart result's `indicators.quote` arrays.
const DaySnapshot = struct {
    open: ?f64,
    high: ?f64,
    low: ?f64,
    volume: ?u64,
    /// Close of the most recent trading day *before* the current (last)
    /// bar in the window - the correct base for a 1-day change. null
    /// when the window holds no prior day (single-bar window), in which
    /// case the caller falls back to meta.chartPreviousClose.
    prev_close: ?f64,
};

/// Build a DaySnapshot from a chart result. The current bar is the last
/// array entry (the in-progress day while the market is open - its
/// close is null then, but open/high/low/volume are live - or the last
/// completed day). The previous close is the most recent non-null close
/// strictly before that last entry. Returns null when the
/// indicators.quote arrays are absent or empty.
fn parseDaySnapshot(result: std.json.ObjectMap) ?DaySnapshot {
    const indicators = result.get("indicators") orelse return null;
    const indicators_obj = switch (indicators) {
        .object => |o| o,
        else => return null,
    };
    const quote_arr = indicators_obj.get("quote") orelse return null;
    const quotes = switch (quote_arr) {
        .array => |a| a.items,
        else => return null,
    };
    if (quotes.len == 0) return null;
    const q = switch (quotes[0]) {
        .object => |o| o,
        else => return null,
    };

    const opens = getFloatArray(q.get("open")) orelse return null;
    const highs = getFloatArray(q.get("high")) orelse return null;
    const lows = getFloatArray(q.get("low")) orelse return null;
    const closes = getFloatArray(q.get("close")) orelse return null;
    const volumes = getFloatArray(q.get("volume")) orelse return null;

    if (closes.len == 0) return null;
    const last = closes.len - 1;

    // Previous close: scan backward from the entry just before the
    // current (last) one for the first non-null close.
    var prev_close: ?f64 = null;
    var i = last;
    while (i > 0) {
        i -= 1;
        if (optFloatAt(closes, i)) |c| {
            prev_close = c;
            break;
        }
    }

    return .{
        .open = optFloatAt(opens, last),
        .high = optFloatAt(highs, last),
        .low = optFloatAt(lows, last),
        .volume = if (optFloatAt(volumes, last)) |v| @as(u64, @intFromFloat(@max(0, v))) else null,
        .prev_close = prev_close,
    };
}

// -- Tests --

test "parseChartCandles basic" {
    const body =
        \\{
        \\  "chart": {
        \\    "result": [{
        \\      "timestamp": [1704067800, 1704154200],
        \\      "indicators": {
        \\        "quote": [{
        \\          "open": [185.0, 187.15],
        \\          "high": [186.1, 188.44],
        \\          "low": [184.0, 183.89],
        \\          "close": [185.5, 184.25],
        \\          "volume": [42000000, 58414460]
        \\        }],
        \\        "adjclose": [{
        \\          "adjclose": [185.5, 184.25]
        \\        }]
        \\      }
        \\    }],
        \\    "error": null
        \\  }
        \\}
    ;

    const allocator = std.testing.allocator;
    const candles = try parseChartCandles(allocator, body);
    defer allocator.free(candles);

    try std.testing.expectEqual(@as(usize, 2), candles.len);

    // Oldest first (Yahoo returns chronological order)
    try std.testing.expectApproxEqAbs(@as(f64, 185.0), candles[0].open, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 185.5), candles[0].close, 0.01);
    try std.testing.expectEqual(@as(u64, 42000000), candles[0].volume);

    try std.testing.expectApproxEqAbs(@as(f64, 187.15), candles[1].open, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 184.25), candles[1].close, 0.01);
}

test "parseChartCandles skips null entries" {
    const body =
        \\{
        \\  "chart": {
        \\    "result": [{
        \\      "timestamp": [1704067800, 1704154200],
        \\      "indicators": {
        \\        "quote": [{
        \\          "open": [185.0, null],
        \\          "high": [186.1, null],
        \\          "low": [184.0, null],
        \\          "close": [185.5, null],
        \\          "volume": [42000000, null]
        \\        }]
        \\      }
        \\    }],
        \\    "error": null
        \\  }
        \\}
    ;

    const allocator = std.testing.allocator;
    const candles = try parseChartCandles(allocator, body);
    defer allocator.free(candles);

    // Should only have 1 candle (null entry skipped)
    try std.testing.expectEqual(@as(usize, 1), candles.len);
    try std.testing.expectApproxEqAbs(@as(f64, 185.5), candles[0].close, 0.01);
}

test "parseChartCandles error response" {
    const body =
        \\{
        \\  "chart": {
        \\    "result": null,
        \\    "error": {"code": "Not Found", "description": "No data found"}
        \\  }
        \\}
    ;

    const allocator = std.testing.allocator;
    const result = parseChartCandles(allocator, body);
    try std.testing.expectError(error.RequestFailed, result);
}

test "parseChartQuote basic" {
    const body =
        \\{
        \\  "chart": {
        \\    "result": [{
        \\      "meta": {
        \\        "symbol": "VTTHX",
        \\        "regularMarketPrice": 27.78,
        \\        "chartPreviousClose": 28.06,
        \\        "fiftyTwoWeekHigh": 28.59,
        \\        "fiftyTwoWeekLow": 22.21
        \\      },
        \\      "timestamp": [1704067800],
        \\      "indicators": {"quote": [{"open": [27.50], "high": [27.90], "low": [27.40], "close": [27.78], "volume": [123456]}]}
        \\    }],
        \\    "error": null
        \\  }
        \\}
    ;

    const allocator = std.testing.allocator;
    const quote = try parseChartQuote(allocator, body, "VTTHX");

    try std.testing.expectApproxEqAbs(@as(f64, 27.78), quote.close, 0.01);
    // Single-bar window: no prior day in the array, so the previous
    // close falls back to meta chartPreviousClose (which IS correct when
    // the window is a single day).
    try std.testing.expectApproxEqAbs(@as(f64, 28.06), quote.previous_close, 0.01);
    // Day OHLCV comes from indicators.quote - NOT the meta 52-week range
    // and NOT regularMarketPrice. Regression: open used to be the current
    // price, and high/low used to be the 52-week high/low.
    try std.testing.expectApproxEqAbs(@as(f64, 27.50), quote.open, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 27.90), quote.high, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 27.40), quote.low, 0.01);
    try std.testing.expectEqual(@as(u64, 123456), quote.volume);
    // The 52-week range stays in its own dedicated fields.
    try std.testing.expectApproxEqAbs(@as(f64, 28.59), quote.fifty_two_week_high, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 22.21), quote.fifty_two_week_low, 0.01);
    // change = 27.78 - 28.06 = -0.28
    try std.testing.expectApproxEqAbs(@as(f64, -0.28), quote.change, 0.01);
    // percent_change = (-0.28 / 28.06) * 100 ~ -0.998
    try std.testing.expectApproxEqAbs(@as(f64, -0.998), quote.percent_change, 0.01);
}

test "parseChartQuote: in-progress day uses live bar and prior day's close" {
    // Mirrors the SPCX bug: the market is open, so the last array entry
    // is today (open/high/low/volume are live, close still null), while
    // meta.chartPreviousClose is the close from *before* the whole 5-day
    // range - badly stale. The 1-day change must use yesterday's close
    // from the array, not the stale meta value (which produced a bogus
    // -17% day).
    const body =
        \\{
        \\  "chart": {
        \\    "result": [{
        \\      "meta": {
        \\        "symbol": "SPCX",
        \\        "regularMarketPrice": 153.23,
        \\        "chartPreviousClose": 185.00,
        \\        "fiftyTwoWeekHigh": 225.64,
        \\        "fiftyTwoWeekLow": 147.11
        \\      },
        \\      "timestamp": [1782135000, 1782221400, 1782307800, 1782394200, 1782480600],
        \\      "indicators": {"quote": [{
        \\        "open":   [176.04, 151.06, 154.20, 156.63, 150.62],
        \\        "high":   [176.75, 165.50, 159.86, 160.65, 158.40],
        \\        "low":    [154.00, 147.11, 150.72, 150.00, 148.51],
        \\        "close":  [154.60, 156.11, 154.54, 153.00, null],
        \\        "volume": [169183800, 155848100, 76101500, 62212400, 126431973]
        \\      }]}
        \\    }],
        \\    "error": null
        \\  }
        \\}
    ;

    const allocator = std.testing.allocator;
    const quote = try parseChartQuote(allocator, body, "SPCX");

    // Today's (in-progress) bar is the last array entry, even though its
    // close is null.
    try std.testing.expectApproxEqAbs(@as(f64, 150.62), quote.open, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 158.40), quote.high, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 148.51), quote.low, 0.01);
    try std.testing.expectEqual(@as(u64, 126431973), quote.volume);
    // Previous close = yesterday's array close (153.00), NOT the stale
    // meta chartPreviousClose (185.00).
    try std.testing.expectApproxEqAbs(@as(f64, 153.00), quote.previous_close, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 153.23), quote.close, 0.01);
    // change = 153.23 - 153.00 = +0.23 (~+0.15%), not -17%.
    try std.testing.expectApproxEqAbs(@as(f64, 0.23), quote.change, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 0.150), quote.percent_change, 0.01);
}

test "parseChartQuote: market-closed day uses last close and prior day's close" {
    // After the close, the last array entry is a completed day (close
    // non-null) and matches regularMarketPrice. Previous close is the
    // day immediately before it, not two days back and not the stale
    // range close.
    const body =
        \\{
        \\  "chart": {
        \\    "result": [{
        \\      "meta": {
        \\        "symbol": "AAPL",
        \\        "regularMarketPrice": 105.00,
        \\        "chartPreviousClose": 90.00,
        \\        "fiftyTwoWeekHigh": 130.00,
        \\        "fiftyTwoWeekLow": 80.00
        \\      },
        \\      "timestamp": [1704067800, 1704154200, 1704240600],
        \\      "indicators": {"quote": [{
        \\        "open":   [99.00, 101.00, 104.00],
        \\        "high":   [100.00, 103.00, 106.00],
        \\        "low":    [98.00, 100.50, 103.50],
        \\        "close":  [100.00, 102.00, 105.00],
        \\        "volume": [1000000, 1100000, 1200000]
        \\      }]}
        \\    }],
        \\    "error": null
        \\  }
        \\}
    ;

    const allocator = std.testing.allocator;
    const quote = try parseChartQuote(allocator, body, "AAPL");

    // Current (last) completed bar.
    try std.testing.expectApproxEqAbs(@as(f64, 104.00), quote.open, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 106.00), quote.high, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 103.50), quote.low, 0.01);
    try std.testing.expectEqual(@as(u64, 1200000), quote.volume);
    // Previous close = the day before the last (102.00), not 100.00 and
    // not the stale meta 90.00.
    try std.testing.expectApproxEqAbs(@as(f64, 102.00), quote.previous_close, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 105.00), quote.close, 0.01);
    // change = 105.00 - 102.00 = +3.00
    try std.testing.expectApproxEqAbs(@as(f64, 3.00), quote.change, 0.01);
}

test "parseChartQuote falls back to price when indicators are absent" {
    const body =
        \\{
        \\  "chart": {
        \\    "result": [{
        \\      "meta": {
        \\        "symbol": "VFIAX",
        \\        "regularMarketPrice": 500.00,
        \\        "chartPreviousClose": 495.00,
        \\        "fiftyTwoWeekHigh": 520.00,
        \\        "fiftyTwoWeekLow": 400.00
        \\      },
        \\      "timestamp": []
        \\    }],
        \\    "error": null
        \\  }
        \\}
    ;

    const allocator = std.testing.allocator;
    const quote = try parseChartQuote(allocator, body, "VFIAX");

    // No indicators.quote arrays -> open/high/low fall back to price,
    // volume to 0. They must NOT pick up the 52-week range.
    try std.testing.expectApproxEqAbs(@as(f64, 500.00), quote.open, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 500.00), quote.high, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 500.00), quote.low, 0.01);
    try std.testing.expectEqual(@as(u64, 0), quote.volume);
    try std.testing.expectApproxEqAbs(@as(f64, 495.00), quote.previous_close, 0.01);
}

test "parseChartQuote: prefers meta.longName for the display name" {
    // The chart meta carries the real security name. longName is
    // preferred over shortName (which Yahoo often truncates / pads).
    const body =
        \\{
        \\  "chart": {
        \\    "result": [{
        \\      "meta": {
        \\        "symbol": "SPCX",
        \\        "longName": "Space Exploration Technologies Corp.",
        \\        "shortName": "Space Exploration Technologies ",
        \\        "regularMarketPrice": 153.23,
        \\        "chartPreviousClose": 153.00
        \\      },
        \\      "timestamp": [1782480600],
        \\      "indicators": {"quote": [{
        \\        "open": [150.62], "high": [158.40], "low": [148.51],
        \\        "close": [153.23], "volume": [126431973]
        \\      }]}
        \\    }],
        \\    "error": null
        \\  }
        \\}
    ;
    const allocator = std.testing.allocator;
    const quote = try parseChartQuote(allocator, body, "SPCX");
    try std.testing.expectEqualStrings("Space Exploration Technologies Corp.", quote.name());
}

test "parseChartQuote: falls back to trimmed shortName when longName absent" {
    // No longName -> use shortName, trimming Yahoo's trailing padding.
    const body =
        \\{
        \\  "chart": {
        \\    "result": [{
        \\      "meta": {
        \\        "symbol": "FOO",
        \\        "shortName": "Foo Industries ",
        \\        "regularMarketPrice": 10.00,
        \\        "chartPreviousClose": 9.50
        \\      },
        \\      "timestamp": [1782480600],
        \\      "indicators": {"quote": [{
        \\        "open": [9.6], "high": [10.2], "low": [9.4],
        \\        "close": [10.0], "volume": [1000]
        \\      }]}
        \\    }],
        \\    "error": null
        \\  }
        \\}
    ;
    const allocator = std.testing.allocator;
    const quote = try parseChartQuote(allocator, body, "FOO");
    try std.testing.expectEqualStrings("Foo Industries", quote.name());
}

test "parseChartQuote: no name fields leaves the name empty" {
    // When the meta carries neither longName nor shortName, the name
    // stays empty (callers fall back to symbol-only / other sources).
    const body =
        \\{
        \\  "chart": {
        \\    "result": [{
        \\      "meta": {
        \\        "symbol": "BAR",
        \\        "regularMarketPrice": 42.00,
        \\        "chartPreviousClose": 41.00
        \\      },
        \\      "timestamp": [1782480600],
        \\      "indicators": {"quote": [{
        \\        "open": [41.5], "high": [42.5], "low": [41.0],
        \\        "close": [42.0], "volume": [500]
        \\      }]}
        \\    }],
        \\    "error": null
        \\  }
        \\}
    ;
    const allocator = std.testing.allocator;
    const quote = try parseChartQuote(allocator, body, "BAR");
    try std.testing.expectEqualStrings("", quote.name());
}
