// Copyright 2025 Bloomberg Finance L.P
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

#include <buildboxcommon_httpclient.h>
#include <buildboxcommon_version.h>
#include <httplib.h>

#include <gmock/gmock.h>
#include <gtest/gtest.h>

#include <atomic>
#include <chrono>
#include <future>
#include <string>
#include <thread>

using namespace buildboxcommon;
using ::testing::HasSubstr;

namespace {
constexpr int kServerReadyWaitMs = 100;
constexpr int kRetryWaitMs = 100;
constexpr int kStatusOK = 200;
constexpr int kStatusBadRequest = 400;
constexpr int kStatusNotFound = 404;
constexpr int kStatusServiceUnavailable = 503;
constexpr int kStatusInternalServerError = 500;
constexpr int kStatusTeapot = 418;
constexpr double kBackoffFactor = 1.5;
constexpr int kChunkDelayMs = 50;
} // namespace

// We use cpp-httplib for the HTTP server in tests.
// https://github.com/yhirose/cpp-httplib

class HttpClientTest : public ::testing::Test {
  public:
    std::thread serverThread;
    std::promise<void> serverReady;
    std::atomic<bool> serverRunning{false};
    std::unique_ptr<httplib::Server> server;
    int d_port = 0;
    std::string baseUrl;

  protected:
    void SetUp() override
    {
        server = std::make_unique<httplib::Server>();
        // Bind to a free port on localhost
        int port = server->bind_to_any_port("127.0.0.1", 0);
        ASSERT_GT(port, 0) << "Failed to bind to a free port";
        d_port = port;
        baseUrl = "http://127.0.0.1:" + std::to_string(d_port);
        serverRunning = true;
        serverThread = std::thread(&HttpClientTest::runTestServer, this,
                                   std::ref(serverReady));
        serverReady.get_future().wait();
        std::this_thread::sleep_for(
            std::chrono::milliseconds(kServerReadyWaitMs));
    }
    void TearDown() override
    {
        serverRunning = false;
        if (server)
            server->stop();
        if (serverThread.joinable())
            serverThread.join();
        server.reset();
    }
    static void runTestServer(HttpClientTest *self,
                              std::promise<void> &readySignal)
    {
        self->registerHandlers();
        readySignal.set_value();
        self->server->listen_after_bind();
    }
    virtual void registerHandlers() {}
};

// Checks that a basic GET request returns the expected response.
TEST_F(HttpClientTest, BasicGetRequest)
{
    server->Get("/test", [](const httplib::Request &, httplib::Response &res) {
        res.status = kStatusOK;
        res.set_content("Hello, World!", "text/plain");
    });
    HTTPClient client(1, kRetryWaitMs, kBackoffFactor);
    HTTPResponse response = client.get(baseUrl + "/test");
    EXPECT_EQ(kStatusOK, response.d_statusCode);
    EXPECT_EQ("Hello, World!", response.d_body);
}

// Checks that a HEAD request returns headers without body.
TEST_F(HttpClientTest, BasicHeadRequest)
{
    server->Get("/head-test", [](const httplib::Request &,
                                 httplib::Response &res) {
        res.status = kStatusOK;
        res.set_content("This content should not be returned for HEAD",
                        "text/plain");
        res.set_header("Content-Type", "text/plain");
        res.set_header("X-Test-Header", "test-value");
        res.set_header("Content-Length", "44"); // Length of the content string
    });
    HTTPClient client(1, kRetryWaitMs, kBackoffFactor);
    HTTPResponse response = client.head(baseUrl + "/head-test");
    EXPECT_EQ(kStatusOK, response.d_statusCode);
    EXPECT_TRUE(response.d_body.empty()); // HEAD requests have empty body
    EXPECT_EQ("text/plain", response.d_headers["Content-Type"]);
    EXPECT_EQ("test-value", response.d_headers["X-Test-Header"]);
    EXPECT_EQ("44", response.d_headers["Content-Length"]);
}

