//! CBOE delayed quotes provider -- options chains from the exchange itself.
//! No API key required. Data is 15-minute delayed during market hours.
//!
//! Endpoint: GET https://cdn.cboe.com/api/global/delayed_quotes/options/{SYMBOL}.json
//!   Returns all expirations with full chains including greeks, bid/ask, volume, OI.

const std = @import("std");
const http = @import("../net/http.zig");
const RateLimiter = @import("../net/RateLimiter.zig");
const Date = @import("../Date.zig");
const OptionContract = @import("../models/option.zig").OptionContract;
const OptionsChain = @import("../models/option.zig").OptionsChain;
const ContractType = @import("../models/option.zig").ContractType;
const json_utils = @import("json_utils.zig");
const optFloat = json_utils.optFloat;
const optUint = json_utils.optUint;

const base_url = "https://cdn.cboe.com/api/global/delayed_quotes/options";

pub const Cboe = struct {
    client: http.Client,
    rate_limiter: RateLimiter,
    allocator: std.mem.Allocator,

    pub fn init(io: std.Io, allocator: std.mem.Allocator) Cboe {
        return .{
            .client = http.Client.init(io, allocator),
            .rate_limiter = RateLimiter.perMinute(io, 30),
            .allocator = allocator,
        };
    }

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

    /// Fetch the full options chain for a symbol (all expirations).
    /// Returns chains grouped by expiration date, sorted nearest-first.
    pub fn fetchOptionsChain(
        self: *Cboe,
        allocator: std.mem.Allocator,
        symbol: []const u8,
    ) ![]OptionsChain {
        self.rate_limiter.acquire();

        // Build URL: {base_url}/{SYMBOL}.json
        const url = try buildCboeUrl(allocator, symbol);
        defer allocator.free(url);

        // Request with Accept-Encoding: identity to avoid Zig 0.15 stdlib deflate panic
        // on malformed compressed responses from CBOE's CDN.
        var response = try self.client.request(.GET, url, null, &.{.{ .name = "Accept-Encoding", .value = "identity" }});
        defer response.deinit();

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

fn buildCboeUrl(allocator: std.mem.Allocator, symbol: []const u8) ![]const u8 {
    var aw: std.Io.Writer.Allocating = .init(allocator);
    errdefer aw.deinit();

    try aw.writer.writeAll(base_url);
    try aw.writer.writeByte('/');
    try aw.writer.writeAll(symbol);
    try aw.writer.writeAll(".json");

    return aw.toOwnedSlice();
}

/// Parse a CBOE options response into grouped OptionsChain slices.
fn parseResponse(
    allocator: std.mem.Allocator,
    body: []const u8,
    symbol: []const u8,
) ![]OptionsChain {
    const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch
        return error.ParseError;
    defer parsed.deinit();

    const root = switch (parsed.value) {
        .object => |o| o,
        else => return error.ParseError,
    };

    const data_obj = switch (root.get("data") orelse return error.ParseError) {
        .object => |o| o,
        else => return error.ParseError,
    };

    const underlying_price: ?f64 = if (data_obj.get("current_price")) |v| optFloat(v) else null;

    const options_arr = switch (data_obj.get("options") orelse return error.ParseError) {
        .array => |a| a.items,
        else => return error.ParseError,
    };

    // Parse all contracts and group by expiration.
    // Use an ArrayList of (expiration, calls_list, puts_list) tuples.
    var exp_map = ExpMap{};

    for (options_arr) |item| {
        const obj = switch (item) {
            .object => |o| o,
            else => continue,
        };

        const occ_sym = switch (obj.get("option") orelse continue) {
            .string => |s| s,
            else => continue,
        };

        const occ = parseOccSymbol(occ_sym, symbol.len) orelse continue;

        const contract = OptionContract{
            .contract_type = occ.contract_type,
            .strike = occ.strike,
            .expiration = occ.expiration,
            .bid = optFloat(obj.get("bid")),
            .ask = optFloat(obj.get("ask")),
            .last_price = optFloat(obj.get("last_trade_price")),
            .volume = optUint(obj.get("volume")),
            .open_interest = optUint(obj.get("open_interest")),
            .implied_volatility = optFloat(obj.get("iv")),
            .delta = optFloat(obj.get("delta")),
            .gamma = optFloat(obj.get("gamma")),
            .theta = optFloat(obj.get("theta")),
            .vega = optFloat(obj.get("vega")),
        };

        // Find or create the expiration bucket
        const bucket = try exp_map.getOrPut(allocator, occ.expiration);

        switch (occ.contract_type) {
            .call => try bucket.calls.append(allocator, contract),
            .put => try bucket.puts.append(allocator, contract),
        }
    }

    // Convert to sorted OptionsChain slice
    // Each chain gets its own dupe of the symbol so callers can free them independently.
    return exp_map.toOwnedChains(allocator, symbol, underlying_price);
}

// ── OCC symbol parsing ──────────────────────────────────────────────

const OccInfo = struct {
    expiration: Date,
    contract_type: ContractType,
    strike: f64,
};

/// Parse OCC option symbol: AAPL260225C00205000
/// Format: {underlying}{YYMMDD}{C|P}{strike * 1000, zero-padded to 8 digits}
fn parseOccSymbol(sym: []const u8, underlying_len: usize) ?OccInfo {
    // After underlying: 6 digits date + 1 char type + 8 digits strike = 15 chars
    if (sym.len < underlying_len + 15) return null;

    const rest = sym[underlying_len..];

    // Parse date: YYMMDD
    const yy = std.fmt.parseInt(i16, rest[0..2], 10) catch return null;
    const mm = std.fmt.parseInt(u8, rest[2..4], 10) catch return null;
    const dd = std.fmt.parseInt(u8, rest[4..6], 10) catch return null;
    const expiration = Date.fromYmd(2000 + yy, mm, dd);

    // Parse type
    const contract_type: ContractType = switch (rest[6]) {
        'C' => .call,
        'P' => .put,
        else => return null,
    };

    // Parse strike: 8 digits, divide by 1000
    const strike_raw = std.fmt.parseInt(u64, rest[7..15], 10) catch return null;
    const strike: f64 = @as(f64, @floatFromInt(strike_raw)) / 1000.0;

    return .{
        .expiration = expiration,
        .contract_type = contract_type,
        .strike = strike,
    };
}

// ── Expiration grouping ─────────────────────────────────────────────

/// Maps expiration dates to call/put ArrayLists. Uses a simple sorted array
/// since the number of expirations is small (typically 10-30).
const ExpMap = struct {
    entries: std.ArrayList(Entry) = .empty,

    const Entry = struct {
        expiration: Date,
        calls: std.ArrayList(OptionContract) = .empty,
        puts: std.ArrayList(OptionContract) = .empty,
    };

    const Bucket = struct {
        calls: *std.ArrayList(OptionContract),
        puts: *std.ArrayList(OptionContract),
    };

    fn getOrPut(self: *ExpMap, allocator: std.mem.Allocator, exp: Date) !Bucket {
        for (self.entries.items) |*entry| {
            if (entry.expiration.eql(exp)) {
                return .{ .calls = &entry.calls, .puts = &entry.puts };
            }
        }
        try self.entries.append(allocator, .{ .expiration = exp });
        const last = &self.entries.items[self.entries.items.len - 1];
        return .{ .calls = &last.calls, .puts = &last.puts };
    }

    /// Convert to owned []OptionsChain, sorted by expiration ascending.
    /// Frees internal structures; caller owns the returned chains.
    /// Each chain gets its own dupe of `symbol` so callers can free them independently.
    fn toOwnedChains(
        self: *ExpMap,
        allocator: std.mem.Allocator,
        symbol: []const u8,
        underlying_price: ?f64,
    ) ![]OptionsChain {
        // Sort entries by expiration
        std.mem.sort(Entry, self.entries.items, {}, struct {
            fn lessThan(_: void, a: Entry, b: Entry) bool {
                return a.expiration.lessThan(b.expiration);
            }
        }.lessThan);

        var chains = try allocator.alloc(OptionsChain, self.entries.items.len);

        var initialized: usize = 0;
        errdefer {
            for (chains[0..initialized]) |c| {
                allocator.free(c.underlying_symbol);
                allocator.free(c.calls);
                allocator.free(c.puts);
            }
            allocator.free(chains);
        }

        for (self.entries.items, 0..) |*entry, i| {
            const owned_symbol = try allocator.dupe(u8, symbol);
            errdefer allocator.free(owned_symbol);
            const calls = try entry.calls.toOwnedSlice(allocator);
            errdefer allocator.free(calls);
            const puts = try entry.puts.toOwnedSlice(allocator);

            chains[i] = .{
                .underlying_symbol = owned_symbol,
                .underlying_price = underlying_price,
                .expiration = entry.expiration,
                .calls = calls,
                .puts = puts,
            };
            initialized += 1;
        }

        self.entries.deinit(allocator);

        return chains;
    }
};

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

test "parseOccSymbol -- call" {
    const info = parseOccSymbol("AAPL260225C00205000", 4).?;
    try std.testing.expect(info.contract_type == .call);
    try std.testing.expect(info.expiration.eql(Date.fromYmd(2026, 2, 25)));
    try std.testing.expectApproxEqAbs(@as(f64, 205.0), info.strike, 0.001);
}

test "parseOccSymbol -- put" {
    const info = parseOccSymbol("AMZN260306P00180000", 4).?;
    try std.testing.expect(info.contract_type == .put);
    try std.testing.expect(info.expiration.eql(Date.fromYmd(2026, 3, 6)));
    try std.testing.expectApproxEqAbs(@as(f64, 180.0), info.strike, 0.001);
}

test "parseOccSymbol -- fractional strike" {
    const info = parseOccSymbol("SPY260227C00555500", 3).?;
    try std.testing.expectApproxEqAbs(@as(f64, 555.5), info.strike, 0.001);
}

test "parseOccSymbol -- invalid" {
    try std.testing.expect(parseOccSymbol("X", 1) == null);
    try std.testing.expect(parseOccSymbol("AAPL26022", 4) == null);
}

test "parseResponse basic" {
    const body =
        \\{
        \\  "data": {
        \\    "current_price": 185.50,
        \\    "options": [
        \\      {
        \\        "option": "AAPL260320C00180000",
        \\        "bid": 7.50,
        \\        "ask": 7.80,
        \\        "last_trade_price": 7.65,
        \\        "volume": 1234,
        \\        "open_interest": 5678,
        \\        "iv": 0.25,
        \\        "delta": 0.65
        \\      },
        \\      {
        \\        "option": "AAPL260320P00180000",
        \\        "bid": 2.10,
        \\        "ask": 2.30
        \\      }
        \\    ]
        \\  }
        \\}
    ;

    const allocator = std.testing.allocator;
    const chains = try parseResponse(allocator, body, "AAPL");
    defer {
        for (chains) |chain| {
            allocator.free(chain.underlying_symbol);
            allocator.free(chain.calls);
            allocator.free(chain.puts);
        }
        allocator.free(chains);
    }

    try std.testing.expectEqual(@as(usize, 1), chains.len);
    try std.testing.expectApproxEqAbs(@as(f64, 185.50), chains[0].underlying_price.?, 0.01);
    try std.testing.expect(chains[0].expiration.eql(Date.fromYmd(2026, 3, 20)));

    try std.testing.expectEqual(@as(usize, 1), chains[0].calls.len);
    try std.testing.expectApproxEqAbs(@as(f64, 180.0), chains[0].calls[0].strike, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 7.50), chains[0].calls[0].bid.?, 0.01);
    try std.testing.expectEqual(@as(?u64, 1234), chains[0].calls[0].volume);
    try std.testing.expectApproxEqAbs(@as(f64, 0.25), chains[0].calls[0].implied_volatility.?, 0.01);

    try std.testing.expectEqual(@as(usize, 1), chains[0].puts.len);
    try std.testing.expectApproxEqAbs(@as(f64, 2.10), chains[0].puts[0].bid.?, 0.01);
}

test "parseResponse missing data" {
    const body =
        \\{"data": {}}
    ;

    const allocator = std.testing.allocator;
    const result = parseResponse(allocator, body, "AAPL");
    try std.testing.expectError(error.ParseError, result);
}
