/*
 * Copyright 2025 Bloomberg Finance LP
 *
 * 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_assetclient.h>
#include <buildboxcommon_casclient.h>
#include <buildboxcommon_digestgenerator.h>
#include <buildboxcommon_grpcclient.h>
#include <buildboxcommon_ociclient.h>
#include <buildboxcommon_temporarydirectory.h>
#include <buildboxcommon_temporaryfile.h>

// CASD integration
#include <buildboxcasd_fslocalactionstorage.h>
#include <buildboxcasd_fslocalassetstorage.h>
#include <buildboxcasd_server.h>
#include <buildboxcommon_connectionoptions.h>
#include <buildboxcommon_fslocalcas.h>

#include <build/bazel/remote/execution/v2/remote_execution_mock.grpc.pb.h>
#include <build/buildgrid/local_cas_mock.grpc.pb.h>
#include <google/bytestream/bytestream_mock.grpc.pb.h>

#include <atomic>
#include <chrono>
#include <fstream>
#include <future>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <httplib.h>
#include <memory>
#include <queue>
#include <set>
#include <sstream>
#include <stdexcept>
#include <sys/stat.h>
#include <sys/types.h>
#include <thread>
#include <unistd.h>

using namespace buildboxcommon;
using namespace testing;

// Global initializer to ensure DigestGenerator::init() is called only once
const auto digestFunctionInitializer = []() {
    buildboxcommon::DigestGenerator::init();
    return 0;
}();

// TestAssetClient - tracks cache hits and misses for testing
class TestAssetClient : public AssetClient {
  public:
    TestAssetClient(std::shared_ptr<GrpcClient> grpcClient)
        : AssetClient(std::move(grpcClient))
    {
    }

    inline std::size_t getCacheHits() const { return cacheHits; }
    inline std::size_t getCacheMisses() const { return cacheMisses; }

    std::optional<AssetClient::FetchResult> tryFetchBlob(
        const std::vector<std::string> &uris,
        const std::vector<std::pair<std::string, std::string>> &qualifiers,
        GrpcClient::RequestStats *requestStats = nullptr) override
    {
        return recordCache(
            AssetClient::tryFetchBlob(uris, qualifiers, requestStats));
    }

  private:
    std::size_t cacheHits = 0;
    std::size_t cacheMisses = 0;

    template <typename T> std::optional<T> recordCache(std::optional<T> result)
    {
        if (result.has_value()) {
            ++cacheHits;
        }
        else {
            ++cacheMisses;
        }
        return result;
    }
};

namespace {
constexpr int kServerReadyWaitMs = 100;
constexpr int kStatusOK = 200;
constexpr int kStatusNotFound = 404;
constexpr int kHttpPrefixLength = 7; // Length of "http://"

// Sample manifest JSON for testing
const std::string VALID_MANIFEST_JSON = R"({
  "schemaVersion": 2,
  "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
  "config": {
    "mediaType": "application/vnd.docker.container.image.v1+json",
    "size": 1461,
    "digest": "sha256:4eacea30377a698ef8fbec99b6caf01cb150151cbedc8e0b1c3d22f134206f1a"
  },
  "layers": [
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": 55009253,
      "digest": "sha256:e756f3fdd6a378aa16205b0f75d178b7532b110e86be7659004fc6a21183226c"
    }
  ]
})";

// Sample OCI Image Index JSON for testing (multi-platform manifest list)
const std::string VALID_OCI_MANIFEST_JSON = R"({
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
    "size": 1470
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef",
      "size": 32654
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef",
      "size": 16724
    }
  ]
})";

// Sample OCI Image Index JSON for testing manifest lists
const std::string MANIFEST_LIST_NO_LINUX_AMD64_JSON = R"({
  "schemaVersion": 2,
  "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
  "manifests": [
    {
      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
      "size": 1456,
      "digest": "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
      "platform": {
        "architecture": "arm64",
        "os": "linux"
      }
    },
    {
      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
      "size": 1678,
      "digest": "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
      "platform": {
        "architecture": "amd64",
        "os": "windows"
      }
    }
  ]
})";

const std::string MANIFEST_LIST_MULTIPLE_LINUX_AMD64_JSON = R"({
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "size": 1234,
      "digest": "sha256:e756f3fdd6a378aa16205b0f75d178b7532b110e86be7659004fc6a21183226c",
      "platform": {
        "architecture": "amd64",
        "os": "linux"
      }
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "size": 2345,
      "digest": "sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
      "platform": {
        "architecture": "amd64",
        "os": "linux"
      }
    }
  ]
})";

const std::string MANIFEST_LIST_MISSING_PLATFORM_JSON = R"({
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "size": 1234,
      "digest": "sha256:e756f3fdd6a378aa16205b0f75d178b7532b110e86be7659004fc6a21183226c",
      "platform": {
        "architecture": "amd64"
      }
    }
  ]
})";

} // namespace

// Mock HTTP server for testing OCI Client API calls
class OciClientTest : 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;
    std::shared_ptr<HTTPClient> httpClient;
    std::shared_ptr<GrpcClient> grpcClient;
    std::shared_ptr<CASClient> casClient;
    std::shared_ptr<AssetClient> assetClient;

  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(&OciClientTest::runTestServer, this,
                                   std::ref(serverReady));
        serverReady.get_future().wait();
        std::this_thread::sleep_for(
            std::chrono::milliseconds(kServerReadyWaitMs));

        // Create a client with SSL verification disabled for tests
        httpClient = std::make_shared<HTTPClient>();
        grpcClient = std::make_shared<GrpcClient>();
        casClient = std::make_shared<CASClient>(grpcClient);
        assetClient = std::make_shared<AssetClient>(grpcClient);
    }

    void TearDown() override
    {
        serverRunning = false;
        if (server)
            server->stop();
        if (serverThread.joinable())
            serverThread.join();
        server.reset();
        httpClient.reset();
    }

    void runTestServer(std::promise<void> &readySignal)
    {
        registerHandlers();
        readySignal.set_value();
        server->listen_after_bind();
    }

    virtual void registerHandlers() {}
};

TEST(OciClientParseUriTest, FullRegistryNamespaceTag)
{
    auto c = OciClient::parseOCIuri(
        "docker://registry.example.com/namespace/repo:tag");
    EXPECT_EQ(c.registry, "registry.example.com");
    EXPECT_EQ(c.repository, "namespace/repo");
    ASSERT_TRUE(c.tag.has_value());
    if (c.tag.has_value()) {
        EXPECT_EQ(c.tag.value(), "tag");
    }
    EXPECT_FALSE(c.sha256Digest.has_value());
}

TEST(OciClientParseUriTest, FullRegistryNamespaceDigest)
{
    auto c = OciClient::parseOCIuri(
        "registry.example.com/namespace/repo@sha256:deadbeef");
    EXPECT_EQ(c.registry, "registry.example.com");
    EXPECT_EQ(c.repository, "namespace/repo");
    EXPECT_FALSE(c.tag.has_value());
    ASSERT_TRUE(c.sha256Digest.has_value());
    if (c.sha256Digest.has_value()) {
        EXPECT_EQ(c.sha256Digest.value(), "deadbeef");
    }
}

// Registry must be explicitly included, these tests should now default to
// BUILDBOXCOMMON_DEFAULT_OCI_REGISTRY
TEST(OciClientParseUriTest, NoRegistryDefaultsToDockerIo)
{
    auto c = OciClient::parseOCIuri("myspace/myrepo:mytag");
    EXPECT_EQ(c.registry, BUILDBOXCOMMON_DEFAULT_OCI_REGISTRY);
    EXPECT_EQ(c.repository, "myspace/myrepo");
    ASSERT_TRUE(c.tag.has_value());
    if (c.tag.has_value()) {
        EXPECT_EQ(c.tag.value(), "mytag");
    }
    EXPECT_FALSE(c.sha256Digest.has_value());

    auto c2 = OciClient::parseOCIuri("alpine:3.18");
    EXPECT_EQ(c2.registry, BUILDBOXCOMMON_DEFAULT_OCI_REGISTRY);
    EXPECT_EQ(c2.repository, "alpine");
    ASSERT_TRUE(c2.tag.has_value());
    if (c2.tag.has_value()) {
        EXPECT_EQ(c2.tag.value(), "3.18");
    }
    EXPECT_FALSE(c2.sha256Digest.has_value());
}

TEST(OciClientParseUriTest, MalformedUriThrows)
{
    EXPECT_THROW(OciClient::parseOCIuri(":"), std::invalid_argument);
    EXPECT_THROW(OciClient::parseOCIuri("/repo"), std::invalid_argument);
    EXPECT_THROW(OciClient::parseOCIuri(""), std::invalid_argument);
    EXPECT_THROW(OciClient::parseOCIuri("registry.example.com/"),
                 std::invalid_argument);
}

// Test that media type constant is defined correctly
TEST_F(OciClientTest, MediaTypeConstants)
{
    EXPECT_STREQ(MANIFEST_MEDIA_TYPE,
                 "application/vnd.oci.image.manifest.v1+json, "
                 "application/vnd.docker.distribution.manifest.v2+json, "
                 "application/vnd.oci.image.index.v1+json, "
                 "application/vnd.docker.distribution.manifest.list.v2+json");
    EXPECT_STREQ(OCI_MANIFEST_MEDIA_TYPE,
                 "application/vnd.oci.image.manifest.v1+json");
    EXPECT_STREQ(DOCKER_MANIFEST_MEDIA_TYPE,
                 "application/vnd.docker.distribution.manifest.v2+json");
    EXPECT_STREQ(OCI_IMAGE_INDEX_MEDIA_TYPE,
                 "application/vnd.oci.image.index.v1+json");
    EXPECT_STREQ(DOCKER_MANIFEST_LIST_MEDIA_TYPE,
                 "application/vnd.docker.distribution.manifest.list.v2+json");
}

// Test OciClient instance creation
TEST_F(OciClientTest, InstanceCreation)
{
    OciClient client(httpClient, casClient, assetClient);
    // No assertion needed, we're just verifying the constructor doesn't throw
}

// Test that "latest" tag references are rejected
TEST_F(OciClientTest, RejectTagReferences)
{
    OciClient client(httpClient, casClient, assetClient);

    // "latest" tag references should be rejected
    EXPECT_THROW(
        {
            try {
                client.getOCIManifest(
                    client.parseOCIuri("registry.example.com/myimage:latest"));
            }
            catch (const OciInvalidUriException &e) {
                // Make sure the error message mentions deterministic
                EXPECT_NE(std::string(e.what()).find("deterministic"),
                          std::string::npos);
                throw;
            }
        },
        OciInvalidUriException);
}

// Test that default "latest" tag is rejected
TEST_F(OciClientTest, RejectDefaultLatestTag)
{
    OciClient client(httpClient, casClient, assetClient);

    // References without tags or digests should be rejected
    EXPECT_THROW(
        {
            client.getOCIManifest(
                client.parseOCIuri("registry.example.com/myimage"));
        },
        OciInvalidUriException);
}

// Test verifyContent function with valid digest
TEST_F(OciClientTest, VerifyContentSuccess)
{
    const std::string testContent = "Hello, world!";
    const Digest contentDigest = DigestGenerator::hash(testContent);
    const std::string &expectedDigest = contentDigest.hash();

    // Verify with correct digest should return true
    EXPECT_TRUE(OciClient::verifyContent(testContent, expectedDigest));
}

// Test verifyContent function with invalid digest
TEST_F(OciClientTest, VerifyContentMismatch)
{
    const std::string testContent = "Hello, world!";
    const std::string wrongDigest =
        "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";

    // Verify with incorrect digest should return false
    EXPECT_FALSE(OciClient::verifyContent(testContent, wrongDigest));
}

// Test verifyContent function with non-SHA256 digest function
TEST_F(OciClientTest, VerifyContentNonSha256)
{
    DigestGenerator::resetState();
    DigestGenerator::init(DigestFunction_Value_SHA1);
    const std::string testContent = "Hello, world!";
    const std::string someDigest =
        "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";

    // Should throw exception for non-SHA256 digest function
    EXPECT_THROW(OciClient::verifyContent(testContent, someDigest),
                 OciRegistryException);
    DigestGenerator::resetState();
    DigestGenerator::init();
    EXPECT_EQ(DigestGenerator::digestFunction(),
              static_cast<DigestFunction_Value>(
                  BUILDBOXCOMMON_DIGEST_FUNCTION_VALUE));
}

// Test class for all manifest retrieval scenarios
class OciClientManifestTest : public OciClientTest {
  protected:
    void registerHandlers() override
    {
        // This will be set up dynamically in individual tests
    }
};

TEST_F(OciClientManifestTest, GetDockerManifestSuccess)
{
    OciClient client(httpClient, casClient, assetClient);

    // Calculate the actual SHA256 digest of our test manifest
    const Digest manifestDigest = DigestGenerator::hash(VALID_MANIFEST_JSON);
    const std::string &testDigest = manifestDigest.hash();

    // Update the server to use the calculated digest
    server->Get("/v2/myrepo/image/manifests/sha256:" + testDigest,
                [](const httplib::Request &, httplib::Response &res) {
                    res.status = kStatusOK;
                    res.set_content(VALID_MANIFEST_JSON, "application/json");
                    res.set_header("Content-Type", DOCKER_MANIFEST_MEDIA_TYPE);
                });

    // Create a URI using the calculated digest
    std::string testUri = baseUrl.substr(kHttpPrefixLength) +
                          "/myrepo/image@sha256:" + testDigest;

    // Test successful retrieval
    OciManifest manifest = client.getOCIManifest(client.parseOCIuri(testUri));

    // Verify manifest content
    EXPECT_EQ(manifest.d_mediaType, DOCKER_MANIFEST_MEDIA_TYPE);
    EXPECT_EQ(manifest.d_schemaVersion, 2);
    EXPECT_EQ(manifest.d_layers.size(), 1);
    EXPECT_EQ(manifest.d_reference, "sha256:" + testDigest);
    EXPECT_EQ(manifest.d_registryUri, "127.0.0.1:" + std::to_string(d_port));
    EXPECT_EQ(manifest.d_repository, "myrepo/image");
}

TEST_F(OciClientManifestTest, GetOCIManifestInvalidJson)
{
    OciClient client(httpClient, casClient, assetClient);
    const std::string invalidJson = "{ invalid json }";

    // Calculate the digest of the invalid JSON
    const Digest invalidDigest = DigestGenerator::hash(invalidJson);
    const std::string &testDigest = invalidDigest.hash();

    // Update the server to use the calculated digest
    server->Get(
        "/v2/myrepo/image/manifests/sha256:" + testDigest,
        [invalidJson](const httplib::Request &, httplib::Response &res) {
            res.status = kStatusOK;
            res.set_content(invalidJson, "application/json");
            res.set_header("Content-Type", DOCKER_MANIFEST_MEDIA_TYPE);
        });

    std::string testUri = baseUrl.substr(kHttpPrefixLength) +
                          "/myrepo/image@sha256:" + testDigest;

    // Invalid JSON should throw a OciRegistryApiCallException
    EXPECT_THROW(client.getOCIManifest(client.parseOCIuri(testUri)),
                 OciRegistryApiCallException);
}

TEST_F(OciClientManifestTest, GetOCIManifestDigestMismatch)
{
    OciClient client(httpClient, casClient, assetClient);
    const std::string testDigest =
        "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";

    // Set up server to return different content than expected digest
    server->Get("/v2/myrepo/image/manifests/sha256:" + testDigest,
                [](const httplib::Request &, httplib::Response &res) {
                    res.status = kStatusOK;
                    res.set_content("wrong content", "application/json");
                    res.set_header("Content-Type", DOCKER_MANIFEST_MEDIA_TYPE);
                });

    std::string testUri = baseUrl.substr(kHttpPrefixLength) +
                          "/myrepo/image@sha256:" + testDigest;

    // Digest mismatch should throw a OciRegistryApiCallException
    EXPECT_THROW(client.getOCIManifest(client.parseOCIuri(testUri)),
                 OciRegistryApiCallException);
}

TEST_F(OciClientManifestTest, GetOCIManifestApiError)
{
    OciClient client(httpClient, casClient, assetClient);
    const std::string testDigest = "abcdef1234567890";

    // Set up server to return an error
    server->Get("/v2/myrepo/image/manifests/sha256:" + testDigest,
                [](const httplib::Request &, httplib::Response &res) {
                    res.status = kStatusNotFound;
                    res.set_content(
                        "{\"errors\":[{\"code\":\"MANIFEST_UNKNOWN\"}]}",
                        "application/json");
                });

    std::string testUri = baseUrl.substr(kHttpPrefixLength) +
                          "/myrepo/image@sha256:" + testDigest;

    // API error should throw a OciRegistryApiCallException
    EXPECT_THROW(client.getOCIManifest(client.parseOCIuri(testUri)),
                 OciRegistryApiCallException);
}

TEST_F(OciClientManifestTest, GetOCIManifestUnsupportedMediaType)
{
    OciClient client(httpClient, casClient, assetClient);

    const std::string unsupportedManifest = R"({
      "schemaVersion": 2,
      "mediaType": "application/vnd.docker.distribution.manifest.v1+json",
      "config": {
        "mediaType": "application/vnd.docker.container.image.v1+json",
        "digest": "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
        "size": 1470
      },
      "layers": [
        {
          "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
          "digest": "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
          "size": 1000
        }
      ]
    })";

    const Digest unsupportedDigest =
        DigestGenerator::hash(unsupportedManifest);
    const std::string &testDigest = unsupportedDigest.hash();

    // Update the server to use the calculated digest
    server->Get(
        "/v2/myrepo/image/manifests/sha256:" + testDigest,
        [unsupportedManifest](const httplib::Request &,
                              httplib::Response &res) {
            res.status = kStatusOK;
            res.set_content(unsupportedManifest, "application/json");
            res.set_header(
                "Content-Type",
                "application/vnd.docker.distribution.manifest.v1+json");
        });

    std::string testUri = baseUrl.substr(kHttpPrefixLength) +
                          "/myrepo/image@sha256:" + testDigest;

    // Unsupported media type should throw a OciRegistryApiCallException
    EXPECT_THROW(client.getOCIManifest(client.parseOCIuri(testUri)),
                 OciRegistryApiCallException);
}

// Test that custom tag references are accepted
TEST_F(OciClientTest, AcceptCustomTagReferences)
{
    OciClient client(httpClient, casClient, assetClient);

    // Set up server to return a valid manifest for custom tag
    server->Get("/v2/myimage/manifests/v1.0",
                [](const httplib::Request &, httplib::Response &res) {
                    res.status = kStatusOK;
                    res.set_content(VALID_MANIFEST_JSON, "application/json");
                    res.set_header("Content-Type", DOCKER_MANIFEST_MEDIA_TYPE);
                });

    // Custom tag references should be accepted (but will fail due to content
    // verification) We're just testing that the tag is not rejected at
    // validation stage
    std::string testUri = baseUrl.substr(kHttpPrefixLength) + "/myimage:v1.0";

    OciManifest manifest = client.getOCIManifest(client.parseOCIuri(testUri));
    // If we reach here, the tag was accepted
    EXPECT_EQ(manifest.d_mediaType, DOCKER_MANIFEST_MEDIA_TYPE);
}

// Test class for manifest list scenarios
class OciClientManifestListTest : public OciClientTest {
  protected:
    void registerHandlers() override
    {
        // This will be set up dynamically in individual tests
    }
};

TEST_F(OciClientManifestListTest, GetManifestListSuccess)
{
    OciClient client(httpClient, casClient, assetClient);

    // Calculate the actual digest of the manifest JSON
    const Digest manifestDigest = DigestGenerator::hash(VALID_MANIFEST_JSON);
    const std::string &actualManifestDigest = manifestDigest.hash();

    // Create manifest list JSON with the correct digest
    const std::string manifestListJson = R"({
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "size": 1234,
      "digest": "sha256:)" + actualManifestDigest +
                                         R"(",
      "platform": {
        "architecture": "amd64",
        "os": "linux"
      }
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "size": 1456,
      "digest": "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
      "platform": {
        "architecture": "arm64",
        "os": "linux"
      }
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "size": 1678,
      "digest": "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
      "platform": {
        "architecture": "amd64",
        "os": "windows"
      }
    }
  ]
})";

    // Calculate digest of the manifest list
    const Digest manifestListDigest = DigestGenerator::hash(manifestListJson);
    const std::string &listDigest = manifestListDigest.hash();

    // Set up server to return manifest list first
    server->Get(
        "/v2/myrepo/image/manifests/sha256:" + listDigest,
        [manifestListJson](const httplib::Request &, httplib::Response &res) {
            res.status = kStatusOK;
            res.set_content(manifestListJson, "application/json");
            res.set_header("Content-Type", OCI_IMAGE_INDEX_MEDIA_TYPE);
        });

    // Set up server to return the actual manifest for linux/amd64
    server->Get("/v2/myrepo/image/manifests/sha256:" + actualManifestDigest,
                [](const httplib::Request &, httplib::Response &res) {
                    res.status = kStatusOK;
                    res.set_content(VALID_MANIFEST_JSON, "application/json");
                    res.set_header("Content-Type", DOCKER_MANIFEST_MEDIA_TYPE);
                });

    std::string testUri = baseUrl.substr(kHttpPrefixLength) +
                          "/myrepo/image@sha256:" + listDigest;

    // Test successful retrieval from manifest list
    OciManifest manifest = client.getOCIManifest(client.parseOCIuri(testUri));

    // Verify manifest content
    EXPECT_EQ(manifest.d_mediaType, DOCKER_MANIFEST_MEDIA_TYPE);
    EXPECT_EQ(manifest.d_schemaVersion, 2);
    EXPECT_EQ(manifest.d_layers.size(), 1);
    EXPECT_EQ(manifest.d_reference, "sha256:" + actualManifestDigest);
    EXPECT_EQ(manifest.d_registryUri, "127.0.0.1:" + std::to_string(d_port));
    EXPECT_EQ(manifest.d_repository, "myrepo/image");
}

TEST_F(OciClientManifestListTest, GetManifestListNoLinuxAmd64)
{
    OciClient client(httpClient, casClient, assetClient);

    const Digest manifestListDigest =
        DigestGenerator::hash(MANIFEST_LIST_NO_LINUX_AMD64_JSON);
    const std::string &listDigest = manifestListDigest.hash();

    // Set up server to return manifest list without linux/amd64
    server->Get("/v2/myrepo/image/manifests/sha256:" + listDigest,
                [](const httplib::Request &, httplib::Response &res) {
                    res.status = kStatusOK;
                    res.set_content(MANIFEST_LIST_NO_LINUX_AMD64_JSON,
                                    "application/json");
                    res.set_header("Content-Type",
                                   DOCKER_MANIFEST_LIST_MEDIA_TYPE);
                });

    std::string testUri = baseUrl.substr(kHttpPrefixLength) +
                          "/myrepo/image@sha256:" + listDigest;

    // Should throw OciUnsupportedPlatformException
    EXPECT_THROW(client.getOCIManifest(client.parseOCIuri(testUri)),
                 OciUnsupportedPlatformException);
}

TEST_F(OciClientManifestListTest, GetManifestListMultipleLinuxAmd64)
{
    OciClient client(httpClient, casClient, assetClient);

    const Digest manifestListDigest =
        DigestGenerator::hash(MANIFEST_LIST_MULTIPLE_LINUX_AMD64_JSON);
    const std::string &listDigest = manifestListDigest.hash();

    // Set up server to return manifest list with multiple linux/amd64 entries
    server->Get("/v2/myrepo/image/manifests/sha256:" + listDigest,
                [](const httplib::Request &, httplib::Response &res) {
                    res.status = kStatusOK;
                    res.set_content(MANIFEST_LIST_MULTIPLE_LINUX_AMD64_JSON,
                                    "application/json");
                    res.set_header("Content-Type", OCI_IMAGE_INDEX_MEDIA_TYPE);
                });

    std::string testUri = baseUrl.substr(kHttpPrefixLength) +
                          "/myrepo/image@sha256:" + listDigest;

    // Should throw OciRegistryApiCallException for multiple entries
    EXPECT_THROW(client.getOCIManifest(client.parseOCIuri(testUri)),
                 OciRegistryApiCallException);
}

TEST_F(OciClientManifestListTest, GetManifestListMissingPlatformFields)
{
    OciClient client(httpClient, casClient, assetClient);

    const Digest manifestListDigest =
        DigestGenerator::hash(MANIFEST_LIST_MISSING_PLATFORM_JSON);
    const std::string &listDigest = manifestListDigest.hash();

    // Set up server to return manifest list with missing platform fields
    server->Get("/v2/myrepo/image/manifests/sha256:" + listDigest,
                [](const httplib::Request &, httplib::Response &res) {
                    res.status = kStatusOK;
                    res.set_content(MANIFEST_LIST_MISSING_PLATFORM_JSON,
                                    "application/json");
                    res.set_header("Content-Type", OCI_IMAGE_INDEX_MEDIA_TYPE);
                });

    std::string testUri = baseUrl.substr(kHttpPrefixLength) +
                          "/myrepo/image@sha256:" + listDigest;

    // Should throw OciRegistryApiCallException for parsing error
    EXPECT_THROW(client.getOCIManifest(client.parseOCIuri(testUri)),
                 OciRegistryApiCallException);
}

TEST_F(OciClientManifestListTest, GetManifestListWithCustomTag)
{
    OciClient client(httpClient, casClient, assetClient);

    // Calculate the actual digest of the manifest JSON
    const Digest manifestDigest = DigestGenerator::hash(VALID_MANIFEST_JSON);
    const std::string &actualManifestDigest = manifestDigest.hash();

    // Create manifest list JSON with the correct digest
    const std::string manifestListJson = R"({
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "size": 1234,
      "digest": "sha256:)" + actualManifestDigest +
                                         R"(",
      "platform": {
        "architecture": "amd64",
        "os": "linux"
      }
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "size": 1456,
      "digest": "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
      "platform": {
        "architecture": "arm64",
        "os": "linux"
      }
    }
  ]
})";

    // Set up server to return manifest list for custom tag
    server->Get(
        "/v2/myrepo/image/manifests/v1.2.3",
        [manifestListJson](const httplib::Request &, httplib::Response &res) {
            res.status = kStatusOK;
            res.set_content(manifestListJson, "application/json");
            res.set_header("Content-Type", OCI_IMAGE_INDEX_MEDIA_TYPE);
        });

    // Set up server to return the actual manifest for linux/amd64
    server->Get("/v2/myrepo/image/manifests/sha256:" + actualManifestDigest,
                [](const httplib::Request &, httplib::Response &res) {
                    res.status = kStatusOK;
                    res.set_content(VALID_MANIFEST_JSON, "application/json");
                    res.set_header("Content-Type", DOCKER_MANIFEST_MEDIA_TYPE);
                });

    std::string testUri =
        baseUrl.substr(kHttpPrefixLength) + "/myrepo/image:v1.2.3";

    // Test successful retrieval from manifest list with custom tag
    OciManifest manifest = client.getOCIManifest(client.parseOCIuri(testUri));

    // Verify manifest content - reference should be the sha256 of the actual
    // manifest
    EXPECT_EQ(manifest.d_mediaType, DOCKER_MANIFEST_MEDIA_TYPE);
    EXPECT_EQ(manifest.d_schemaVersion, 2);
    EXPECT_EQ(manifest.d_layers.size(), 1);
    EXPECT_EQ(manifest.d_reference, "sha256:" + actualManifestDigest);
    EXPECT_EQ(manifest.d_registryUri, "127.0.0.1:" + std::to_string(d_port));
    EXPECT_EQ(manifest.d_repository, "myrepo/image");
}

TEST_F(OciClientManifestTest, GetManifestWithDockerContentDigestHeader)
{
    OciClient client(httpClient, casClient, assetClient);

    // Calculate the actual SHA256 digest of our test manifest
    const Digest manifestDigest = DigestGenerator::hash(VALID_MANIFEST_JSON);
    const std::string &testDigest = manifestDigest.hash();

    // Update the server to return docker-content-digest header
    server->Get(
        "/v2/myrepo/image/manifests/sha256:" + testDigest,
        [testDigest](const httplib::Request &, httplib::Response &res) {
            res.status = kStatusOK;
            res.set_content(VALID_MANIFEST_JSON, "application/json");
            res.set_header("Content-Type", DOCKER_MANIFEST_MEDIA_TYPE);
            // Set the docker-content-digest header
            res.set_header("docker-content-digest", "sha256:" + testDigest);
        });

    // Create a URI using the calculated digest
    std::string testUri = baseUrl.substr(kHttpPrefixLength) +
                          "/myrepo/image@sha256:" + testDigest;

    // Test successful retrieval with header verification
    OciManifest manifest = client.getOCIManifest(client.parseOCIuri(testUri));

    // Verify manifest content
    EXPECT_EQ(manifest.d_mediaType, DOCKER_MANIFEST_MEDIA_TYPE);
    EXPECT_EQ(manifest.d_schemaVersion, 2);
    EXPECT_EQ(manifest.d_layers.size(), 1);
    EXPECT_EQ(manifest.d_reference, "sha256:" + testDigest);
    EXPECT_EQ(manifest.d_registryUri, "127.0.0.1:" + std::to_string(d_port));
    EXPECT_EQ(manifest.d_repository, "myrepo/image");
}

TEST_F(OciClientManifestTest, GetManifestWithInvalidDockerContentDigestHeader)
{
    OciClient client(httpClient, casClient, assetClient);

    // Calculate the actual SHA256 digest of our test manifest
    const Digest manifestDigest = DigestGenerator::hash(VALID_MANIFEST_JSON);
    const std::string &testDigest = manifestDigest.hash();

    // Update the server to return incorrect docker-content-digest header
    server->Get("/v2/myrepo/image/manifests/sha256:" + testDigest,
                [](const httplib::Request &, httplib::Response &res) {
                    res.status = kStatusOK;
                    res.set_content(VALID_MANIFEST_JSON, "application/json");
                    res.set_header("Content-Type", DOCKER_MANIFEST_MEDIA_TYPE);
                    // Set an incorrect docker-content-digest header
                    res.set_header("docker-content-digest",
                                   "sha256:"
                                   "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefde"
                                   "adbeefdeadbeefdeadbeef");
                });

    // Create a URI using the calculated digest
    std::string testUri = baseUrl.substr(kHttpPrefixLength) +
                          "/myrepo/image@sha256:" + testDigest;

    // Test that invalid header digest causes exception
    EXPECT_THROW(
        {
            try {
                client.getOCIManifest(client.parseOCIuri(testUri));
            }
            catch (const OciRegistryApiCallException &e) {
                // Verify the exception message mentions digest mismatch
                std::string errorMsg = e.what();
                EXPECT_TRUE(errorMsg.find("Content digest mismatch") !=
                            std::string::npos);
                EXPECT_TRUE(errorMsg.find("docker-content-digest header") !=
                            std::string::npos);
                throw;
            }
        },
        OciRegistryApiCallException);
}

TEST_F(OciClientManifestListTest, GetManifestListCustomPlatform)
{
    OciClient client(httpClient, casClient, assetClient);

    // Calculate the actual digest of the manifest JSON
    const Digest manifestDigest = DigestGenerator::hash(VALID_MANIFEST_JSON);
    const std::string &actualManifestDigest = manifestDigest.hash();

    // Create manifest list JSON with multiple platforms
    const std::string manifestListJson = R"({
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "size": 1234,
      "digest": "sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
      "platform": {
        "architecture": "amd64",
        "os": "linux"
      }
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "size": 1456,
      "digest": "sha256:)" + actualManifestDigest +
                                         R"(",
      "platform": {
        "architecture": "arm64",
        "os": "linux"
      }
    }
  ]
})";

    // Calculate the digest of the manifest list
    const Digest manifestListDigest = DigestGenerator::hash(manifestListJson);
    const std::string &manifestListDigestStr = manifestListDigest.hash();

    // Set up server to return manifest list
    server->Get("/v2/myrepo/image/manifests/sha256:" + manifestListDigestStr,
                [manifestListJson, manifestListDigestStr](
                    const httplib::Request &, httplib::Response &res) {
                    res.status = kStatusOK;
                    res.set_content(manifestListJson, "application/json");
                    res.set_header("Content-Type", OCI_IMAGE_INDEX_MEDIA_TYPE);
                    res.set_header("docker-content-digest",
                                   "sha256:" + manifestListDigestStr);
                });

    // Set up server to return the actual manifest for linux/arm64
    server->Get("/v2/myrepo/image/manifests/sha256:" + actualManifestDigest,
                [actualManifestDigest](const httplib::Request &,
                                       httplib::Response &res) {
                    res.status = kStatusOK;
                    res.set_content(VALID_MANIFEST_JSON, "application/json");
                    res.set_header("Content-Type", DOCKER_MANIFEST_MEDIA_TYPE);
                    res.set_header("docker-content-digest",
                                   "sha256:" + actualManifestDigest);
                });

    std::string testUri = baseUrl.substr(kHttpPrefixLength) +
                          "/myrepo/image@sha256:" + manifestListDigestStr;

    // Test retrieval with custom platform (linux/arm64 instead of default
    // linux/amd64)
    OciManifest manifest =
        client.getOCIManifest(client.parseOCIuri(testUri), "linux", "arm64");

    // Verify manifest content - should get the arm64 variant, not the default
    // amd64
    EXPECT_EQ(manifest.d_mediaType, DOCKER_MANIFEST_MEDIA_TYPE);
    EXPECT_EQ(manifest.d_schemaVersion, 2);
    EXPECT_EQ(manifest.d_layers.size(), 1);
    EXPECT_EQ(manifest.d_reference, "sha256:" + actualManifestDigest);
    EXPECT_EQ(manifest.d_registryUri, "127.0.0.1:" + std::to_string(d_port));
    EXPECT_EQ(manifest.d_repository, "myrepo/image");
}

// Test fixture for streaming and extraction tests
class OciClientExtractionTest : public OciClientTest {
  public:
    void SetUp() override
    {
        OciClientTest::SetUp();
        createTestLayerContent();
    }

    void createTestLayerContent()
    {
        // Read the tar.gz file from test data directory
        // Uses relative path when run with ctest (working directory set to
        // data/)
        const std::string tarGzPath = "hello.tar.gz";

        std::ifstream file(tarGzPath, std::ios::binary);
        if (!file) {
            FAIL() << "Could not read test data file: " << tarGzPath;
        }

        // Read the entire file content
        std::ostringstream buffer;
        buffer << file.rdbuf();
        testLayerContent = buffer.str();

        // Calculate the SHA256 digest of the tar.gz content
        const Digest layerDigest = DigestGenerator::hash(testLayerContent);
        testLayerDigest = "sha256:" + layerDigest.hash();

        // Mock server endpoint that returns the tar.gz content
        server->Get("/v2/test-repo/blobs/" + testLayerDigest,
                    [this](const httplib::Request &, httplib::Response &res) {
                        res.set_content(testLayerContent,
                                        "application/octet-stream");
                        res.status = kStatusOK;
                    });

        // Image digest tests use a different repository path.
        server->Get("/v2/test/image/blobs/" + testLayerDigest,
                    [this](const httplib::Request &, httplib::Response &res) {
                        res.set_content(testLayerContent,
                                        "application/octet-stream");
                        res.status = kStatusOK;
                    });
    }

    std::string testLayerContent;
    std::string testLayerDigest;
};

TEST_F(OciClientExtractionTest, StreamAndExtractLayerUnsupportedMediaType)
{
    OciClient client(httpClient, casClient, assetClient);
    TemporaryDirectory tempDir;

    // Test with unsupported media type
    OciManifestLayer unsupportedLayer;
    unsupportedLayer.d_mediaType = "application/vnd.unsupported.layer";
    unsupportedLayer.d_digest = testLayerDigest;
    unsupportedLayer.d_size = static_cast<int64_t>(testLayerContent.size());

    // Should throw exception due to unsupported media type
    EXPECT_THROW(client.streamAndExtractLayer(&tempDir, baseUrl, "test-repo",
                                              unsupportedLayer),
                 OciRegistryException);
}

TEST_F(OciClientExtractionTest, StreamAndExtractLayerNullPointer)
{
    OciClient client(httpClient, casClient, assetClient);

    OciManifestLayer layer;
    layer.d_mediaType = DOCKER_LAYER_MEDIA_TYPE;
    layer.d_digest = testLayerDigest;
    layer.d_size = static_cast<int64_t>(testLayerContent.size());

    // Should throw exception with null pointer
    EXPECT_THROW(
        client.streamAndExtractLayer(nullptr, baseUrl, "test-repo", layer),
        OciRegistryException);
}
#ifdef BUILDBOX_OCI_TAR_EXTRACTION_TESTS
// TAR extraction test - requires tar binary to be available in the environment
// Tests that file permissions are preserved during extraction
TEST_F(OciClientExtractionTest, StreamAndExtractLayerPermissionsPreserved)
{
    OciClient client(httpClient, casClient, assetClient);
    TemporaryDirectory tempDir;

    // Create a proper layer with correct media type and digest
    OciManifestLayer layer;
    layer.d_mediaType = DOCKER_LAYER_MEDIA_TYPE;
    layer.d_digest = testLayerDigest;
    layer.d_size = static_cast<int64_t>(testLayerContent.size());

    // Test successful extraction
    ASSERT_NO_THROW(
        client.streamAndExtractLayer(&tempDir, baseUrl, "test-repo", layer));

    // Verify that files were extracted with correct permissions
    const std::string helloPath = tempDir.strname() + "/hello";
    const std::string scriptPath = tempDir.strname() + "/script.sh";
    const std::string readonlyPath = tempDir.strname() + "/readonly.txt";

    // Check that all files exist
    ASSERT_TRUE(FileUtils::isRegularFile(helloPath.c_str()))
        << "hello file should exist at: " << helloPath;
    ASSERT_TRUE(FileUtils::isRegularFile(scriptPath.c_str()))
        << "script.sh file should exist at: " << scriptPath;
    ASSERT_TRUE(FileUtils::isRegularFile(readonlyPath.c_str()))
        << "readonly.txt file should exist at: " << readonlyPath;

    // Check file permissions using stat
    struct stat helloStat{}, scriptStat{}, readonlyStat{};
    ASSERT_EQ(stat(helloPath.c_str(), &helloStat), 0)
        << "Failed to stat hello file";
    ASSERT_EQ(stat(scriptPath.c_str(), &scriptStat), 0)
        << "Failed to stat script.sh file";
    ASSERT_EQ(stat(readonlyPath.c_str(), &readonlyStat), 0)
        << "Failed to stat readonly.txt file";

    // Verify permissions (using mode & 0777 to get only permission bits)
    EXPECT_EQ(helloStat.st_mode & 0777, 0644)
        << "hello file should have 644 permissions";
    EXPECT_EQ(scriptStat.st_mode & 0777, 0755)
        << "script.sh file should have 755 permissions";
    EXPECT_EQ(readonlyStat.st_mode & 0777, 0444)
        << "readonly.txt file should have 444 permissions";

    // Verify file contents as well
    std::ifstream helloFile(helloPath);
    std::string helloContent;
    std::getline(helloFile, helloContent);
    EXPECT_EQ(helloContent, "hello world");

    std::ifstream scriptFile(scriptPath);
    std::string scriptContent;
    std::getline(scriptFile, scriptContent);
    EXPECT_EQ(scriptContent, "#!/bin/bash");

    std::ifstream readonlyFile(readonlyPath);
    std::string readonlyContent;
    std::getline(readonlyFile, readonlyContent);
    EXPECT_EQ(readonlyContent, "readonly content");
}
#endif // BUILDBOX_OCI_TAR_EXTRACTION_TESTS
// Test fixture for captureLayerToDigest tests with CAS server
class OciClientCaptureFixture : public OciClientExtractionTest {
  protected:
    OciClientCaptureFixture()
        : OciClientExtractionTest(),
          TEST_CAS_SERVER_ADDRESS("unix://" +
                                  std::string(casSocketDirectory.name()) +
                                  "/cas.sock"),
          storage(
              std::make_shared<FsLocalCas>(casStorageRootDirectory.name())),
          assetStorage(std::make_shared<buildboxcasd::FsLocalAssetStorage>(
              casStorageRootDirectory.name())),
          actionStorage(std::make_shared<buildboxcasd::FsLocalActionStorage>(
              casStorageRootDirectory.name())),
          casdServer(std::make_shared<buildboxcasd::Server>(
              storage, assetStorage, actionStorage)),
          grpcClient(std::make_shared<GrpcClient>()),
          realCasClient(std::make_shared<CASClient>(grpcClient)),
          realAssetClient(std::make_shared<TestAssetClient>(grpcClient))
    {
        // Building and starting a server:
        casdServer->addLocalServerInstance(TEST_INSTANCE_NAME);
        casdServer->addListeningPort(TEST_CAS_SERVER_ADDRESS);
        casdServer->start();

        ConnectionOptions options;
        options.d_url = TEST_CAS_SERVER_ADDRESS;
        options.d_instanceName = TEST_INSTANCE_NAME;
        grpcClient->init(options);
        realCasClient->init();
        realAssetClient->init();
    }

    void SetUp() override
    {
        OciClientExtractionTest::SetUp();
        registerImageDigestsManifestHandler();
    }

    void registerImageDigestsManifestHandler()
    {
        testManifestJson =
            R"({
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "size": 1461,
    "digest": "sha256:4eacea30377a698ef8fbec99b6caf01cb150151cbedc8e0b1c3d22f134206f1a"
  },
  "layers": [
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": )" +
            std::to_string(testLayerContent.size()) + R"(,
      "digest": ")" +
            testLayerDigest + R"("
    }
  ]
})";

        const Digest manifestDigest = DigestGenerator::hash(testManifestJson);
        testManifestDigest = manifestDigest.hash();

        server->Get("/v2/test/image/manifests/sha256:" + testManifestDigest,
                    [this](const httplib::Request &, httplib::Response &res) {
                        res.status = kStatusOK;
                        res.set_content(testManifestJson, "application/json");
                        res.set_header("Content-Type",
                                       OCI_MANIFEST_MEDIA_TYPE);
                        res.set_header("docker-content-digest",
                                       "sha256:" + testManifestDigest);
                    });
    }

    static void createTestDirectoryStructure(const std::string &baseDir)
    {
        // Create a more complex directory structure
        const std::string subDir1 = baseDir + "/subdir1";
        const std::string subDir2 = baseDir + "/subdir2";
        const std::string nestedDir = subDir1 + "/nested";

        // Create directories
        ASSERT_EQ(mkdir(subDir1.c_str(), 0755), 0);
        ASSERT_EQ(mkdir(subDir2.c_str(), 0755), 0);
        ASSERT_EQ(mkdir(nestedDir.c_str(), 0755), 0);

        // Create files in different directories
        std::ofstream file1(baseDir + "/root_file.txt");
        file1 << "root level file content";
        file1.close();

        std::ofstream file2(subDir1 + "/file1.txt");
        file2 << "subdirectory 1 file content";
        file2.close();

        std::ofstream file3(subDir2 + "/file2.txt");
        file3 << "subdirectory 2 file content";
        file3.close();

        std::ofstream file4(nestedDir + "/nested_file.txt");
        file4 << "nested directory file content";
        file4.close();
    }

    // Creates every component of relPath under base (equivalent to mkdir -p).
    static void makeDirs(const std::string &base, const std::string &rel)
    {
        std::string path = base;
        std::istringstream ss(rel);
        std::string component;
        while (std::getline(ss, component, '/')) {
            if (component.empty())
                continue;
            path += "/" + component;
            ASSERT_EQ(mkdir(path.c_str(), 0755), 0)
                << "Failed to create directory: " << path;
        }
    }

    static void makeFile(const std::string &base, const std::string &rel,
                         const std::string &content)
    {
        std::ofstream ofs(base + "/" + rel);
        ASSERT_TRUE(ofs.is_open()) << "Failed to create: " << base + "/" + rel;
        ofs << content;
    }

    // Verify DirectoryTrees are the same, down to ordering
    static bool isTreeSame(const DirectoryTree &t1, const DirectoryTree &t2)
    {
        if (t1.map.size() != t2.map.size()) {
            return false;
        }

        // Lambda to get BFS ordering of DirectoryTree
        // This is deterministic since the DirectoryTree's digest to directory
        // map is ordered
        auto bfsOrder = [](const DirectoryTree &tree) {
            std::vector<const Directory *> result;
            std::queue<Digest> queue;
            queue.push(tree.root);

            while (!queue.empty()) {
                auto it = tree.map.find(queue.front());
                queue.pop();
                if (it == tree.map.end())
                    continue;
                result.push_back(&it->second);
                for (const auto &node : it->second.directories()) {
                    queue.push(node.digest());
                }
            }
            return result;
        };

        auto dirs1 = bfsOrder(t1);
        auto dirs2 = bfsOrder(t2);

        for (std::size_t i = 0; i < dirs1.size(); ++i) {
            if (dirs1[i]->SerializeAsString() != dirs2[i]->SerializeAsString())
                return false;
        }
        return true;
    }

    inline std::size_t assetClientCacheHits()
    {
        auto testClient =
            dynamic_cast<TestAssetClient *>(realAssetClient.get());
        return testClient->getCacheHits();
    }

    buildboxcommon::TemporaryDirectory casSocketDirectory;
    buildboxcommon::TemporaryDirectory casStorageRootDirectory;
    const std::string TEST_CAS_SERVER_ADDRESS;
    const std::string TEST_INSTANCE_NAME = "testInstances/instance1";

    std::shared_ptr<FsLocalCas> storage;
    std::shared_ptr<buildboxcasd::FsLocalAssetStorage> assetStorage;
    std::shared_ptr<buildboxcasd::FsLocalActionStorage> actionStorage;
    std::shared_ptr<buildboxcasd::Server> casdServer;

    std::shared_ptr<GrpcClient> grpcClient;
    std::shared_ptr<CASClient> realCasClient;
    std::shared_ptr<AssetClient> realAssetClient;

    std::string testManifestJson;
    std::string testManifestDigest;
};

TEST_F(OciClientCaptureFixture, CaptureLayerToDigestSuccess)
{
    // Create OciClient with real CAS client
    OciClient client(httpClient, realCasClient, realAssetClient);
    TemporaryDirectory tempDir;

    // Create a proper layer
    OciManifestLayer layer;
    layer.d_mediaType = DOCKER_LAYER_MEDIA_TYPE;
    layer.d_digest = testLayerDigest;
    layer.d_size = static_cast<int64_t>(testLayerContent.size());

    // Extract layer content first
    ASSERT_NO_THROW(
        client.streamAndExtractLayer(&tempDir, baseUrl, "test-repo", layer));

    // Verify that the file was extracted correctly
    const std::string extractedFilePath = tempDir.strname() + "/hello";
    ASSERT_TRUE(FileUtils::isRegularFile(extractedFilePath.c_str()))
        << "Extracted file should exist at: " << extractedFilePath;

    // Now capture the extracted directory to CAS as a tree
    Digest treeDigest, rootDigest;
    ASSERT_NO_THROW(std::tie(treeDigest, rootDigest) =
                        client.captureLayerToDigest(tempDir.strname(), layer));

    // Verify that we got valid digests
    ASSERT_FALSE(treeDigest.hash().empty());
    ASSERT_GT(treeDigest.size_bytes(), 0);
    ASSERT_FALSE(rootDigest.hash().empty());
    ASSERT_GT(rootDigest.size_bytes(), 0);
}

TEST_F(OciClientCaptureFixture, CaptureLayerToDigestNoCASClient)
{
    // Should throw exception during construction due to null CAS client
    EXPECT_THROW(OciClient client(httpClient, nullptr, realAssetClient),
                 OciRegistryException);
}

TEST_F(OciClientCaptureFixture, CaptureLayerToDigestNonExistentDirectory)
{
    OciClient client(httpClient, realCasClient, realAssetClient);

    OciManifestLayer layer;
    layer.d_mediaType = DOCKER_LAYER_MEDIA_TYPE;
    layer.d_digest = testLayerDigest;
    layer.d_size = static_cast<int64_t>(testLayerContent.size());

    // Should throw exception with non-existent directory
    const std::string nonExistentPath = "/definitely/does/not/exist/path";
    EXPECT_THROW(client.captureLayerToDigest(nonExistentPath, layer),
                 OciRegistryException);
}

TEST_F(OciClientCaptureFixture, CaptureLayerToDigestWithSubdirectories)
{
    OciClient client(httpClient, realCasClient, realAssetClient);
    TemporaryDirectory tempDir;

    // Create complex directory structure
    createTestDirectoryStructure(tempDir.strname());

    OciManifestLayer layer;
    layer.d_mediaType = DOCKER_LAYER_MEDIA_TYPE;
    layer.d_digest = testLayerDigest;
    layer.d_size = static_cast<int64_t>(testLayerContent.size());

    // Capture the directory structure to CAS as a tree
    Digest treeDigest, rootDigest;
    ASSERT_NO_THROW(std::tie(treeDigest, rootDigest) =
                        client.captureLayerToDigest(tempDir.strname(), layer));

    // Verify that we got valid digests
    ASSERT_FALSE(treeDigest.hash().empty());
    ASSERT_GT(treeDigest.size_bytes(), 0);
    ASSERT_FALSE(rootDigest.hash().empty());
    ASSERT_GT(rootDigest.size_bytes(), 0);
}

// Test for mergeLayerTrees function
TEST_F(OciClientCaptureFixture, MergeLayerTrees)
{
    OciClient client(httpClient, realCasClient, realAssetClient);

    // Create two temporary directories with different files
    TemporaryDirectory tempDir1;
    TemporaryDirectory tempDir2;

    // Directory 1: create file1.txt
    const std::string file1Path = tempDir1.strname() + "/file1.txt";
    std::ofstream file1(file1Path);
    file1 << "Content of file 1\n";
    file1.close();

    // Directory 2: create file2.txt
    const std::string file2Path = tempDir2.strname() + "/file2.txt";
    std::ofstream file2(file2Path);
    file2 << "Content of file 2\n";
    file2.close();

    // Capture both directories to CAS to get tree digests
    std::vector<std::string> paths1 = {tempDir1.strname()};
    std::vector<std::string> paths2 = {tempDir2.strname()};
    std::vector<std::string> properties;

    constexpr mode_t kModeDefault = 022;
    CaptureTreeResponse response1 =
        realCasClient->captureTree(paths1, properties, false, kModeDefault);
    CaptureTreeResponse response2 =
        realCasClient->captureTree(paths2, properties, false, kModeDefault);

    ASSERT_EQ(response1.responses_size(), 1);
    ASSERT_EQ(response2.responses_size(), 1);

    Digest treeDigest1 = response1.responses(0).tree_digest();
    Digest treeDigest2 = response2.responses(0).tree_digest();

    // Test merging the two layer trees
    std::vector<Digest> layerTreeDigests = {treeDigest1, treeDigest2};
    Digest mergedRootDigest;
    Digest mergedTreeDigest;

    ASSERT_NO_THROW(std::tie(mergedTreeDigest, mergedRootDigest) =
                        client.mergeLayerTrees(layerTreeDigests));

    // Verify that we got valid merged digests
    ASSERT_GT(mergedRootDigest.hash().size(), 0);
    ASSERT_GT(mergedRootDigest.size_bytes(), 0);
    ASSERT_GT(mergedTreeDigest.hash().size(), 0);
    ASSERT_GT(mergedTreeDigest.size_bytes(), 0);

    // Verify the merged digests are different from individual digests
    EXPECT_NE(mergedRootDigest.hash(), treeDigest1.hash());
    EXPECT_NE(mergedRootDigest.hash(), treeDigest2.hash());
    EXPECT_NE(mergedTreeDigest.hash(), treeDigest1.hash());
    EXPECT_NE(mergedTreeDigest.hash(), treeDigest2.hash());

    BUILDBOX_LOG_INFO("Successfully merged two layer trees into root digest: "
                      << mergedRootDigest.hash()
                      << " and tree digest: " << mergedTreeDigest.hash());
}

// Test error case: empty layer list
TEST_F(OciClientCaptureFixture, MergeLayerTreesEmpty)
{
    OciClient client(httpClient, realCasClient, realAssetClient);

    std::vector<Digest> emptyLayerTreeDigests;

    EXPECT_THROW(client.mergeLayerTrees(emptyLayerTreeDigests),
                 OciRegistryException);
}

// Verifies that BFS traversal correctly reaches all levels of a deep hierarchy
// and that files contributed by different layers into the same subdirectory
// both appear in the merged result.
TEST_F(OciClientCaptureFixture, MergeLayerTreesDeepHierarchy)
{
    OciClient client(httpClient, realCasClient, realAssetClient);

    // Layer 1: a/b/c/deep.txt and a/b/shallow.txt
    TemporaryDirectory tempDir1;
    makeDirs(tempDir1.strname(), "a/b/c");
    makeFile(tempDir1.strname(), "a/b/c/deep.txt", "deep in layer 1\n");
    makeFile(tempDir1.strname(), "a/b/shallow.txt", "shallow\n");

    // Layer 2: a/b/c/other_deep.txt and x/y.txt (contributes to a/b/c and
    // adds a new top-level directory x)
    TemporaryDirectory tempDir2;
    makeDirs(tempDir2.strname(), "a/b/c");
    makeDirs(tempDir2.strname(), "x");
    makeFile(tempDir2.strname(), "a/b/c/other_deep.txt", "deep in layer 2\n");
    makeFile(tempDir2.strname(), "x/y.txt", "x/y\n");

    constexpr mode_t kModeDefault = 022;
    std::vector<std::string> properties;
    CaptureTreeResponse r1 = realCasClient->captureTree(
        {tempDir1.strname()}, properties, false, kModeDefault);
    CaptureTreeResponse r2 = realCasClient->captureTree(
        {tempDir2.strname()}, properties, false, kModeDefault);
    ASSERT_EQ(r1.responses_size(), 1);
    ASSERT_EQ(r2.responses_size(), 1);

    Digest mergedTreeDigest, mergedRootDigest;
    ASSERT_NO_THROW(
        std::tie(mergedTreeDigest, mergedRootDigest) = client.mergeLayerTrees(
            {r1.responses(0).tree_digest(), r2.responses(0).tree_digest()}));

    // Expected: what getTree (old approach) fetches from CAS via RPC
    auto expected = realCasClient->getTree(mergedRootDigest);
    // Actual: what getMergedTree (new approach) built, stored as the Tree
    // message
    auto actual = realCasClient->getTreeFromTreeMessage(mergedTreeDigest);
    ASSERT_TRUE(isTreeSame(expected, actual));
}

// Verifies that when two layers contain an identical subdirectory (same
// content, same digest), the merged tree map contains that digest exactly
// once.
TEST_F(OciClientCaptureFixture, MergeLayerTreesSharedSubdirectory)
{
    OciClient client(httpClient, realCasClient, realAssetClient);

    // Both layers have an identical shared/ directory (same content = same
    // digest in CAS, so BFS will encounter it twice but must only store it
    // once)
    TemporaryDirectory tempDir1;
    makeDirs(tempDir1.strname(), "shared");
    makeFile(tempDir1.strname(), "shared/common.txt", "common\n");
    makeFile(tempDir1.strname(), "layer1_only.txt", "layer1\n");

    TemporaryDirectory tempDir2;
    makeDirs(tempDir2.strname(), "shared");
    makeFile(tempDir2.strname(), "shared/common.txt", "common\n");
    makeFile(tempDir2.strname(), "layer2_only.txt", "layer2\n");

    constexpr mode_t kModeDefault = 022;
    std::vector<std::string> properties;
    CaptureTreeResponse r1 = realCasClient->captureTree(
        {tempDir1.strname()}, properties, false, kModeDefault);
    CaptureTreeResponse r2 = realCasClient->captureTree(
        {tempDir2.strname()}, properties, false, kModeDefault);
    ASSERT_EQ(r1.responses_size(), 1);
    ASSERT_EQ(r2.responses_size(), 1);

    Digest mergedTreeDigest, mergedRootDigest;
    ASSERT_NO_THROW(
        std::tie(mergedTreeDigest, mergedRootDigest) = client.mergeLayerTrees(
            {r1.responses(0).tree_digest(), r2.responses(0).tree_digest()}));

    auto expected = realCasClient->getTree(mergedRootDigest);
    auto actual = realCasClient->getTreeFromTreeMessage(mergedTreeDigest);
    ASSERT_TRUE(isTreeSame(expected, actual));
}

// Verifies that three layers are all correctly incorporated — each
// contributing files to shared directories (bin/, lib/) and unique directories
// (etc/).
TEST_F(OciClientCaptureFixture, MergeLayerTreesThreeLayers)
{
    OciClient client(httpClient, realCasClient, realAssetClient);

    TemporaryDirectory tempDir1;
    makeDirs(tempDir1.strname(), "bin");
    makeDirs(tempDir1.strname(), "lib");
    makeFile(tempDir1.strname(), "bin/tool1", "tool1\n");
    makeFile(tempDir1.strname(), "lib/libA.so", "libA\n");

    TemporaryDirectory tempDir2;
    makeDirs(tempDir2.strname(), "bin");
    makeDirs(tempDir2.strname(), "lib");
    makeFile(tempDir2.strname(), "bin/tool2", "tool2\n");
    makeFile(tempDir2.strname(), "lib/libB.so", "libB\n");

    TemporaryDirectory tempDir3;
    makeDirs(tempDir3.strname(), "bin");
    makeDirs(tempDir3.strname(), "etc");
    makeFile(tempDir3.strname(), "bin/tool3", "tool3\n");
    makeFile(tempDir3.strname(), "etc/config.ini", "config\n");

    constexpr mode_t kModeDefault = 022;
    std::vector<std::string> properties;
    CaptureTreeResponse r1 = realCasClient->captureTree(
        {tempDir1.strname()}, properties, false, kModeDefault);
    CaptureTreeResponse r2 = realCasClient->captureTree(
        {tempDir2.strname()}, properties, false, kModeDefault);
    CaptureTreeResponse r3 = realCasClient->captureTree(
        {tempDir3.strname()}, properties, false, kModeDefault);
    ASSERT_EQ(r1.responses_size(), 1);
    ASSERT_EQ(r2.responses_size(), 1);
    ASSERT_EQ(r3.responses_size(), 1);

    Digest mergedTreeDigest, mergedRootDigest;
    ASSERT_NO_THROW(
        std::tie(mergedTreeDigest, mergedRootDigest) = client.mergeLayerTrees(
            {r1.responses(0).tree_digest(), r2.responses(0).tree_digest(),
             r3.responses(0).tree_digest()}));

    auto expected = realCasClient->getTree(mergedRootDigest);
    auto actual = realCasClient->getTreeFromTreeMessage(mergedTreeDigest);
    ASSERT_TRUE(isTreeSame(expected, actual));
}

// Test asset service caching performance - second processLayer call should be
// faster
TEST_F(OciClientCaptureFixture, ProcessLayerCachingPerformance)
{
    OciClient client(httpClient, realCasClient, realAssetClient);

    // Create manifest and layer for testing
    OciManifest manifest;
    manifest.d_registryUri = baseUrl;
    manifest.d_repository = "test-repo";
    manifest.d_mediaType = OCI_MANIFEST_MEDIA_TYPE;
    manifest.d_reference = "sha256:test123";

    OciManifestLayer layer;
    layer.d_mediaType = DOCKER_LAYER_MEDIA_TYPE;
    layer.d_digest = testLayerDigest;
    layer.d_size = static_cast<int64_t>(testLayerContent.size());

    // First call - should process layer and cache result
    Digest digest1;
    ASSERT_NO_THROW(digest1 = client.processLayer(manifest, layer));

    // Cache miss
    EXPECT_EQ(assetClientCacheHits(), 0);

    // Verify we got a valid digest from first call
    ASSERT_FALSE(digest1.hash().empty());
    ASSERT_GT(digest1.size_bytes(), 0);

    // Second call - should hit asset service cache
    Digest digest2;
    ASSERT_NO_THROW(digest2 = client.processLayer(manifest, layer));

    // Verify second call returns same digest
    EXPECT_EQ(digest1.hash(), digest2.hash());
    EXPECT_EQ(digest1.size_bytes(), digest2.size_bytes());

    // Cache hit
    EXPECT_EQ(assetClientCacheHits(), 1);
}

TEST_F(OciClientCaptureFixture, GetImageDigestsSuccess)
{
    OciClient client(httpClient, realCasClient, realAssetClient);

    // Build the OCI URI with the manifest digest (already set up in fixture)
    std::string ociUri = baseUrl.substr(kHttpPrefixLength) +
                         "/test/image@sha256:" + testManifestDigest;

    // Call getImageDigests
    Digest treeDigest, rootDigest;
    ASSERT_NO_THROW(std::tie(treeDigest, rootDigest) =
                        client.getImageDigests(ociUri));

    // Verify we got valid digests
    ASSERT_FALSE(treeDigest.hash().empty());
    ASSERT_GT(treeDigest.size_bytes(), 0);
    ASSERT_FALSE(rootDigest.hash().empty());
    ASSERT_GT(rootDigest.size_bytes(), 0);

    BUILDBOX_LOG_INFO("Successfully retrieved image digests: "
                      << "tree=" << treeDigest.hash()
                      << " root=" << rootDigest.hash());
}

TEST_F(OciClientCaptureFixture, GetFullyResolvedImageDigestsWithCaching)
{
    OciClient client(httpClient, realCasClient, realAssetClient);

    // Build OCI URI (manifest and layer already set up in fixture)
    std::string ociUri = baseUrl.substr(kHttpPrefixLength) +
                         "/test/image@sha256:" + testManifestDigest;

    // First call - should process the image and cache result
    Digest treeDigest1, rootDigest1;
    ASSERT_NO_THROW(std::tie(treeDigest1, rootDigest1) =
                        client.getImageDigests(ociUri));

    ASSERT_FALSE(treeDigest1.hash().empty());
    ASSERT_FALSE(rootDigest1.hash().empty());

    // Cache miss
    EXPECT_EQ(assetClientCacheHits(), 0);

    // Second call - should hit asset cache
    Digest treeDigest2, rootDigest2;
    ASSERT_NO_THROW(std::tie(treeDigest2, rootDigest2) =
                        client.getImageDigests(ociUri));

    // Verify second call returns identical digests
    EXPECT_EQ(treeDigest1.hash(), treeDigest2.hash());
    EXPECT_EQ(treeDigest1.size_bytes(), treeDigest2.size_bytes());
    EXPECT_EQ(rootDigest1.hash(), rootDigest2.hash());
    EXPECT_EQ(rootDigest1.size_bytes(), rootDigest2.size_bytes());

    // Cache hit
    EXPECT_EQ(assetClientCacheHits(), 1);
}

TEST_F(OciClientCaptureFixture, GetImageDigestsPopulatesAssetCache)
{
    OciClient client(httpClient, realCasClient, realAssetClient);

    // Use a fully resolved image URI to exercise cache lookup path directly.
    const std::string ociUri = baseUrl.substr(kHttpPrefixLength) +
                               "/test/image@sha256:" + testManifestDigest;

    Digest treeDigest, rootDigest;
    ASSERT_NO_THROW(std::tie(treeDigest, rootDigest) =
                        client.getImageDigests(ociUri));
    ASSERT_FALSE(treeDigest.hash().empty());
    ASSERT_FALSE(rootDigest.hash().empty());

    // After processing, the fully resolved image should be cached.
    std::vector<std::pair<std::string, std::string>> qualifiers;
    qualifiers.emplace_back(BUILDBOX_OCI_VERSION_QUALIFIER,
                            BUILDBOX_OCI_VERSION);
    qualifiers.emplace_back(PLATFORM_OS_QUALIFIER, DEFAULT_PLATFORM_OS);
    qualifiers.emplace_back(PLATFORM_ARCH_QUALIFIER, DEFAULT_PLATFORM_ARCH);
    auto cachedImage = realAssetClient->tryFetchBlob({ociUri}, qualifiers);
    ASSERT_TRUE(cachedImage.has_value());

    if (cachedImage.has_value()) {
        EXPECT_EQ(cachedImage.value().digest.hash(), treeDigest.hash());
        EXPECT_EQ(cachedImage.value().digest.size_bytes(),
                  treeDigest.size_bytes());
    }
}

// Basic test for getImageTreeDigest error case - using a non-existent image
TEST_F(OciClientTest, GetImageTreeDigestError)
{
    OciClient client(httpClient, casClient, assetClient);

    // This should fail because we don't have a real registry or image
    EXPECT_THROW(
        client.getImageTreeDigest("invalid.registry/invalid/image:invalid"),
        OciRegistryException);
}

// Basic test for getImageRootDigest error case - using a non-existent image
TEST_F(OciClientTest, GetImageRootDigestError)
{
    OciClient client(httpClient, casClient, assetClient);

    // This should fail because we don't have a real registry or image
    EXPECT_THROW(
        client.getImageRootDigest("invalid.registry/invalid/image:invalid"),
        OciRegistryException);
}