// Checks that HEAD requests retry on temporary errors and eventually succeed.
TEST_F(HttpClientTest, HeadRetrySuccess)
{
    std::atomic<int> attemptCount{0};
    server->Get(
        "/head-retry-success",
        [&attemptCount](const httplib::Request &, httplib::Response &res) {
            ++attemptCount;
            if (attemptCount == 1) {
                res.status = kStatusServiceUnavailable;
                res.set_header("X-Error", "Temporary error");
            }
            else {
                res.status = kStatusOK;
                res.set_header("Content-Type", "text/plain");
                res.set_header("X-Test-Header", "success-after-retry");
                res.set_header("Content-Length", "32");
            }
        });
    HTTPClient client(1, kRetryWaitMs, kBackoffFactor); // Only 1 retry
    HTTPResponse response = client.head(baseUrl + "/head-retry-success");
    EXPECT_EQ(kStatusOK, response.d_statusCode);
    EXPECT_TRUE(response.d_body.empty()); // HEAD requests have empty body
    EXPECT_EQ("text/plain", response.d_headers["Content-Type"]);
    EXPECT_EQ("success-after-retry", response.d_headers["X-Test-Header"]);
    EXPECT_EQ(2, attemptCount);
}

// Checks that HEAD requests give up after retry limit and throw HTTPException.
TEST_F(HttpClientTest, HeadRetryFailure)
{
    std::atomic<int> attemptCount{0};
    server->Get("/head-retry-fail", [&attemptCount](const httplib::Request &,
                                                    httplib::Response &res) {
        ++attemptCount;
        res.status = kStatusServiceUnavailable;
        res.set_header("X-Error", "Persistent error");
        res.set_header("Retry-Count", std::to_string(attemptCount));
    });
    HTTPClient client(1, kRetryWaitMs, kBackoffFactor); // Only 1 retry
    try {
        client.head(baseUrl + "/head-retry-fail");
        FAIL() << "Expected HTTPException to be thrown";
    }
    catch (const HTTPException &ex) {
        EXPECT_THAT(
            ex.what(),
            HasSubstr("Max retries exceeded for HTTP status code: 503"));
    }
    EXPECT_EQ(2, attemptCount);
}

// Checks that a POST request sends data and receives the expected response.
TEST_F(HttpClientTest, BasicPostRequest)
{
    std::string capturedRequest;
    server->Post("/post", [&capturedRequest](const httplib::Request &req,
                                             httplib::Response &res) {
        capturedRequest = req.body;
        res.status = kStatusOK;
        res.set_content("Post successful", "text/plain");
    });
    HTTPClient client(1, kRetryWaitMs, kBackoffFactor);
    std::string postData = "{\"key\":\"value\"}";
    HTTPResponse response = client.post(baseUrl + "/post", postData);
    EXPECT_EQ(kStatusOK, response.d_statusCode);
    EXPECT_EQ("Post successful", response.d_body);
    EXPECT_EQ(postData, capturedRequest);
}

// Checks that a 404 Not Found response throws HTTPException with correct
// message.
TEST_F(HttpClientTest, NotFoundError)
{
    server->Get("/missing",
                [](const httplib::Request &, httplib::Response &res) {
                    res.status = kStatusNotFound;
                    res.set_content("Not Found", "text/plain");
                });
    HTTPClient client(1, kRetryWaitMs, kBackoffFactor);
    try {
        client.get(baseUrl + "/missing");
        FAIL() << "Expected HTTPException to be thrown";
    }
    catch (const HTTPException &ex) {
        EXPECT_THAT(ex.what(),
                    HasSubstr("Non-retryable HTTP status code: 404"));
    }
}

// Checks that the client retries on temporary errors and eventually succeeds.
TEST_F(HttpClientTest, RetryOnTemporaryError)
{
    std::atomic<int> attemptCount{0};
    server->Get("/retry", [&attemptCount](const httplib::Request &,
                                          httplib::Response &res) {
        ++attemptCount;
        if (attemptCount < 2) {
            res.status = kStatusServiceUnavailable;
            res.set_content("Try again later", "text/plain");
        }
        else {
            res.status = kStatusOK;
            res.set_content("Success", "text/plain");
        }
    });
    HTTPClient client(1, kRetryWaitMs, kBackoffFactor);
    HTTPResponse response = client.get(baseUrl + "/retry");
    EXPECT_EQ(kStatusOK, response.d_statusCode);
    EXPECT_EQ("Success", response.d_body);
    EXPECT_GT(attemptCount, 1);
}

