const std = @import("std");

const log = std.log.scoped(.http);

/// Public error set for the HTTP client wrapper. Merges the stdlib
/// transport error sets directly so that callers see meaningful Zig
/// error names (`NoAddressReturned`, `ConnectionRefused`,
/// `EndOfStream`, `TlsInitializationFailed`, ...) at the boundary
/// instead of a single opaque `RequestFailed`.
///
/// Why this shape, not `anyerror` and not a hand-rolled flat set:
///   - Callers get exhaustive-switch checking from the compiler when
///     they need to discriminate between, say, DNS failures and
///     connection failures.
///   - The error names propagate through `@errorName(err)` to log
///     lines without per-stage translation tables. An operator
///     reading "DnsLookupFailed" or "ConnectFailed" learns less
///     than one reading "NoAddressReturned" — the stdlib variant
///     is the truth, ours would be a lossy summary.
///   - The set is the same shape as stdlib's own
///     `http.Client.ConnectError = ConnectTcpError || RequestError`
///     pattern. Zig dedupes shared variants automatically.
///
/// HTTP-status classifications (`RateLimited`, `NotFound`, etc.) live
/// alongside the transport variants because callers consume both via
/// the same `try` sites and the same error switches.
pub const HttpError = std.Uri.ParseError ||
    std.http.Client.RequestError ||
    std.http.Client.Request.ReceiveHeadError ||
    std.Io.Writer.Error ||
    std.Io.Reader.Error ||
    std.mem.Allocator.Error ||
    error{
        /// Last-resort transport failure. Used only when we cannot
        /// recover the underlying stdlib error (e.g., across the
        /// retry boundary in `request()` after multiple distinct
        /// failures collapsed into a single retry exhaustion).
        /// Adding new uses of this variant is a smell — prefer
        /// preserving the real error.
        RequestFailed,
        // ── HTTP status classifications (no stdlib equivalent) ──
        RateLimited,
        Unauthorized,
        NotFound,
        /// HTTP 402 Payment Required — used by FMP to mark symbols (mainly ETFs,
        /// mutual funds, CUSIPs, and some dual-class shares) that aren't covered
        /// by the caller's current plan. Providers should translate this into
        /// "no data" rather than a hard failure.
        PaymentRequired,
        ServerError,
        InvalidResponse,
    };

pub const Response = struct {
    status: std.http.Status,
    body: []const u8,
    /// Raw `ETag` header value from the server, if present. Owned by the
    /// same allocator as `body`. Captured verbatim (including quotes and
    /// any `sha256:` scheme prefix) so diagnostic archival can record
    /// it as-is.
    etag: ?[]const u8,
    allocator: std.mem.Allocator,

    pub fn deinit(self: *Response) void {
        self.allocator.free(self.body);
        if (self.etag) |e| self.allocator.free(e);
    }

    /// Integrity check outcome.
    pub const IntegrityResult = union(enum) {
        /// No ETag present, or the ETag wasn't a recognized sha256
        /// shape. Verification is skipped — the caller should treat
        /// this the same as a successful verification.
        not_applicable,
        /// Server's advertised sha256 matches the body's actual
        /// sha256. The body is byte-exact with what the server sent.
        ok,
        /// Mismatch between server's advertised sha256 and the body's
        /// actual sha256. Indicates truncation or corruption in
        /// transit. `expected_hex` and `actual_hex` are each 64 chars
        /// of lowercase hex; they reference internal buffers of the
        /// result and are valid for the lifetime of this struct.
        mismatch: struct {
            expected_hex: [64]u8,
            actual_hex: [64]u8,
        },
    };

    /// Verify the body's sha256 against the server's `ETag` header.
    ///
    /// Recognizes `ETag: "sha256:<64-hex>"` (quoted or unquoted, prefix
    /// is case-insensitive). Other ETag shapes — weak etags, md5, etc.
    /// — return `.not_applicable` so deployments with non-sha256 etags
    /// don't get their requests rejected.
    pub fn verifyIntegrity(self: *const Response) IntegrityResult {
        const etag = self.etag orelse return .not_applicable;
        const expected_hex = parseSha256Etag(etag) orelse return .not_applicable;

        var actual: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
        std.crypto.hash.sha2.Sha256.hash(self.body, &actual, .{});
        var actual_hex: [std.crypto.hash.sha2.Sha256.digest_length * 2]u8 = undefined;
        // SAFETY: actual_hex is exactly digest_length*2 bytes, which is
        // exactly what "{x}" writes for a digest_length-byte input.
        _ = std.fmt.bufPrint(&actual_hex, "{x}", .{&actual}) catch &actual_hex;

        if (std.ascii.eqlIgnoreCase(&actual_hex, expected_hex)) return .ok;

        var result: IntegrityResult = .{
            .mismatch = .{
                // SAFETY: both fields are populated by the loop and @memcpy below.
                .expected_hex = undefined,
                // SAFETY: see above.
                .actual_hex = undefined,
            },
        };
        // expected_hex may be uppercase depending on server — copy as
        // lowercase for stable comparison downstream.
        for (expected_hex, 0..) |c, i| result.mismatch.expected_hex[i] = std.ascii.toLower(c);
        @memcpy(&result.mismatch.actual_hex, &actual_hex);
        return result;
    }
};

