//! Financial Modeling Prep (FMP) provider — earnings data (actuals + estimates).
//!
//! Endpoint: GET https://financialmodelingprep.com/stable/earnings?symbol=X&apikey=KEY
//!
//! Free tier: 250 requests/day. With the 30-day earnings cache TTL, a 50-symbol
//! portfolio hits this endpoint ~2 times/day on average.
//!
//! Coverage on the free tier:
//!   - Individual US stocks: full history (often back to the 1980s)
//!   - ETFs/mutual funds/CUSIPs: 402 Payment Required (they don't have earnings anyway)
//!   - Dual-class shares (BRK.B, GOOG): 402 — documented limitation, free-tier only
//!   - Stale stubs: a few symbols (e.g. SPY) return 200 with all-null records;
//!     we treat those the same as "no data".
//!
//! Status-code mapping:
//!   - 200 → parse
//!   - 402 (PaymentRequired) → empty slice (treated as "no data")
//!   - NotFound, RateLimited, etc. → bubble up as HttpError for caller to handle
//!
//! Response record shape:
//!   {symbol, date, epsActual, epsEstimated, revenueActual, revenueEstimated, lastUpdated}
//!   `date` is the *announcement* date, not period-end. We pass it through as
//!   `EarningsEvent.date` — that's what shows up on earnings calendars
//!   everywhere, so users recognize it immediately. Calendar quarter and
//!   fiscal year are derived from `date.subtractMonths(1)`, which maps to
//!   the reporting period's calendar quarter for both calendar-year and
//!   fiscal-year filers (an announcement in month M typically reports the
//!   period that ended in month M-1).

const std = @import("std");
const http = @import("../net/http.zig");
const RateLimiter = @import("../net/RateLimiter.zig");
const Date = @import("../Date.zig");
const EarningsEvent = @import("../models/earnings.zig").EarningsEvent;
const json_utils = @import("json_utils.zig");
const optFloat = json_utils.optFloat;
const jsonStr = json_utils.jsonStr;

const base_url = "https://financialmodelingprep.com/stable";

pub const Fmp = 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) Fmp {
        return .{
            .api_key = api_key,
            .client = http.Client.init(io, allocator),
            .rate_limiter = RateLimiter.perDay(io, 250),
            .allocator = allocator,
        };
    }

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

    /// Fetch earnings events for a symbol. Returns an empty slice for symbols
    /// FMP doesn't cover on the current plan (402) or that genuinely have no
    /// earnings data. Newest-first sorted.
    pub fn fetchEarnings(
        self: *Fmp,
        allocator: std.mem.Allocator,
        symbol: []const u8,
    ) ![]EarningsEvent {
        self.rate_limiter.acquire();

        const url = try http.buildUrl(allocator, base_url ++ "/earnings", &.{
            .{ "symbol", symbol },
            .{ "apikey", self.api_key },
        });
        defer allocator.free(url);

        var response = self.client.get(url) catch |err| switch (err) {
            // Symbol not covered by caller's plan (common for ETFs, mutual
            // funds, dual-class shares). Treat the same as "no data".
            error.PaymentRequired => return allocator.alloc(EarningsEvent, 0),
            else => |e| return e,
        };
        defer response.deinit();

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

// ── JSON parsing ──────────────────────────────────────────────

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

    // Error envelopes come back as `{"Error Message": "..."}` with 200 status.
    // Treat any non-array top-level as empty.
    const items = switch (parsed.value) {
        .array => |a| a.items,
        else => return allocator.alloc(EarningsEvent, 0),
    };

    var events: std.ArrayList(EarningsEvent) = .empty;
    errdefer {
        for (events.items) |e| e.deinit(allocator);
        events.deinit(allocator);
    }

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

        const date_str = jsonStr(obj.get("date")) orelse continue;
        const date = Date.parse(date_str) catch continue;

        const actual = optFloat(obj.get("epsActual"));
        const estimate = optFloat(obj.get("epsEstimated"));

        // Skip completely-empty stub records (some symbols like SPY return
        // many rows with nothing useful).
        if (actual == null and estimate == null) continue;

        const surprise: ?f64 = if (actual != null and estimate != null)
            actual.? - estimate.?
        else
            null;
        const surprise_pct: ?f64 = if (surprise != null and estimate != null and estimate.? != 0)
            (surprise.? / @abs(estimate.?)) * 100.0
        else
            null;

        // Derive calendar quarter / fiscal year from the reporting period, not
        // the announcement date. Subtracting one month from the announcement
        // maps into the calendar quarter of the period being reported for both
        // calendar-year (e.g. AMZN 2026-04-29 → March 2026 → Q1 2026) and
        // fiscal-year (e.g. AAPL 2026-01-30 → December 2025 → Q4 2025) filers.
        const period_anchor = date.subtractMonths(1);
        const quarter: u8 = @intCast(((period_anchor.month() - 1) / 3) + 1);

        // Each event owns its own copy of `symbol`. The cache-read path
        // dupes string fields into the caller's allocator, so the provider
        // path must too — otherwise the returned slice has mixed ownership
        // and no single `freeSlice` can release it correctly. See
        // `EarningsEvent.deinit`.
        const owned_symbol = try allocator.dupe(u8, symbol);
        events.append(allocator, .{
            .symbol = owned_symbol,
            .date = date,
            .estimate = estimate,
            .actual = actual,
            .surprise = surprise,
            .surprise_percent = surprise_pct,
            .quarter = quarter,
            .fiscal_year = period_anchor.year(),
            .revenue_actual = optFloat(obj.get("revenueActual")),
            .revenue_estimate = optFloat(obj.get("revenueEstimated")),
            // FMP's /stable/earnings endpoint doesn't expose BMO/AMC timing.
            .report_time = .unknown,
        }) catch |err| {
            allocator.free(owned_symbol);
            return err;
        };
    }

    // Newest-first, matching the UI's expectation.
    std.mem.sort(EarningsEvent, events.items, {}, struct {
        fn lt(_: void, a: EarningsEvent, b: EarningsEvent) bool {
            return a.date.days > b.date.days;
        }
    }.lt);

    return events.toOwnedSlice(allocator);
}

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