// Checks that custom headers are sent with the request.
TEST_F(HttpClientTest, CustomHeaders)
{
    std::string receivedAuth;
    std::string receivedUserAgent;
    std::string receivedCustom;
    server->Get("/headers",
                [&](const httplib::Request &req, httplib::Response &res) {
                    auto itAuth = req.headers.find("Authorization");
                    if (itAuth != req.headers.end())
                        receivedAuth = itAuth->second;
                    auto itUA = req.headers.find("User-Agent");
                    if (itUA != req.headers.end())
                        receivedUserAgent = itUA->second;
                    auto itCustom = req.headers.find("X-Custom-Header");
                    if (itCustom != req.headers.end())
                        receivedCustom = itCustom->second;
                    res.status = kStatusOK;
                    res.set_content("Headers received", "text/plain");
                });
    HTTPClient client(1, kRetryWaitMs, kBackoffFactor);
    HTTPClient::HeaderMap headers;
    headers["X-Custom-Header"] = "CustomValue";
    headers["User-Agent"] = "TestClient";
    headers["Authorization"] = "Bearer token123";
    HTTPResponse response = client.get(baseUrl + "/headers", headers);
    EXPECT_EQ(kStatusOK, response.d_statusCode);
    EXPECT_EQ("CustomValue", receivedCustom);
    EXPECT_EQ("TestClient", receivedUserAgent);
    EXPECT_EQ("Bearer token123", receivedAuth);
}

// Checks that the client throws after max retries are exceeded and logs error.
TEST_F(HttpClientTest, RetryExceedsMaxAttempts)
{
    std::atomic<int> attemptCount{0};
    server->Get("/fail", [&attemptCount](const httplib::Request &,
                                         httplib::Response &res) {
        ++attemptCount;
        res.status = kStatusServiceUnavailable;
        res.set_content("Service Unavailable", "text/plain");
    });
    HTTPClient client(1, kRetryWaitMs, kBackoffFactor);
    try {
        client.get(baseUrl + "/fail");
        FAIL() << "Expected HTTPException to be thrown";
    }
    catch (const HTTPException &ex) {
        EXPECT_THAT(
            ex.what(),
            HasSubstr("Max retries exceeded for HTTP status code: 503"));
    }
    EXPECT_GT(attemptCount, 1);
}

// Checks that the client throws HTTPException on connection failure.
TEST_F(HttpClientTest, ThrowsOnConnectionFailure)
{
    HTTPClient client(1, kRetryWaitMs, kBackoffFactor);
    // Use a port that is not open
    std::string badUrl = "http://localhost:65534/nowhere";
    EXPECT_THROW({ client.get(badUrl); }, HTTPException);
}

// Checks that the client retries on 5xx errors and throws after max retries.
TEST_F(HttpClientTest, RetryOnServerError)
{
    std::atomic<int> attemptCount{0};
    server->Get("/alwaysfail", [&attemptCount](const httplib::Request &,
                                               httplib::Response &res) {
        ++attemptCount;
        res.status = kStatusInternalServerError;
        res.set_content("Internal Error", "text/plain");
    });
    HTTPClient client(1, kRetryWaitMs, kBackoffFactor);
    EXPECT_THROW({ client.get(baseUrl + "/alwaysfail"); }, HTTPException);
    EXPECT_GT(attemptCount, 1);
}

