//! Polygon.io API provider -- primary source for dividend/split reference data.
//! API docs: https://polygon.io/docs
//!
//! Free tier: 5 requests/min, unlimited daily.
//! Dividends and splits are available for all history.

const std = @import("std");
const http = @import("../net/http.zig");
const RateLimiter = @import("../net/RateLimiter.zig");
const Date = @import("../Date.zig");
const Dividend = @import("../models/dividend.zig").Dividend;
const DividendType = @import("../models/dividend.zig").DividendType;
const Split = @import("../models/split.zig").Split;
const json_utils = @import("json_utils.zig");
const parseJsonFloat = json_utils.parseJsonFloat;
const jsonStr = json_utils.jsonStr;

const base_url = "https://api.polygon.io";

pub const Polygon = struct {
    api_key: []const u8,
    client: http.Client,
    rate_limiter: RateLimiter,
    allocator: std.mem.Allocator,

    pub fn init(io: std.Io, allocator: std.mem.Allocator, api_key: []const u8) Polygon {
        return .{
            .api_key = api_key,
            .client = http.Client.init(io, allocator),
            .rate_limiter = RateLimiter.perMinute(io, 4),
            .allocator = allocator,
        };
    }

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

    /// Fetch dividend history for a ticker. Results sorted oldest-first by ex_date.
    /// Polygon endpoint: GET /v3/reference/dividends?ticker=X&ex_dividend_date.gte=YYYY-MM-DD&...
    pub fn fetchDividends(
        self: *Polygon,
        allocator: std.mem.Allocator,
        symbol: []const u8,
        from: ?Date,
        to: ?Date,
    ) ![]Dividend {
        var all_dividends: std.ArrayList(Dividend) = .empty;
        errdefer {
            for (all_dividends.items) |d| d.deinit(allocator);
            all_dividends.deinit(allocator);
        }

        var next_url: ?[]const u8 = null;
        defer if (next_url) |u| allocator.free(u);

        // First request
        {
            self.rate_limiter.acquire();

            var params: [5][2][]const u8 = undefined;
            var n: usize = 0;

            params[n] = .{ "ticker", symbol };
            n += 1;
            params[n] = .{ "limit", "1000" };
            n += 1;
            params[n] = .{ "sort", "ex_dividend_date" };
            n += 1;

            var from_buf: [10]u8 = undefined;
            var to_buf: [10]u8 = undefined;

            if (from) |f| {
                params[n] = .{ "ex_dividend_date.gte", try std.fmt.bufPrint(&from_buf, "{f}", .{f}) };
                n += 1;
            }
            if (to) |t| {
                params[n] = .{ "ex_dividend_date.lte", try std.fmt.bufPrint(&to_buf, "{f}", .{t}) };
                n += 1;
            }

            const url = try http.buildUrl(allocator, base_url ++ "/v3/reference/dividends", params[0..n]);
            defer allocator.free(url);

            const authed = try appendApiKey(allocator, url, self.api_key);
            defer allocator.free(authed);

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

            next_url = try parseDividendsPage(allocator, response.body, &all_dividends);
        }

        // Paginate
        while (next_url) |cursor_url| {
            self.rate_limiter.acquire();

            const authed = try appendApiKey(allocator, cursor_url, self.api_key);
            defer allocator.free(authed);

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

            // Free the cursor URL we just consumed and clear next_url
            // BEFORE attempting to parse. If parseDividendsPage errors,
            // the function-scope defer at the top must not see a
            // dangling pointer in next_url -- otherwise it double-frees
            // the buffer we just released. The new next_url (if any)
            // gets assigned below on success.
            allocator.free(cursor_url);
            next_url = null;
            next_url = try parseDividendsPage(allocator, response.body, &all_dividends);
        }

        return try all_dividends.toOwnedSlice(allocator);
    }

    /// Fetch split history for a ticker. Results sorted oldest-first.
    /// Polygon endpoint: GET /v3/reference/splits?ticker=X&...
    pub fn fetchSplits(
        self: *Polygon,
        allocator: std.mem.Allocator,
        symbol: []const u8,
    ) ![]Split {
        self.rate_limiter.acquire();

        const url = try http.buildUrl(allocator, base_url ++ "/v3/reference/splits", &.{
            .{ "ticker", symbol },
            .{ "limit", "1000" },
            .{ "sort", "execution_date" },
        });
        defer allocator.free(url);

        const authed = try appendApiKey(allocator, url, self.api_key);
        defer allocator.free(authed);

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

        return parseSplitsResponse(allocator, response.body);
    }
};

