/*
 * Copyright 2019 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_fslocalcas.h>
#include <buildboxcommon_lrulocalcas.h>

#include <buildboxcommon_digestgenerator.h>
#include <buildboxcommon_metricnames.h>
#include <buildboxcommon_temporarydirectory.h>

#include <buildboxcommonmetrics_countingmetricvalue.h>
#include <buildboxcommonmetrics_durationmetricvalue.h>
#include <buildboxcommonmetrics_gaugemetricvalue.h>
#include <buildboxcommonmetrics_testingutils.h>

#include <condition_variable>
#include <fstream>
#include <mutex>
#include <thread>
#include <unistd.h>

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

namespace {
void writeToFile(const std::string &data, const std::string &path)
{
    std::ofstream file(path, std::ofstream::binary);
    file << data;
    file.close();
    ASSERT_TRUE(buildboxcommon::FileUtils::isRegularFile(path.c_str()));
}

const auto digestFunctionInitializer = []() {
    buildboxcommon::DigestGenerator::init();
    return 0;
}();
} // namespace

using namespace buildboxcommon;
using namespace buildboxcommon::buildboxcommonmetrics;
using buildboxcommon::DigestGenerator;

static const int LRU_QUOTA_HIGH = 4096;
static const double LRU_QUOTA_LOW_RATIO = 0.5;

class LruLocalCasFixture : public ::testing::Test {
  protected:
    LruLocalCasFixture()
        : cas(std::make_unique<FsLocalCas>(cas_root_directory.name()),
              LRU_QUOTA_LOW_RATIO, LRU_QUOTA_HIGH, 0)
    {
    }

    buildboxcommon::TemporaryDirectory cas_root_directory;
    LruLocalCas cas;

    typedef std::vector<std::pair<std::string, GaugeMetricValue>>
        MetricValPairs;
    MetricValPairs getLruMetricPairs()
    {
        return {{CommonMetricNames::GAUGE_NAME_DISK_USAGE,
                 GaugeMetricValue(cas.getDiskUsage())},
                {CommonMetricNames::GAUGE_NAME_LRU_EFFECTIVE_HIGH_WATERMARK,
                 GaugeMetricValue(cas.getDiskQuota().value_or(0))},
                {CommonMetricNames::GAUGE_NAME_LRU_EFFECTIVE_LOW_WATERMARK,
                 GaugeMetricValue(
                     static_cast<GaugeMetricValue::GaugeMetricNumericType>(
                         LRU_QUOTA_HIGH * LRU_QUOTA_LOW_RATIO))},
                {CommonMetricNames::GAUGE_NAME_LRU_CONFIGURED_HIGH_WATERMARK,
                 GaugeMetricValue(LRU_QUOTA_HIGH)},
                {CommonMetricNames::GAUGE_NAME_LRU_LOW_WATERMARK_PERCENTAGE,
                 GaugeMetricValue(
                     static_cast<GaugeMetricValue::GaugeMetricNumericType>(
                         LRU_QUOTA_LOW_RATIO * 100))}};
    }
};

TEST_F(LruLocalCasFixture, InitialDiskUsagePublished)
{
    ASSERT_TRUE(
        allCollectedByNameWithValues<GaugeMetricValue>(getLruMetricPairs()));
}

// Test identical to FsLocalCas test
TEST_F(LruLocalCasFixture, TestWriteAndRead)
{
    const std::string data = "someData";
    const auto digest = DigestGenerator::hash(data);

    ASSERT_FALSE(cas.hasBlob(digest));

    const auto previous_disk_usage = cas.getDiskUsage();
    cas.writeBlob(digest, data);

    // Data was written and is valid:
    ASSERT_TRUE(cas.hasBlob(digest));
    ASSERT_EQ(*cas.readBlob(digest), data);
    ASSERT_EQ(*cas.readBlob(digest, 0, static_cast<size_t>(data.size())),
              data);

    const auto current_disk_usage = cas.getDiskUsage();
    ASSERT_EQ(current_disk_usage, previous_disk_usage + digest.size_bytes());

    // Metric issued:
    ASSERT_TRUE(
        allCollectedByNameWithValues<GaugeMetricValue>(getLruMetricPairs()));
}

TEST_F(LruLocalCasFixture, GetStoragePath)
{
    const auto cas_objects_path =
        std::string(cas_root_directory.name()) + "/cas";
    ASSERT_EQ(cas.path(), cas_objects_path);
};

TEST_F(LruLocalCasFixture, GetPath)
{
    const auto data = "data";
    const auto digest = DigestGenerator::hash(data);
    cas.writeBlob(digest, data);

    ASSERT_EQ(cas.path(digest), cas.getBase()->path(digest));
}

TEST_F(LruLocalCasFixture, ExternalFileMovesDisabledByDefault)
{
    ASSERT_FALSE(cas.externalFileMovesAllowed());
}

TEST_F(LruLocalCasFixture, TestDelete)
{
    const std::string data = "someData";
    const auto digest = DigestGenerator::hash(data);

    ASSERT_FALSE(cas.hasBlob(digest));

    // Write blob
    cas.writeBlob(digest, data);
    ASSERT_TRUE(cas.hasBlob(digest));

    // Delete blob and verify it has been deleted
    ASSERT_TRUE(cas.deleteBlob(digest));
    ASSERT_FALSE(cas.hasBlob(digest));

    // Try deleting it again
    ASSERT_FALSE(cas.deleteBlob(digest));

    // Metric issued:
    ASSERT_TRUE(
        allCollectedByNameWithValues<GaugeMetricValue>(getLruMetricPairs()));
}

TEST_F(LruLocalCasFixture, EmptyBlobIsNotDeleted)
{
    const Digest empty_blob_digest = DigestGenerator::hash("");

    ASSERT_TRUE(cas.hasBlob(empty_blob_digest));

    ASSERT_FALSE(cas.deleteBlob(empty_blob_digest));

    ASSERT_TRUE(cas.hasBlob(empty_blob_digest));
}

TEST_F(LruLocalCasFixture, Expire)
{
    auto digests = std::vector<Digest>(6);

    for (size_t i = 0; i < digests.size(); i++) {
        const auto data = std::string(1000, static_cast<char>('0' + i));
        digests[i] = DigestGenerator::hash(data);
        cas.writeBlob(digests[i], data);

        // Wait 1 second between writes to get different timestamps.
        sleep(1);
    }

    for (size_t i = 0; i < digests.size(); i++) {
        // Writing the fifth 1000 Byte blob should
        // expire the first three 1000 Byte blobs.
        if (i < 3) {
            ASSERT_FALSE(cas.hasBlob(digests[i]));
        }
        else {
            ASSERT_TRUE(cas.hasBlob(digests[i]));
        }
    }

    // Metrics issued:
    ASSERT_TRUE(collectedByName<DurationMetricValue>(
        CommonMetricNames::TIMER_NAME_LRU_CLEANUP));

    ASSERT_TRUE(
        allCollectedByNameWithValues<GaugeMetricValue>(getLruMetricPairs()));
}

TEST_F(LruLocalCasFixture, DiskUsage)
{
    cas.verifyDiskUsage();

    for (int i = 0; i < 10; i++) {
        const auto data = std::string(1000, static_cast<char>('0' + i));
        const auto digest = DigestGenerator::hash(data);
        cas.writeBlob(digest, data);
        cas.verifyDiskUsage();
    }
}

TEST_F(LruLocalCasFixture, DiskUsageDuplicates)
{
    cas.verifyDiskUsage();

    for (int i = 0; i < 20; i++) {
        const auto data = std::string(1000, static_cast<char>('0' + i / 2));
        const auto digest = DigestGenerator::hash(data);
        cas.writeBlob(digest, data);
        cas.verifyDiskUsage();
    }
}

TEST_F(LruLocalCasFixture, MoveBlobFromTmpFileUpdatesDiskUsage)
{
    // Creating a temporary file inside the CAS temp directory and writing some
    // data to it:
    auto temp_file = cas.createTemporaryFile();
    const std::string data = "Hello. This is some data.";
    const Digest digest = DigestGenerator::hash(data);
    writeToFile(data, temp_file.name());

    ASSERT_FALSE(cas.hasBlob(digest));
    // We'll now move that file to the CAS.
    // (Since the file isn't there, the move is performed and
    // `moveBlobFromTemporaryFile()` returns `true`.)
    const auto disk_usage_before_move = cas.getDiskUsage();
    ASSERT_TRUE(cas.moveBlobFromTemporaryFile(digest, std::move(temp_file)));

    // The data is correct:
    ASSERT_TRUE(cas.hasBlob(digest));
    ASSERT_EQ(*cas.readBlob(digest), data);

    // And the disk usage increased accordingly:
    ASSERT_EQ(cas.getDiskUsage(),
              disk_usage_before_move + static_cast<int64_t>(data.size()));

    // Metric issued:
    ASSERT_TRUE(
        allCollectedByNameWithValues<GaugeMetricValue>(getLruMetricPairs()));
}

TEST_F(LruLocalCasFixture, MoveExistingBlobFromTmpFileDoesNotChangeDiskUsage)
{
    // Metric issued:
    ASSERT_TRUE(
        collectedByNameWithValue(CommonMetricNames::GAUGE_NAME_DISK_USAGE,
                                 GaugeMetricValue(cas.getDiskUsage())));

    // Creating a temporary file inside the CAS temp directory and writing some
    // data to it:
    auto temp_file = cas.createTemporaryFile();
    const std::string data = "Hello. This is some data.";
    const Digest digest = DigestGenerator::hash(data);
    writeToFile(data, temp_file.name());

    // We want the blob to be present in the CAS before the move:
    cas.writeBlob(digest, data);
    ASSERT_TRUE(cas.hasBlob(digest));

    const auto disk_usage_before_move = cas.getDiskUsage();

    // We'll now move that file to the CAS.
    // (Since the file is already stored, `moveBlobFromTemporaryFile()` returns
    // `false`.)
    ASSERT_FALSE(cas.moveBlobFromTemporaryFile(digest, std::move(temp_file)));

    // The disk usage did not change:
    ASSERT_EQ(cas.getDiskUsage(), disk_usage_before_move);
}

TEST(LruLocalCasEnablingMovesTest, MoveBlobFromExternalFileUpdatesDiskUsage)
{
    buildboxcommon::TemporaryDirectory cas_root;
    auto base_cas = std::make_unique<FsLocalCas>(cas_root.name(), true);
    LruLocalCas cas(std::move(base_cas), LRU_QUOTA_LOW_RATIO, LRU_QUOTA_HIGH,
                    0);

    ASSERT_TRUE(cas.externalFileMovesAllowed());

    // Creating a temporary file inside the CAS temp directory and writing some
    // data to it:
    buildboxcommon::TemporaryFile temp_file;
    const std::string data = "Hello. This is some data.";
    const Digest digest = DigestGenerator::hash(data);
    writeToFile(data, temp_file.name());

    ASSERT_FALSE(cas.hasBlob(digest));
    // We'll now move that file to the CAS.
    // (Since the file isn't there, the move is performed and
    // `moveBlobFromExternalFile()` returns `true`.)
    const auto disk_usage_before_move = cas.getDiskUsage();
    ASSERT_TRUE(
        cas.moveBlobFromExternalFile(digest, AT_FDCWD, temp_file.name()));

    // The data is correct:
    ASSERT_TRUE(cas.hasBlob(digest));
    ASSERT_EQ(*cas.readBlob(digest), data);

    // And the disk usage increased accordingly:
    ASSERT_EQ(cas.getDiskUsage(),
              disk_usage_before_move + static_cast<int64_t>(data.size()));
}

// LocalCas wrapper that blocks in listBlobs() until signaled by the test.
// All other methods delegate to a real FsLocalCas.
class SlowListLocalCas final : public LocalCas {
  public:
    SlowListLocalCas(const std::string &root, std::mutex &mu,
                     std::condition_variable &startedCv, bool &started,
                     std::condition_variable &proceedCv, bool &proceed)
        : d_base(std::make_unique<FsLocalCas>(root)), d_mu(mu),
          d_startedCv(startedCv), d_started(started), d_proceedCv(proceedCv),
          d_proceed(proceed)
    {
    }

    void listBlobs(digest_time_callback_type cb) override
    {
        {
            std::lock_guard<std::mutex> lock(d_mu);
            d_started = true;
        }
        d_startedCv.notify_all();
        {
            std::unique_lock<std::mutex> lock(d_mu);
            d_proceedCv.wait(lock, [this] { return d_proceed; });
        }
        d_base->listBlobs(cb);
    }

    bool hasBlob(const Digest &d) override { return d_base->hasBlob(d); }
    bool writeBlob(const Digest &d, const std::string &data) override
    {
        return d_base->writeBlob(d, data);
    }
    bool writeBlob(int fd, Digest *d) override
    {
        return d_base->writeBlob(fd, d);
    }
    bool moveBlobFromTemporaryFile(const Digest &d, TemporaryFile f) override
    {
        return d_base->moveBlobFromTemporaryFile(d, std::move(f));
    }
    bool moveBlobFromTemporaryFile(const Digest &d,
                                   const TemporaryDirectory &dir,
                                   const std::string &name) override
    {
        return d_base->moveBlobFromTemporaryFile(d, dir, name);
    }
    std::unique_ptr<std::string> readBlob(const Digest &d) override
    {
        return d_base->readBlob(d);
    }
    std::unique_ptr<std::string> readBlob(const Digest &d, int64_t off,
                                          size_t len) override
    {
        return d_base->readBlob(d, off, len);
    }
    bool copyBlob(const Digest &d, int dirfd, const char *path,
                  mode_t mode = 0, struct stat *destStat = nullptr) override
    {
        return d_base->copyBlob(d, dirfd, path, mode, destStat);
    }
    bool deleteBlob(const Digest &d) override { return d_base->deleteBlob(d); }
    bool deleteBlob(const Digest &d, const blob_time_type &t) override
    {
        return d_base->deleteBlob(d, t);
    }
    int64_t getDiskUsage(bool force) override
    {
        return d_base->getDiskUsage(force);
    }
    std::optional<int64_t> getDiskQuota() override
    {
        return d_base->getDiskQuota();
    }
    std::string path(const Digest &d) override { return d_base->path(d); }
    std::string pathUnchecked(const Digest &d) override
    {
        return d_base->pathUnchecked(d);
    }
    FdNamePair pathAt(const Digest &d) override { return d_base->pathAt(d); }
    FdNamePair pathAtUnchecked(const Digest &d) override
    {
        return d_base->pathAtUnchecked(d);
    }
    const std::string &path() override { return d_base->path(); }
    TemporaryDirectory createStagingDirectory() override
    {
        return d_base->createStagingDirectory();
    }
    TemporaryFile createTemporaryFile() override
    {
        return d_base->createTemporaryFile();
    }
    TemporaryDirectory createTemporaryDirectory() override
    {
        return d_base->createTemporaryDirectory();
    }
    int64_t getAvailableDiskSpace() override
    {
        return d_base->getAvailableDiskSpace();
    }
    bool externalFileMovesAllowed() const override
    {
        return d_base->externalFileMovesAllowed();
    }
    bool objectFilesWritable() const override
    {
        return d_base->objectFilesWritable();
    }
    bool moveBlobFromExternalFile(const Digest &d, int dirfd,
                                  const std::string &p) override
    {
        return d_base->moveBlobFromExternalFile(d, dirfd, p);
    }

  private:
    std::unique_ptr<FsLocalCas> d_base;
    std::mutex &d_mu;
    std::condition_variable &d_startedCv;
    bool &d_started;
    std::condition_variable &d_proceedCv;
    bool &d_proceed;
};

class LruLocalCasFailFastFixture : public ::testing::Test {
  protected:
    LruLocalCasFailFastFixture() {}

    void signalProceed()
    {
        {
            std::lock_guard<std::mutex> lock(d_mu);
            d_proceed = true;
        }
        d_proceedCv.notify_all();
    }

    void waitForCleanupStarted()
    {
        std::unique_lock<std::mutex> lock(d_mu);
        d_startedCv.wait(lock, [this] { return d_started; });
    }

    std::unique_ptr<LruLocalCas> makeCas(bool failFast,
                                         int64_t quotaHigh = LRU_QUOTA_HIGH)
    {
        auto base = std::make_unique<SlowListLocalCas>(d_casRoot.name(), d_mu,
                                                       d_startedCv, d_started,
                                                       d_proceedCv, d_proceed);
        return std::make_unique<LruLocalCas>(
            std::move(base), LRU_QUOTA_LOW_RATIO, quotaHigh, 0, failFast);
    }

    void fillNearQuota(LruLocalCas &cas)
    {
        for (int i = 0; i < 4; i++) {
            const auto data = std::string(1000, static_cast<char>('0' + i));
            const auto digest = DigestGenerator::hash(data);
            cas.writeBlob(digest, data);
        }
    }

    TemporaryDirectory d_casRoot;
    std::mutex d_mu;
    std::condition_variable d_startedCv;
    bool d_started = false;
    std::condition_variable d_proceedCv;
    bool d_proceed = false;
};

TEST_F(LruLocalCasFailFastFixture,
       FailFastDuringCleanup_RejectsWriteImmediately)
{
    auto cas = makeCas(true);
    fillNearQuota(*cas);

    // Thread A: write a blob that exceeds quota, triggering cleanup
    const auto triggerData = std::string(1000, 'A');
    const auto triggerDigest = DigestGenerator::hash(triggerData);

    std::thread cleanupThread(
        [&] { cas->writeBlob(triggerDigest, triggerData); });

    waitForCleanupStarted();

    // Main thread: attempt a write while cleanup is in progress
    const auto rejectData = std::string(100, 'B');
    const auto rejectDigest = DigestGenerator::hash(rejectData);
    ASSERT_THROW(cas->writeBlob(rejectDigest, rejectData),
                 OutOfSpaceException);

    signalProceed();
    cleanupThread.join();
}

TEST_F(LruLocalCasFailFastFixture, FailFastDisabled_WriteBlocksDuringCleanup)
{
    auto cas = makeCas(false);
    fillNearQuota(*cas);

    const auto triggerData = std::string(1000, 'A');
    const auto triggerDigest = DigestGenerator::hash(triggerData);

    std::thread cleanupThread(
        [&] { cas->writeBlob(triggerDigest, triggerData); });

    waitForCleanupStarted();

    // Thread B: write while cleanup is in progress — should block, not throw
    const auto writeData = std::string(100, 'C');
    const auto writeDigest = DigestGenerator::hash(writeData);
    bool writeSucceeded = false;

    std::thread writeThread([&] {
        cas->writeBlob(writeDigest, writeData);
        writeSucceeded = true;
    });

    signalProceed();
    cleanupThread.join();
    writeThread.join();

    ASSERT_TRUE(writeSucceeded);
    ASSERT_TRUE(cas->hasBlob(writeDigest));
}

TEST_F(LruLocalCasFailFastFixture,
       FailFastDuringCleanup_WriteSucceedsWhenBelowQuota)
{
    // Large quota — writes won't trigger cleanup
    auto cas = makeCas(true, 1000000);

    for (int i = 0; i < 5; i++) {
        const auto data = std::string(100, static_cast<char>('0' + i));
        const auto digest = DigestGenerator::hash(data);
        ASSERT_NO_THROW(cas->writeBlob(digest, data));
    }

    // Need to signal proceed so the destructor cleanup (if triggered by
    // the disk monitor thread) can complete
    signalProceed();
}

TEST_F(LruLocalCasFailFastFixture,
       FailFastDuringCleanup_WriteSucceedsAfterCleanupCompletes)
{
    auto cas = makeCas(true);
    fillNearQuota(*cas);

    const auto triggerData = std::string(1000, 'A');
    const auto triggerDigest = DigestGenerator::hash(triggerData);

    std::thread cleanupThread(
        [&] { cas->writeBlob(triggerDigest, triggerData); });

    waitForCleanupStarted();

    signalProceed();
    cleanupThread.join();

    // After cleanup completes, writes should succeed
    const auto data = std::string(100, 'D');
    const auto digest = DigestGenerator::hash(data);
    ASSERT_NO_THROW(cas->writeBlob(digest, data));
    ASSERT_TRUE(cas->hasBlob(digest));
}