// Checks that the client retries on custom retry code.
TEST_F(HttpClientTest, RetryOnCustomRetryCode)
{
    std::atomic<int> attemptCount{0};
    server->Get("/customretry", [&attemptCount](const httplib::Request &,
                                                httplib::Response &res) {
        ++attemptCount;
        if (attemptCount < 2) {
            res.status = kStatusTeapot;
            res.set_content("Teapot", "text/plain");
        }
        else {
            res.status = kStatusOK;
            res.set_content("Recovered", "text/plain");
        }
    });
    HTTPClient client(1, kRetryWaitMs, kBackoffFactor,
                      {kStatusTeapot}); // Add 418 as a retry code
    HTTPResponse response = client.get(baseUrl + "/customretry");
    EXPECT_EQ(kStatusOK, response.d_statusCode);
    EXPECT_EQ("Recovered", response.d_body);
    EXPECT_EQ(2, attemptCount);
}
// Test: Verify specific CURL error codes trigger retries
TEST_F(HttpClientTest, CurlTimeoutRetryBehavior)
{
    HTTPClient client(1, kRetryWaitMs, kBackoffFactor,
                      HTTPClient::DEFAULT_RETRY_CODES,
                      1); // 1 retry, 1 second timeout

    // Create a slow endpoint that will timeout
    server->Get("/slow", [](const httplib::Request &, httplib::Response &res) {
        std::this_thread::sleep_for(
            std::chrono::seconds(4)); // Longer than timeout
        res.status = kStatusOK;
        res.set_content("Too slow", "text/plain");
    });

    auto start = std::chrono::steady_clock::now();
    EXPECT_THROW({ client.get(baseUrl + "/slow"); }, HTTPException);
    auto elapsed = std::chrono::steady_clock::now() - start;

    // Should take at least timeout + retry delay + timeout again
    // = 1000ms + 100ms + 1000ms = 2100ms minimum
    EXPECT_GE(elapsed, std::chrono::milliseconds(2000))
        << "Request should have retried on CURLE_OPERATION_TIMEDOUT";
}

// Test: streamDownload delivers all data in correct order and chunking
TEST_F(HttpClientTest, StreamDownloadChunked)
{
    std::string testData = "abcdefghij";
    server->Get("/chunked",
                [testData](const httplib::Request &, httplib::Response &res) {
                    res.set_content(testData, "application/octet-stream");
                });

    HTTPClient client(0, 0, 1.0);
    std::vector<std::string> receivedChunks;
    size_t totalReceived = 0;
    auto callback = [&](const char *data, size_t size) {
        receivedChunks.emplace_back(data, size);
        totalReceived += size;
        return true;
    };
    HTTPResponse response = client.streamDownload(
        baseUrl + "/chunked", HTTPClient::HeaderMap{}, callback);
    EXPECT_EQ(response.d_statusCode, 200);
    EXPECT_EQ(totalReceived, testData.size());
    std::string reassembled;
    for (const auto &chunk : receivedChunks)
        reassembled += chunk;
    EXPECT_EQ(reassembled, testData);
}

// Test: streamDownload aborts when callback returns false and throws
// HTTPStreamAborted
TEST_F(HttpClientTest, StreamDownloadUserAbort)
{
    std::string testData = "abcdefghij";
    server->Get("/abort",
                [testData](const httplib::Request &, httplib::Response &res) {
                    res.set_content(testData, "application/octet-stream");
                });

    HTTPClient client(0, 0, 1.0);
    size_t totalReceived = 0;
    int chunkCount = 0;
    auto callback = [&](const char *data, size_t size) {
        totalReceived += size;
        chunkCount++;
        // Abort after first chunk
        return false;
    };
    EXPECT_THROW(
        {
            client.streamDownload(baseUrl + "/abort", HTTPClient::HeaderMap{},
                                  callback);
        },
        HTTPStreamAborted);
    EXPECT_GT(totalReceived, 0u);
    EXPECT_EQ(chunkCount, 1);
}