// -- JSON parsing --

fn parseDividendsPage(
    allocator: std.mem.Allocator,
    body: []const u8,
    out: *std.ArrayList(Dividend),
) !?[]const u8 {
    const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch
        return error.ParseError;
    defer parsed.deinit();

    const root = parsed.value.object;

    // Check status
    if (root.get("status")) |s| {
        if (s == .string and std.mem.eql(u8, s.string, "ERROR"))
            return error.RequestFailed;
    }

    const results = root.get("results") orelse return null;
    const items = switch (results) {
        .array => |a| a.items,
        else => return null,
    };

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

        const ex_date = blk: {
            const v = obj.get("ex_dividend_date") orelse continue;
            const s = switch (v) {
                .string => |str| str,
                else => continue,
            };
            break :blk Date.parse(s) catch continue;
        };

        const amount = parseJsonFloat(obj.get("cash_amount"));
        if (amount <= 0) continue;

        try out.append(allocator, .{
            .ex_date = ex_date,
            .amount = amount,
            .pay_date = parseDateField(obj, "pay_date"),
            .record_date = parseDateField(obj, "record_date"),
            .type = parseDividendType(obj),
            .currency = if (jsonStr(obj.get("currency"))) |s|
                (allocator.dupe(u8, s) catch null)
            else
                null,
        });
    }

    // Check for next_url (pagination cursor)
    if (root.get("next_url")) |nu| {
        const url_str = switch (nu) {
            .string => |s| s,
            else => return null,
        };
        const duped = try allocator.dupe(u8, url_str);
        return duped;
    }

    return null;
}

fn parseSplitsResponse(allocator: std.mem.Allocator, body: []const u8) ![]Split {
    const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch
        return error.ParseError;
    defer parsed.deinit();

    const root = parsed.value.object;

    if (root.get("status")) |s| {
        if (s == .string and std.mem.eql(u8, s.string, "ERROR"))
            return error.RequestFailed;
    }

    const results = root.get("results") orelse {
        return try allocator.alloc(Split, 0);
    };
    const items = switch (results) {
        .array => |a| a.items,
        else => {
            return try allocator.alloc(Split, 0);
        },
    };

    var splits: std.ArrayList(Split) = .empty;
    errdefer splits.deinit(allocator);

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

        const date = blk: {
            const v = obj.get("execution_date") orelse continue;
            const s = switch (v) {
                .string => |str| str,
                else => continue,
            };
            break :blk Date.parse(s) catch continue;
        };

        try splits.append(allocator, .{
            .date = date,
            .numerator = parseJsonFloat(obj.get("split_to")),
            .denominator = parseJsonFloat(obj.get("split_from")),
        });
    }

    return try splits.toOwnedSlice(allocator);
}

// -- Helpers --

fn appendApiKey(allocator: std.mem.Allocator, url: []const u8, api_key: []const u8) ![]const u8 {
    const sep: u8 = if (std.mem.indexOfScalar(u8, url, '?') != null) '&' else '?';
    return std.fmt.allocPrint(allocator, "{s}{c}apiKey={s}", .{ url, sep, api_key });
}

fn parseDateField(obj: std.json.ObjectMap, key: []const u8) ?Date {
    const v = obj.get(key) orelse return null;
    const s = switch (v) {
        .string => |str| str,
        else => return null,
    };
    return Date.parse(s) catch null;
}