/// Extract the hex portion of a `"sha256:<hex>"` ETag. Accepts both
/// quoted and unquoted forms (both are commonly written in the wild),
/// and the `sha256:` prefix is case-insensitive. Returns null for any
/// other shape — callers should then skip the integrity check rather
/// than failing the request.
fn parseSha256Etag(etag: []const u8) ?[]const u8 {
    var v = etag;
    if (v.len >= 2 and v[0] == '"' and v[v.len - 1] == '"') v = v[1 .. v.len - 1];
    const prefix = "sha256:";
    if (v.len <= prefix.len) return null;
    if (!std.ascii.eqlIgnoreCase(v[0..prefix.len], prefix)) return null;
    const hex = v[prefix.len..];
    if (hex.len != std.crypto.hash.sha2.Sha256.digest_length * 2) return null;
    for (hex) |c| if (!std.ascii.isHex(c)) return null;
    return hex;
}

/// Thin HTTP client wrapper with retry and error classification.
pub const Client = struct {
    io: std.Io,
    allocator: std.mem.Allocator,
    http_client: std.http.Client,
    max_retries: u8 = 3,
    base_backoff_ms: u64 = 500,

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

    pub fn deinit(self: *Client) void {
        self.http_client.deinit();
    }

    /// Perform a GET request with automatic retries on transient errors.
    pub fn get(self: *Client, url: []const u8) HttpError!Response {
        return self.request(.GET, url, null, &.{});
    }

    /// Perform a POST request with automatic retries on transient errors.
    pub fn post(self: *Client, url: []const u8, body: []const u8, extra_headers: []const std.http.Header) HttpError!Response {
        return self.request(.POST, url, body, extra_headers);
    }

    pub fn request(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response {
        // Preserves the last underlying error across retry attempts.
        // Without this, the retry loop used to collapse every attempt's
        // distinct failure into a single `HttpError.RequestFailed`,
        // which is exactly the kind of opaque error that hides root
        // causes. A DNS-truncation bug surfaced for hours as bare
        // "RequestFailed" because the per-attempt error was discarded
        // by `catch {}`. Now the caller's `@errorName(err)` reports
        // the real cause (e.g., `NoAddressReturned`).
        var attempt: u8 = 0;
        var last_err: HttpError = HttpError.RequestFailed;
        while (true) : (attempt += 1) {
            const response = self.doRequest(method, url, body, extra_headers) catch |err| {
                last_err = err;
                if (attempt >= self.max_retries) return last_err;
                self.backoffSleep(attempt);
                continue;
            };
            return classifyResponse(response) catch |err| {
                if (err == HttpError.ServerError and attempt < self.max_retries) {
                    last_err = err;
                    self.backoffSleep(attempt);
                    continue;
                }
                return err;
            };
        }
    }

    fn backoffSleep(self: *Client, attempt: u8) void {
        const backoff = self.base_backoff_ms * std.math.shl(u64, 1, attempt);
        std.Io.sleep(self.io, std.Io.Duration.fromMilliseconds(@intCast(backoff)), .awake) catch |err| std.log.debug("backoff sleep interrupted: {t}", .{err});
    }

    fn doRequest(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response {
        // Per-stage timing for diagnosing where transport-level stalls
        // occur. Zig 0.16's `std.http.Client` exposes no per-request
        // receive timeout (only `connectTcpOptions.timeout`), so a
        // request can hang forever in `receiveHead` or `streamRemaining`
        // with no upstream visibility. The per-stage logging lets the
        // operator pinpoint exactly which network operation wedged
        // once they Ctrl-C, even though no `catch` will fire on a
        // truly infinite stall.
        //
        // Each stage that may fail logs the underlying stdlib error
        // name verbatim before propagating it through `HttpError`.
        // The error returned to the caller IS the underlying stdlib
        // error (`NoAddressReturned`, `ConnectionRefused`,
        // `EndOfStream`, `TlsInitializationFailed`, ...) — `HttpError`
        // is a merged superset of the relevant stdlib error sets, so
        // `try` here propagates the original error verbatim. Earlier
        // versions of this function caught every error and rethrew as
        // a single `HttpError.RequestFailed`, which collapsed six
        // distinct failure modes into one and made operator
        // diagnosis impossible (this cost hours of debugging at
        // least once when an intermittent DNS-truncation bug
        // surfaced as bare "RequestFailed").
        //
        // wall-clock required: per-stage transport timing. Uses
        // `.awake` (monotonic) so a system clock jump mid-request
        // doesn't produce nonsense elapsed values.
        const t_start = std.Io.Timestamp.now(self.io, .awake).nanoseconds;
        var t_stage = t_start;
        const stageElapsedMs = struct {
            fn f(prev: *i96, io: std.Io) i64 {
                const now = std.Io.Timestamp.now(io, .awake).nanoseconds;
                const delta_ns = now - prev.*;
                prev.* = now;
                return @intCast(@divTrunc(delta_ns, std.time.ns_per_ms));
            }
        }.f;

        const uri = std.Uri.parse(url) catch |err| {
            log.warn("http {s}: stage=uri_parse err={s} url={s}", .{ @tagName(method), @errorName(err), url });
            return err;
        };
        const ms_uri_parse = stageElapsedMs(&t_stage, self.io);

        // If the caller supplied a `User-Agent` in extra_headers,
        // route it to `headers.user_agent.override` so it REPLACES
        // Zig's default "zig/0.x.y (std.http)" UA rather than
        // sitting alongside it. Some servers (notably SEC EDGAR)
        // reject requests where a default-library UA is present
        // even when a descriptive UA is also provided. Same logic
        // applies to other "default-then-override" stdlib headers
        // (Host, Accept-Encoding, Connection, Content-Type) but
        // User-Agent is the only one the EDGAR/Wikidata politeness
        // contract cares about today.
        var std_headers: std.http.Client.Request.Headers = .{};
        var filtered: std.ArrayList(std.http.Header) = .empty;
        defer filtered.deinit(self.allocator);
        for (extra_headers) |h| {
            if (std.ascii.eqlIgnoreCase(h.name, "user-agent"))
                std_headers.user_agent = .{ .override = h.value }
            else
                filtered.append(self.allocator, h) catch return error.OutOfMemory;
        }

        var req = self.http_client.request(method, uri, .{
            .redirect_behavior = @enumFromInt(3),
            .headers = std_headers,
            .extra_headers = filtered.items,
        }) catch |err| {
            // The connect stage covers DNS lookup, TCP connect, and
            // TLS handshake. Logging at warn level (rather than debug)
            // because DNS / connectivity failures are exactly what
            // operators need to see immediately.
            log.warn("http {s}: stage=connect err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
            return err;
        };
        defer req.deinit();
        const ms_connect = stageElapsedMs(&t_stage, self.io);

        if (body) |payload| {
            var send_buf: [4096]u8 = undefined;
            req.transfer_encoding = .{ .content_length = payload.len };
            var bw = req.sendBodyUnflushed(&send_buf) catch |err| {
                log.warn("http {s}: stage=send_body_open err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
                return err;
            };
            bw.writer.writeAll(payload) catch |err| {
                log.warn("http {s}: stage=send_body_write err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
                return err;
            };
            bw.end() catch |err| {
                log.warn("http {s}: stage=send_body_end err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
                return err;
            };
            req.connection.?.flush() catch |err| {
                log.warn("http {s}: stage=send_body_flush err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
                return err;
            };
        } else {
            req.sendBodiless() catch |err| {
                log.warn("http {s}: stage=send_bodiless err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
                return err;
            };
        }
        const ms_send = stageElapsedMs(&t_stage, self.io);

        // Matches the default redirect capacity in std.http.Client.fetch.
        var redirect_buffer: [8 * 1024]u8 = undefined;
        var response = req.receiveHead(&redirect_buffer) catch |err| {
            log.warn("http {s}: stage=receive_head err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
            return err;
        };
        const ms_receive_head = stageElapsedMs(&t_stage, self.io);

        // Capture the ETag (if any) from the response head BEFORE
        // draining the body. `Response.reader()` invalidates
        // `head.bytes`, and `iterateHeaders` reads from that slice, so
        // anything we want must be duplicated now.
        const etag_owned: ?[]const u8 = blk: {
            var it = response.head.iterateHeaders();
            while (it.next()) |h| {
                if (std.ascii.eqlIgnoreCase(h.name, "etag")) {
                    const dup = try self.allocator.dupe(u8, h.value);
                    break :blk dup;
                }
            }
            break :blk null;
        };
        errdefer if (etag_owned) |e| self.allocator.free(e);

        // Drain the body. `readerDecompressing` is adaptive: for
        // identity-encoded responses (the zfin server's default) it
        // hands back the transfer reader unchanged — zero-cost. For
        // gzip/deflate/zstd it wraps the transfer reader with the
        // appropriate decompressor. The decompress buffer is only
        // touched on the compressed paths; sized at 64 KiB as a
        // reasonable default for the unlikely case a provider endpoint
        // starts sending compressed SRF/JSON.
        var aw: std.Io.Writer.Allocating = .init(self.allocator);
        errdefer aw.deinit();

        var transfer_buffer: [4096]u8 = undefined;
        // SAFETY: `decompress` is initialized by `readerDecompressing`
        // before any read. Same pattern as `transfer_buffer`/`decompress_buffer`.
        var decompress: std.http.Decompress = undefined;
        var decompress_buffer: [64 * 1024]u8 = undefined;
        const reader = response.readerDecompressing(&transfer_buffer, &decompress, &decompress_buffer);
        _ = reader.streamRemaining(&aw.writer) catch |err| {
            log.warn("http {s}: stage=stream_body err={s} elapsed_ms={d} url={s}", .{ @tagName(method), @errorName(err), stageElapsedMs(&t_stage, self.io), url });
            return err;
        };
        const ms_body = stageElapsedMs(&t_stage, self.io);

        const resp_body = try aw.toOwnedSlice();

        const total_ms = @divTrunc(std.Io.Timestamp.now(self.io, .awake).nanoseconds - t_start, std.time.ns_per_ms);
        log.debug(
            "http {s}: ok status={d} bytes={d} total_ms={d} (uri_parse={d} connect={d} send={d} receive_head={d} body={d}) url={s}",
            .{
                @tagName(method),
                @intFromEnum(response.head.status),
                resp_body.len,
                total_ms,
                ms_uri_parse,
                ms_connect,
                ms_send,
                ms_receive_head,
                ms_body,
                url,
            },
        );

        return .{
            .status = response.head.status,
            .body = resp_body,
            .etag = etag_owned,
            .allocator = self.allocator,
        };
    }

    fn classifyResponse(response: Response) HttpError!Response {
        switch (response.status) {
            .ok => return response,
            else => {
                // Surface the rejection body — many providers
                // ship actionable diagnostic text in non-2xx
                // bodies (Akamai/SEC's "Request Rate Threshold
                // Exceeded" page, Polygon's "free tier exceeded
                // 5 calls/min" hints, Wikidata's SPARQL syntax
                // errors, etc.). Without this, the caller only
                // sees the mapped HttpError variant
                // (`Unauthorized`, `RateLimited`, ...) and has no
                // path back to the upstream's reason.
                //
                // 404s are noisy in normal operation: callers
                // routinely encounter "no data" 404s for symbols
                // EDGAR doesn't track, money-market funds with no
                // NPORT-P, ETFs with no shares-outstanding XBRL,
                // etc. The body of a 404 is usually a generic
                // "NoSuchKey" / "Not Found" XML/HTML page that
                // tells the operator nothing actionable. Demote
                // those to debug so the warn-level log stream
                // stays focused on cases the operator can act on
                // (auth, rate, server outages).
                if (response.status == .not_found) {
                    log.debug("http rejection body status=404 body={s}", .{response.body});
                } else {
                    log.warn("http rejection body status={d} body={s}", .{ @intFromEnum(response.status), response.body });
                }
                response.allocator.free(response.body);
                if (response.etag) |e| response.allocator.free(e);
                return switch (response.status) {
                    .too_many_requests => HttpError.RateLimited,
                    .unauthorized, .forbidden => HttpError.Unauthorized,
                    .payment_required => HttpError.PaymentRequired,
                    .not_found => HttpError.NotFound,
                    .internal_server_error, .bad_gateway, .service_unavailable, .gateway_timeout => HttpError.ServerError,
                    else => HttpError.InvalidResponse,
                };
            },
        }
    }
};

/// Build a URL with query parameters. Values are percent-encoded per RFC 3986.
pub fn buildUrl(
    allocator: std.mem.Allocator,
    base: []const u8,
    params: []const [2][]const u8,
) ![]const u8 {
    var aw: std.Io.Writer.Allocating = .init(allocator);
    errdefer aw.deinit();

    try aw.writer.writeAll(base);
    for (params, 0..) |param, i| {
        try aw.writer.writeByte(if (i == 0) '?' else '&');
        try aw.writer.writeAll(param[0]);
        try aw.writer.writeByte('=');
        try std.Uri.Component.percentEncode(&aw.writer, param[1], isQueryValueChar);
    }

    return aw.toOwnedSlice();
}

/// RFC 3986 query-safe characters, excluding '&' and '=' which delimit
/// key=value pairs within the query string.
fn isQueryValueChar(c: u8) bool {
    return switch (c) {
        // Unreserved characters (RFC 3986 section 2.3)
        'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true,
        // Sub-delimiters safe in query values (excludes '&' and '=')
        '!', '$', '\'', '(', ')', '*', '+', ',', ';' => true,
        // Additional query/path characters
        ':', '@', '/', '?' => true,
        else => false,
    };
}

test "buildUrl" {
    const allocator = std.testing.allocator;
    const url = try buildUrl(allocator, "https://api.example.com/v1/data", &.{
        .{ "symbol", "AAPL" },
        .{ "apikey", "test123" },
    });
    defer allocator.free(url);
    try std.testing.expectEqualStrings("https://api.example.com/v1/data?symbol=AAPL&apikey=test123", url);
}

test "parseSha256Etag: quoted form" {
    const hex = parseSha256Etag("\"sha256:0402d084abcbd4e40993ebe1e55e0beb400ad77c8c5354a46b047c821e36d3b9\"") orelse unreachable;
    try std.testing.expectEqualStrings("0402d084abcbd4e40993ebe1e55e0beb400ad77c8c5354a46b047c821e36d3b9", hex);
}

test "parseSha256Etag: unquoted form" {
    const hex = parseSha256Etag("sha256:deadbeefcafedeadbeefcafedeadbeefcafedeadbeefcafedeadbeefcafe1234") orelse unreachable;
    try std.testing.expectEqualStrings("deadbeefcafedeadbeefcafedeadbeefcafedeadbeefcafedeadbeefcafe1234", hex);
}

test "parseSha256Etag: case-insensitive prefix" {
    const hex = parseSha256Etag("\"SHA256:0402d084abcbd4e40993ebe1e55e0beb400ad77c8c5354a46b047c821e36d3b9\"") orelse unreachable;
    try std.testing.expectEqualStrings("0402d084abcbd4e40993ebe1e55e0beb400ad77c8c5354a46b047c821e36d3b9", hex);
}

test "parseSha256Etag: wrong scheme returns null" {
    try std.testing.expectEqual(@as(?[]const u8, null), parseSha256Etag("\"md5:deadbeef\""));
    try std.testing.expectEqual(@as(?[]const u8, null), parseSha256Etag("W/\"weak-etag\""));
    try std.testing.expectEqual(@as(?[]const u8, null), parseSha256Etag(""));
}

test "parseSha256Etag: wrong hex length returns null" {
    try std.testing.expectEqual(@as(?[]const u8, null), parseSha256Etag("\"sha256:deadbeef\""));
    try std.testing.expectEqual(@as(?[]const u8, null), parseSha256Etag("\"sha256:0402d084abcbd4e40993ebe1e55e0beb400ad77c8c5354a46b047c821e36d3b9aa\""));
}

test "parseSha256Etag: non-hex character returns null" {
    try std.testing.expectEqual(@as(?[]const u8, null), parseSha256Etag("\"sha256:ZZ02d084abcbd4e40993ebe1e55e0beb400ad77c8c5354a46b047c821e36d3b9\""));
}

test "Response.verifyIntegrity: no etag returns not_applicable" {
    var body_buf = [_]u8{ 'h', 'i' };
    var response = Response{
        .status = .ok,
        .body = &body_buf,
        .etag = null,
        .allocator = std.testing.allocator,
    };
    const result = response.verifyIntegrity();
    try std.testing.expect(result == .not_applicable);
}

test "Response.verifyIntegrity: non-sha256 etag returns not_applicable" {
    var body_buf = [_]u8{ 'h', 'i' };
    var response = Response{
        .status = .ok,
        .body = &body_buf,
        .etag = "W/\"weak-etag\"",
        .allocator = std.testing.allocator,
    };
    const result = response.verifyIntegrity();
    try std.testing.expect(result == .not_applicable);
}

test "Response.verifyIntegrity: matching sha256 returns ok" {
    // sha256("hello world") = b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9
    const body = "hello world";
    var response = Response{
        .status = .ok,
        .body = body,
        .etag = "\"sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9\"",
        .allocator = std.testing.allocator,
    };
    const result = response.verifyIntegrity();
    try std.testing.expect(result == .ok);
}

test "Response.verifyIntegrity: mismatched sha256 returns mismatch" {
    const body = "hello world";
    // Intentionally wrong digest.
    var response = Response{
        .status = .ok,
        .body = body,
        .etag = "\"sha256:0000000000000000000000000000000000000000000000000000000000000000\"",
        .allocator = std.testing.allocator,
    };
    const result = response.verifyIntegrity();
    switch (result) {
        .mismatch => |m| {
            try std.testing.expectEqualStrings(
                "0000000000000000000000000000000000000000000000000000000000000000",
                &m.expected_hex,
            );
            try std.testing.expectEqualStrings(
                "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
                &m.actual_hex,
            );
        },
        else => try std.testing.expect(false),
    }
}