// Test: streamDownload throws HTTPException on connection failure
TEST_F(HttpClientTest, StreamDownloadThrowsOnConnectionFailure)
{
    HTTPClient client(1, kRetryWaitMs, kBackoffFactor);
    std::string badUrl = "http://localhost:65534/nowhere";

    auto callback = [](const char *, size_t) { return true; };
    EXPECT_THROW(
        { client.streamDownload(badUrl, HTTPClient::HeaderMap{}, callback); },
        HTTPException);
}
// Test: streamDownload throws HTTPException for 404 Not Found
TEST_F(HttpClientTest, StreamDownloadNotFoundError)
{
    server->Get("/missing",
                [](const httplib::Request &, httplib::Response &res) {
                    res.status = kStatusNotFound;
                    res.set_content("Not Found", "text/plain");
                });
    HTTPClient client(1, kRetryWaitMs, kBackoffFactor);
    auto callback = [](const char *, size_t) { return true; };
    try {
        client.streamDownload(baseUrl + "/missing", HTTPClient::HeaderMap{},
                              callback);
        FAIL() << "Expected HTTPException to be thrown";
    }
    catch (const HTTPException &ex) {
        EXPECT_THAT(ex.what(),
                    HasSubstr("Non-retryable HTTP status code: 404"));
    }
}

// Test: streamDownload throws HTTPException after max retries on 5xx error
TEST_F(HttpClientTest, StreamDownloadServerError)
{
    server->Get("/fail", [](const httplib::Request &, httplib::Response &res) {
        res.status = kStatusInternalServerError;
        res.set_content("Internal Error", "text/plain");
    });
    HTTPClient client(1, kRetryWaitMs, kBackoffFactor);
    auto callback = [](const char *, size_t) { return true; };
    try {
        client.streamDownload(baseUrl + "/fail", HTTPClient::HeaderMap{},
                              callback);
        FAIL() << "Expected HTTPException to be thrown";
    }
    catch (const HTTPException &ex) {
        EXPECT_THAT(
            ex.what(),
            HasSubstr("Max retries exceeded for HTTP status code: 500"));
    }
}

// Test: streamDownload retries on temporary error and eventually succeeds
TEST_F(HttpClientTest, StreamDownloadRetryOnTemporaryError)
{
    std::atomic<int> attemptCount{0};
    std::string testData = "stream-retry-success";
    server->Get("/stream-retry",
                [&attemptCount, &testData](const httplib::Request &,
                                           httplib::Response &res) {
                    ++attemptCount;
                    if (attemptCount < 2) {
                        res.status = kStatusServiceUnavailable;
                        res.set_content("Try again later", "text/plain");
                    }
                    else {
                        res.status = kStatusOK;
                        res.set_content(testData, "application/octet-stream");
                    }
                });
    HTTPClient client(1, kRetryWaitMs, kBackoffFactor);
    std::vector<std::string> receivedChunks;
    size_t totalReceived = 0;
    auto callback = [&](const char *data, size_t size) {
        receivedChunks.emplace_back(data, size);
        totalReceived += size;
        return true;
    };
    HTTPResponse response = client.streamDownload(
        baseUrl + "/stream-retry", HTTPClient::HeaderMap{}, callback);
    EXPECT_EQ(response.d_statusCode, kStatusOK);
    EXPECT_EQ(totalReceived, testData.size());
    std::string reassembled;
    for (const auto &chunk : receivedChunks)
        reassembled += chunk;
    EXPECT_EQ(reassembled, testData);
    EXPECT_EQ(attemptCount, 3);
}

// Test: streamDownload does not retry if user aborts during a retryable error
TEST_F(HttpClientTest, StreamDownloadNoRetryOnUserAbort)
{
    std::atomic<int> attemptCount{0};
    std::string testData = "stream-user-abort";
    server->Get("/stream-user-abort",
                [&attemptCount, &testData](const httplib::Request &,
                                           httplib::Response &res) {
                    ++attemptCount;
                    if (attemptCount < 2) {
                        res.status = kStatusServiceUnavailable;
                        res.set_content("Try again later", "text/plain");
                    }
                    else {
                        res.status = kStatusOK;
                        res.set_content(testData, "application/octet-stream");
                    }
                });
    HTTPClient client(1, kRetryWaitMs, kBackoffFactor);
    size_t totalReceived = 0;
    int chunkCount = 0;
    auto callback = [&](const char *data, size_t size) {
        totalReceived += size;
        chunkCount++;
        // Abort on first chunk
        return false;
    };
    EXPECT_THROW(
        {
            client.streamDownload(baseUrl + "/stream-user-abort",
                                  HTTPClient::HeaderMap{}, callback);
        },
        HTTPStreamAborted);
    EXPECT_EQ(attemptCount, 3);
    EXPECT_GT(totalReceived, 0u);
    EXPECT_EQ(chunkCount, 1);
}