const testing = std.testing;

test "parseEarningsResponse: typical response (AMZN-style)" {
    // Shape matches the real /stable/earnings response — announcement dates,
    // both actual and estimate present, plus an upcoming quarter with actual=null.
    const body =
        \\[
        \\  {"symbol": "AMZN", "date": "2026-07-30", "epsActual": null, "epsEstimated": 1.76, "revenueActual": null, "revenueEstimated": 193981500000, "lastUpdated": "2026-04-30"},
        \\  {"symbol": "AMZN", "date": "2026-04-29", "epsActual": 2.78, "epsEstimated": 1.63, "revenueActual": 181519000000, "revenueEstimated": 177281700000, "lastUpdated": "2026-04-30"},
        \\  {"symbol": "AMZN", "date": "2026-02-05", "epsActual": 1.95, "epsEstimated": 1.97, "revenueActual": 213386000000, "revenueEstimated": 211454800000, "lastUpdated": "2026-04-30"}
        \\]
    ;

    const events = try parseEarningsResponse(testing.allocator, body, "AMZN");
    defer EarningsEvent.freeSlice(testing.allocator, events);

    try testing.expectEqual(@as(usize, 3), events.len);

    // Newest first
    try testing.expect(events[0].date.eql(Date.fromYmd(2026, 7, 30)));
    try testing.expect(events[0].actual == null);
    try testing.expectApproxEqAbs(@as(f64, 1.76), events[0].estimate.?, 0.001);
    try testing.expect(events[0].surprise == null);

    // The big beat quarter
    try testing.expect(events[1].date.eql(Date.fromYmd(2026, 4, 29)));
    try testing.expectApproxEqAbs(@as(f64, 2.78), events[1].actual.?, 0.001);
    try testing.expectApproxEqAbs(@as(f64, 1.63), events[1].estimate.?, 0.001);
    try testing.expectApproxEqAbs(@as(f64, 1.15), events[1].surprise.?, 0.001);
    // (0.345 / |1.605|) ≈ 70%... actually (1.15 / 1.63) * 100 ≈ 70.55
    try testing.expectApproxEqAbs(@as(f64, 70.55), events[1].surprise_percent.?, 0.5);
    try testing.expectApproxEqAbs(@as(f64, 181519000000), events[1].revenue_actual.?, 1);

    // Calendar quarter is Q1 2026 (announcement in April → reports March period)
    try testing.expectEqual(@as(?u8, 1), events[1].quarter);
    try testing.expectEqual(@as(?i16, 2026), events[1].fiscal_year);
}

test "parseEarningsResponse: AAPL fiscal-year company quarter mapping" {
    // AAPL's fiscal Q1 FY2026 announcement is Jan 30 2026 → reports period
    // ending Dec 27 2025 → calendar Q4 2025. Subtracting one month from the
    // announcement must land in Q4.
    const body =
        \\[
        \\  {"symbol": "AAPL", "date": "2026-01-30", "epsActual": 2.85, "epsEstimated": 2.67, "revenueActual": 143756000000, "revenueEstimated": 138391000000, "lastUpdated": "2026-04-29"}
        \\]
    ;

    const events = try parseEarningsResponse(testing.allocator, body, "AAPL");
    defer EarningsEvent.freeSlice(testing.allocator, events);

    try testing.expectEqual(@as(usize, 1), events.len);
    try testing.expectEqual(@as(?u8, 4), events[0].quarter);
    try testing.expectEqual(@as(?i16, 2025), events[0].fiscal_year);
}