fn parseDividendType(obj: std.json.ObjectMap) DividendType {
    const v = obj.get("dividend_type") orelse return .unknown;
    const s = switch (v) {
        .string => |str| str,
        else => return .unknown,
    };
    if (std.mem.eql(u8, s, "CD")) return .regular;
    if (std.mem.eql(u8, s, "SC")) return .special;
    if (std.mem.eql(u8, s, "LT") or std.mem.eql(u8, s, "ST")) return .regular;
    return .unknown;
}

// -- Tests --

test "parseDividendsPage basic" {
    const body =
        \\{
        \\  "status": "OK",
        \\  "results": [
        \\    {
        \\      "ex_dividend_date": "2024-08-12",
        \\      "cash_amount": 0.25,
        \\      "pay_date": "2024-08-15",
        \\      "record_date": "2024-08-13",
        \\      "frequency": 4,
        \\      "dividend_type": "CD",
        \\      "currency": "USD"
        \\    },
        \\    {
        \\      "ex_dividend_date": "2024-05-10",
        \\      "cash_amount": 0.25,
        \\      "dividend_type": "SC"
        \\    }
        \\  ]
        \\}
    ;

    const allocator = std.testing.allocator;
    var out = std.ArrayList(Dividend).empty;
    defer {
        for (out.items) |d| d.deinit(allocator);
        out.deinit(allocator);
    }

    const next_url = try parseDividendsPage(allocator, body, &out);
    try std.testing.expect(next_url == null);

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

    try std.testing.expect(out.items[0].ex_date.eql(Date.fromYmd(2024, 8, 12)));
    try std.testing.expectApproxEqAbs(@as(f64, 0.25), out.items[0].amount, 0.001);
    try std.testing.expect(out.items[0].pay_date != null);
    try std.testing.expectEqual(DividendType.regular, out.items[0].type);

    try std.testing.expectEqual(DividendType.special, out.items[1].type);
}

test "parseDividendsPage with pagination" {
    const body =
        \\{
        \\  "status": "OK",
        \\  "results": [
        \\    {"ex_dividend_date": "2024-01-10", "cash_amount": 0.50}
        \\  ],
        \\  "next_url": "https://api.polygon.io/v3/reference/dividends?cursor=abc123"
        \\}
    ;

    const allocator = std.testing.allocator;
    var out = std.ArrayList(Dividend).empty;
    defer {
        for (out.items) |d| d.deinit(allocator);
        out.deinit(allocator);
    }

    const next_url = try parseDividendsPage(allocator, body, &out);
    try std.testing.expect(next_url != null);
    defer allocator.free(next_url.?);

    try std.testing.expectEqualStrings("https://api.polygon.io/v3/reference/dividends?cursor=abc123", next_url.?);
    try std.testing.expectEqual(@as(usize, 1), out.items.len);
}

test "parseDividendsPage error status" {
    const body =
        \\{"status": "ERROR", "error": "Bad request"}
    ;

    const allocator = std.testing.allocator;
    var out = std.ArrayList(Dividend).empty;
    defer {
        for (out.items) |d| d.deinit(allocator);
        out.deinit(allocator);
    }

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

test "parseSplitsResponse basic" {
    const body =
        \\{
        \\  "status": "OK",
        \\  "results": [
        \\    {"execution_date": "2020-08-31", "split_to": 4, "split_from": 1},
        \\    {"execution_date": "2014-06-09", "split_to": 7, "split_from": 1}
        \\  ]
        \\}
    ;

    const allocator = std.testing.allocator;
    const splits = try parseSplitsResponse(allocator, body);
    defer allocator.free(splits);

    try std.testing.expectEqual(@as(usize, 2), splits.len);
    try std.testing.expect(splits[0].date.eql(Date.fromYmd(2020, 8, 31)));
    try std.testing.expectApproxEqAbs(@as(f64, 4.0), splits[0].numerator, 0.001);
    try std.testing.expectApproxEqAbs(@as(f64, 1.0), splits[0].denominator, 0.001);
}

test "parseSplitsResponse empty results" {
    const body =
        \\{"status": "OK", "results": []}
    ;

    const allocator = std.testing.allocator;
    const splits = try parseSplitsResponse(allocator, body);
    defer allocator.free(splits);

    try std.testing.expectEqual(@as(usize, 0), splits.len);
}