// Test: streamDownload receives multiple chunks when server sends data in
// parts
TEST_F(HttpClientTest, StreamDownloadMultipleChunks)
{
    std::string part1 = "abc";
    std::string part2 = "def";
    std::string part3 = "ghi";
    const std::string allData = part1 + part2 + part3;
    const size_t DATA_CHUNK_SIZE = 3; // send 3 bytes at a time
    server->Get(
        "/multi-chunk", [allData, DATA_CHUNK_SIZE](const httplib::Request &,
                                                   httplib::Response &res) {
            res.set_content_provider(
                allData.size(), // Content length
                "application/octet-stream",
                [allData, DATA_CHUNK_SIZE](size_t offset, size_t length,
                                           httplib::DataSink &sink) {
                    if (offset >= allData.size()) {
                        return false;
                    }
                    size_t toSend =
                        std::min(DATA_CHUNK_SIZE, allData.size() - offset);
                    sink.write(&allData[offset], toSend);
                    std::this_thread::sleep_for(
                        std::chrono::milliseconds(kChunkDelayMs));
                    return true;
                });
        });

    HTTPClient client(0, 0, 1.0);
    std::vector<std::string> receivedChunks;
    size_t totalReceived = 0;
    auto callback = [&](const char *data, size_t size) {
        receivedChunks.emplace_back(data, size);
        totalReceived += size;
        return true;
    };
    HTTPResponse response = client.streamDownload(
        baseUrl + "/multi-chunk", HTTPClient::HeaderMap{}, callback);
    EXPECT_EQ(response.d_statusCode, 200);
    EXPECT_EQ(totalReceived, allData.size());
    std::string reassembled;
    for (const auto &chunk : receivedChunks)
        reassembled += chunk;
    EXPECT_EQ(reassembled, allData);
    EXPECT_GE(receivedChunks.size(), 2u); // Should receive at least two chunks
}

// Test: CURL error retry behavior for all HTTP methods
TEST_F(HttpClientTest, CurlErrorRetryBehavior)
{
    HTTPClient client(1, kRetryWaitMs, kBackoffFactor); // 1 retry

    // Test with unreachable port to trigger CURLE_COULDNT_CONNECT
    const std::string unreachableUrl = "http://localhost:65534/test";

    // Expected minimum time: initial attempt + retry delay + retry attempt
    // Should be at least kRetryWaitMs (100ms) for the retry delay
    const auto expectedMinDuration = std::chrono::milliseconds(kRetryWaitMs);

    // Test GET request retry
    auto start = std::chrono::steady_clock::now();
    EXPECT_THROW(
        { HTTPResponse response = client.get(unreachableUrl); },
        HTTPException);
    auto elapsed = std::chrono::steady_clock::now() - start;
    EXPECT_GE(elapsed, expectedMinDuration)
        << "GET request should have retried with delay";

    // Test POST request retry
    start = std::chrono::steady_clock::now();
    EXPECT_THROW(
        { HTTPResponse response = client.post(unreachableUrl, "test data"); },
        HTTPException);
    elapsed = std::chrono::steady_clock::now() - start;
    EXPECT_GE(elapsed, expectedMinDuration)
        << "POST request should have retried with delay";

    // Test HEAD request retry
    start = std::chrono::steady_clock::now();
    EXPECT_THROW(
        { HTTPResponse response = client.head(unreachableUrl); },
        HTTPException);
    elapsed = std::chrono::steady_clock::now() - start;
    EXPECT_GE(elapsed, expectedMinDuration)
        << "HEAD request should have retried with delay";

    // Test streamDownload retry
    start = std::chrono::steady_clock::now();
    EXPECT_THROW(
        {
            auto callback = [](const char *data, size_t size) -> bool {
                return true;
            };
            HTTPResponse response =
                client.streamDownload(unreachableUrl, {}, callback);
        },
        HTTPException);
    elapsed = std::chrono::steady_clock::now() - start;
    EXPECT_GE(elapsed, expectedMinDuration)
        << "streamDownload should have retried with delay";
}