test "parseEarningsResponse: skips empty stub records" {
    // SPY returns 200 with a bunch of rows that have null for EVERY numeric
    // field. These are useless — don't clutter the UI with them.
    const body =
        \\[
        \\  {"symbol": "SPY", "date": "2017-11-29", "epsActual": null, "epsEstimated": null, "revenueActual": null, "revenueEstimated": null, "lastUpdated": "2025-04-25"},
        \\  {"symbol": "SPY", "date": "2017-08-15", "epsActual": null, "epsEstimated": null, "revenueActual": null, "revenueEstimated": null, "lastUpdated": "2025-04-25"}
        \\]
    ;

    const events = try parseEarningsResponse(testing.allocator, body, "SPY");
    defer EarningsEvent.freeSlice(testing.allocator, events);

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

test "parseEarningsResponse: keeps records with only estimate (upcoming)" {
    // An upcoming quarter has estimate but no actual. That's valuable — keep it.
    const body =
        \\[
        \\  {"symbol": "X", "date": "2026-07-30", "epsActual": null, "epsEstimated": 1.76, "revenueActual": null, "revenueEstimated": 1e9, "lastUpdated": "2026-04-30"}
        \\]
    ;
    const events = try parseEarningsResponse(testing.allocator, body, "X");
    defer EarningsEvent.freeSlice(testing.allocator, events);

    try testing.expectEqual(@as(usize, 1), events.len);
    try testing.expect(events[0].actual == null);
    try testing.expectApproxEqAbs(@as(f64, 1.76), events[0].estimate.?, 0.001);
    try testing.expect(events[0].surprise == null);
}

test "parseEarningsResponse: keeps records with only actual (no estimate)" {
    // Very old records pre-date analyst estimates. Still useful as historical
    // actuals — don't throw them away.
    const body =
        \\[
        \\  {"symbol": "AAPL", "date": "1985-09-30", "epsActual": 0.00161, "epsEstimated": null, "revenueActual": 409700000, "revenueEstimated": null, "lastUpdated": "2026-03-23"}
        \\]
    ;
    const events = try parseEarningsResponse(testing.allocator, body, "AAPL");
    defer EarningsEvent.freeSlice(testing.allocator, events);

    try testing.expectEqual(@as(usize, 1), events.len);
    try testing.expectApproxEqAbs(@as(f64, 0.00161), events[0].actual.?, 1e-6);
    try testing.expect(events[0].estimate == null);
    try testing.expect(events[0].surprise == null);
}

test "parseEarningsResponse: error envelope returns empty" {
    // FMP emits `{"Error Message": "..."}` with 200 status for some malformed
    // queries. We treat any non-array body as "no data" rather than error.
    const body =
        \\{"Error Message": "API limit reached"}
    ;
    const events = try parseEarningsResponse(testing.allocator, body, "X");
    defer EarningsEvent.freeSlice(testing.allocator, events);
    try testing.expectEqual(@as(usize, 0), events.len);
}

test "parseEarningsResponse: empty array" {
    const events = try parseEarningsResponse(testing.allocator, "[]", "X");
    defer EarningsEvent.freeSlice(testing.allocator, events);
    try testing.expectEqual(@as(usize, 0), events.len);
}

test "parseEarningsResponse: malformed JSON returns ParseError" {
    const result = parseEarningsResponse(testing.allocator, "not json", "X");
    try testing.expectError(error.ParseError, result);
}

test "parseEarningsResponse: surprise_percent handles zero estimate" {
    // Edge case: estimate is 0 (rare but possible). We should not divide by zero.
    const body =
        \\[
        \\  {"symbol": "X", "date": "2025-04-30", "epsActual": 0.05, "epsEstimated": 0.0, "revenueActual": null, "revenueEstimated": null, "lastUpdated": "2025-05-01"}
        \\]
    ;
    const events = try parseEarningsResponse(testing.allocator, body, "X");
    defer EarningsEvent.freeSlice(testing.allocator, events);

    try testing.expectEqual(@as(usize, 1), events.len);
    try testing.expectApproxEqAbs(@as(f64, 0.05), events[0].surprise.?, 0.001);
    try testing.expect(events[0].surprise_percent == null);
}