// Test: Verify that non-retriable CURL errors don't retry
TEST_F(HttpClientTest, CurlErrorNoRetryOnNonRetriableErrors)
{
    HTTPClient client(2, kRetryWaitMs,
                      kBackoffFactor); // 2 retries to make timing more obvious

    // Create a server that will cause a specific error we don't retry on
    server->Get("/bad-request",
                [](const httplib::Request &, httplib::Response &res) {
                    // Return a 400 Bad Request - this is a client error, not
                    // retriable
                    res.status = kStatusBadRequest;
                    res.set_content("Bad Request", "text/plain");
                });

    // clang-tidy false positive
    // NOLINTBEGIN(clang-analyzer-deadcode.DeadStores)
    auto start = std::chrono::steady_clock::now();
    // NOLINTEND(clang-analyzer-deadcode.DeadStores)
    try {
        client.get(baseUrl + "/bad-request");
        FAIL() << "Expected HTTPException to be thrown";
    }
    catch (const HTTPException &ex) {
        EXPECT_THAT(ex.what(),
                    HasSubstr("Non-retryable HTTP status code: 400"));
    }
    auto elapsed = std::chrono::steady_clock::now() - start;

    // Should complete quickly since 400 is not a retriable HTTP status code
    EXPECT_LT(elapsed, std::chrono::milliseconds(kRetryWaitMs / 2))
        << "400 Bad Request should not trigger retries";
}

// Test the new configuration struct constructor approach
TEST_F(HttpClientTest, ConfigStructConstructor)
{
    server->Get("/config-test",
                [](const httplib::Request &, httplib::Response &res) {
                    res.status = kStatusOK;
                    res.set_content("Config struct works!", "text/plain");
                });

    // Test default configuration
    {
        HTTPClient client;
        HTTPResponse response = client.get(baseUrl + "/config-test");
        EXPECT_EQ(kStatusOK, response.d_statusCode);
        EXPECT_EQ("Config struct works!", response.d_body);
    }

    // Test named initialization (designated initializers)
    {
        HTTPClient client({.maxRetries = 2,
                           .initialDelayMs = kRetryWaitMs,
                           .backoffFactor = kBackoffFactor,
                           .userAgent = "test-agent"});
        HTTPResponse response = client.get(baseUrl + "/config-test");
        EXPECT_EQ(kStatusOK, response.d_statusCode);
        EXPECT_EQ("Config struct works!", response.d_body);
    }
}

// Test: Verify default User-Agent is set correctly
TEST_F(HttpClientTest, DefaultUserAgent)
{
    std::string receivedUserAgent;
    server->Get("/user-agent-test",
                [&receivedUserAgent](const httplib::Request &req,
                                     httplib::Response &res) {
                    auto itUA = req.headers.find("User-Agent");
                    if (itUA != req.headers.end())
                        receivedUserAgent = itUA->second;
                    res.status = kStatusOK;
                    res.set_content("User-Agent received", "text/plain");
                });

    // Test default configuration uses correct User-Agent
    HTTPClient client;
    HTTPResponse response = client.get(baseUrl + "/user-agent-test");
    EXPECT_EQ(kStatusOK, response.d_statusCode);
    EXPECT_EQ("User-Agent received", response.d_body);

    // Verify the User-Agent follows the expected format
    EXPECT_THAT(receivedUserAgent, HasSubstr("BuildBox/"));
    EXPECT_THAT(
        receivedUserAgent,
        HasSubstr("(+https://gitlab.com/BuildGrid/buildbox/buildbox)"));
    EXPECT_NE(receivedUserAgent.find(buildboxcommon::VERSION),
              std::string::npos);
}
