BareGit
#include "scheduler.h"
#include "data_source_sqlite.h"
#include "fake_probe.h"

#include <atomic>
#include <future>
#include <stdexcept>

#include <gtest/gtest.h>

namespace
{

using namespace std::chrono_literals;

TEST(Scheduler, NextDueTimePreservesCadence)
{
    using Time = Scheduler::Clock::time_point;
    auto next = Scheduler::nextDueTime(Time{10s}, 5s, Time{10s});
    ASSERT_TRUE(next);
    EXPECT_EQ(*next, Time{15s});
    auto late = Scheduler::nextDueTime(Time{10s}, 5s, Time{27s});
    ASSERT_TRUE(late);
    EXPECT_EQ(*late, Time{30s});
    auto exact = Scheduler::nextDueTime(Time{10s}, 5s, Time{30s});
    ASSERT_TRUE(exact);
    EXPECT_EQ(*exact, Time{35s});
}

TEST(Scheduler, NextDueTimeRejectsInvalidSchedule)
{
    using Time = Scheduler::Clock::time_point;
    EXPECT_FALSE(Scheduler::nextDueTime(Time{}, 0s, Time{}));
    EXPECT_FALSE(Scheduler::nextDueTime(Time{}, -1s, Time{}));
    EXPECT_FALSE(Scheduler::nextDueTime(Time{-1s}, 1s, Time{}));
    EXPECT_FALSE(Scheduler::nextDueTime(Time{1s}, 1s, Time{}));
}

TEST(Scheduler, NextDueTimeDetectsOverflow)
{
    using Time = Scheduler::Clock::time_point;
    EXPECT_FALSE(Scheduler::nextDueTime(Time::max(), 1s, Time::max()));
}

// Keep SQLite persistence real while observing worker completion in tests.
class WatchingSource final : public DataSourceInterface
{
public:
    enum class SaveMode { SUCCESS, ERROR, EXCEPTION };

    const Scheduler* scheduler = nullptr;
    SaveMode mode = SaveMode::SUCCESS;
    std::atomic<bool> flight_during_save{true};

    mw::E<void> save(const StatusRecord& record) override
    {
        if(scheduler)
        {
            const auto* service = scheduler->findService(record.service_id);
            if(!service || !service->in_flight.load())
            {
                flight_during_save = false;
            }
        }
        if(mode == SaveMode::ERROR)
        {
            return std::unexpected(mw::runtimeError("Save failed"));
        }
        if(mode == SaveMode::EXCEPTION)
        {
            throw std::runtime_error("Unexpected save exception");
        }
        auto result = database->save(record);
        if(result)
        {
            {
                std::lock_guard lock(mutex);
                ++counts[record.service_id];
            }
            changed.notify_all();
        }
        return result;
    }

    mw::E<std::optional<StatusRecord>> latest(
        const std::string& service_id) override
    {
        return database->latest(service_id);
    }

    mw::E<std::vector<StatusRecord>> history(
        const std::string& service_id, std::int64_t start,
        std::int64_t end) override
    {
        return database->history(service_id, start, end);
    }

    bool waitForRecords(const std::string& service_id, std::size_t count,
                        std::chrono::milliseconds timeout = 1s)
    {
        std::unique_lock lock(mutex);
        const auto deadline = std::chrono::steady_clock::now() + timeout;
        while(counts[service_id] < count)
        {
            if(changed.wait_until(lock, deadline) == std::cv_status::timeout)
            {
                return counts[service_id] >= count;
            }
        }
        return true;
    }

private:
    std::unique_ptr<DataSourceSqlite> database =
        DataSourceSqlite::open(":memory:").value();
    std::mutex mutex;
    std::condition_variable changed;
    std::unordered_map<std::string, std::size_t> counts;
};

ServiceConfig serviceConfig(const std::string& id,
                            Scheduler::Clock::duration interval = 1h)
{
    return {id, "Display " + id, "Description", TcpEndpoint{"localhost", 80},
            1s, interval, "https://example.invalid"};
}

Configuration oneService()
{
    return {1, ":memory:", {{"Group", {serviceConfig("service")}}}};
}

mw::E<std::unique_ptr<ProbeInterface>> makeFake(
    [[maybe_unused]] const Service& service)
{
    return std::make_unique<FakeProbe>(
        ProbeResult{ProbeStatus::BAD, 123, 456}, 0ms);
}

// Cancel before the async run's future is destroyed after a failed assertion.
struct StopOnExit
{
    std::stop_source& source;

    ~StopOnExit()
    {
        source.request_stop();
    }
};

TEST(Scheduler, BuildsServicesAcrossGroupsAndPersistsResults)
{
    WatchingSource source;
    Configuration configuration{2, ":memory:", {
        {"First", {serviceConfig("first")}},
        {"Second", {serviceConfig("second")}}
    }};
    auto created = Scheduler::create(configuration, source, makeFake);
    ASSERT_TRUE(created) << created.error().msg();
    auto scheduler = std::move(*created);
    source.scheduler = scheduler.get();
    ASSERT_NE(scheduler->findService("first"), nullptr);
    ASSERT_NE(scheduler->findService("second"), nullptr);
    EXPECT_EQ(scheduler->findService("missing"), nullptr);
    EXPECT_EQ(scheduler->findService("first")->config.name, "Display first");
    configuration.groups.clear();
    EXPECT_EQ(scheduler->findService("first")->config.id, "first");

    std::stop_source stop;
    auto running = std::async(std::launch::async, &Scheduler::run,
                              scheduler.get(), stop.get_token());
    StopOnExit cleanup{stop};
    ASSERT_TRUE(source.waitForRecords("first", 1));
    ASSERT_TRUE(source.waitForRecords("second", 1));
    stop.request_stop();
    ASSERT_EQ(running.wait_for(1s), std::future_status::ready);
    EXPECT_TRUE(running.get());
    for(const auto& id : {"first", "second"})
    {
        auto record = source.latest(id);
        ASSERT_TRUE(record);
        ASSERT_TRUE(*record);
        EXPECT_EQ((**record), (StatusRecord{id, 123, 456, ProbeStatus::BAD}));
        EXPECT_FALSE(scheduler->findService(id)->in_flight.load());
    }
    EXPECT_TRUE(source.flight_during_save.load());
    EXPECT_FALSE(scheduler->run(stop.get_token()));
}

TEST(Scheduler, CancellationWakesEmptyQueue)
{
    WatchingSource source;
    auto scheduler = Scheduler::create(
        Configuration{1, ":memory:", {}}, source);
    ASSERT_TRUE(scheduler);
    std::stop_source stop;
    auto running = std::async(std::launch::async, &Scheduler::run,
                              scheduler->get(), stop.get_token());
    StopOnExit cleanup{stop};
    EXPECT_EQ(running.wait_for(20ms), std::future_status::timeout);
    stop.request_stop();
    ASSERT_EQ(running.wait_for(1s), std::future_status::ready);
    EXPECT_TRUE(running.get());
}

mw::E<std::unique_ptr<ProbeInterface>> factoryError(
    [[maybe_unused]] const Service& service)
{
    return std::unexpected(mw::runtimeError("Factory failed"));
}

TEST(Scheduler, CancellationBeforeStart)
{
    WatchingSource source;
    auto scheduler = Scheduler::create(oneService(), source, factoryError);
    ASSERT_TRUE(scheduler);
    std::stop_source stop;
    stop.request_stop();
    EXPECT_TRUE((*scheduler)->run(stop.get_token()));
    EXPECT_FALSE((*scheduler)->findService("service")->in_flight.load());
    EXPECT_FALSE(source.latest("service").value());
}

TEST(Scheduler, CancellationWakesFutureDueTime)
{
    WatchingSource source;
    auto scheduler = Scheduler::create(oneService(), source, makeFake);
    ASSERT_TRUE(scheduler);
    std::stop_source stop;
    auto running = std::async(std::launch::async, &Scheduler::run,
                              scheduler->get(), stop.get_token());
    StopOnExit cleanup{stop};
    ASSERT_TRUE(source.waitForRecords("service", 1));
    EXPECT_EQ(running.wait_for(20ms), std::future_status::timeout);
    stop.request_stop();
    ASSERT_EQ(running.wait_for(1s), std::future_status::ready);
    EXPECT_TRUE(running.get());
}

class FailureProbe final : public ProbeInterface
{
public:
    explicit FailureProbe(bool throw_exception)
        : throw_exception(throw_exception)
    {}

    mw::E<ProbeResult> probe() override
    {
        if(throw_exception)
        {
            throw std::runtime_error("Unexpected probe exception");
        }
        return std::unexpected(mw::runtimeError("Probe failed"));
    }

private:
    bool throw_exception;
};

mw::E<std::unique_ptr<ProbeInterface>> failedProbe(
    [[maybe_unused]] const Service& service)
{
    return std::make_unique<FailureProbe>(false);
}

mw::E<std::unique_ptr<ProbeInterface>> throwingProbe(
    [[maybe_unused]] const Service& service)
{
    return std::make_unique<FailureProbe>(true);
}

mw::E<std::unique_ptr<ProbeInterface>> nullProbe(
    [[maybe_unused]] const Service& service)
{
    return std::unique_ptr<ProbeInterface>{};
}

mw::E<std::unique_ptr<ProbeInterface>> throwingFactory(
    [[maybe_unused]] const Service& service)
{
    throw std::runtime_error("Unexpected factory exception");
}

TEST(Scheduler, FailuresClearServiceFlightState)
{
    for(auto factory : {factoryError, failedProbe, nullProbe})
    {
        WatchingSource source;
        auto scheduler = Scheduler::create(oneService(), source, factory);
        ASSERT_TRUE(scheduler);
        std::stop_source stop;
        auto running = std::async(std::launch::async, &Scheduler::run,
                                  scheduler->get(), stop.get_token());
        StopOnExit cleanup{stop};
        ASSERT_EQ(running.wait_for(1s), std::future_status::ready);
        auto result = running.get();
        ASSERT_FALSE(result);
        EXPECT_NE(result.error().msg().find("service:"), std::string::npos);
        EXPECT_FALSE((*scheduler)->findService("service")->in_flight.load());
        EXPECT_FALSE(source.latest("service").value());
    }
}

TEST(Scheduler, PersistenceFailuresClearServiceFlightState)
{
    WatchingSource source;
    source.mode = WatchingSource::SaveMode::ERROR;
    auto scheduler = Scheduler::create(oneService(), source, makeFake);
    ASSERT_TRUE(scheduler);
    source.scheduler = scheduler->get();
    std::stop_source stop;
    auto running = std::async(std::launch::async, &Scheduler::run,
                              scheduler->get(), stop.get_token());
    StopOnExit cleanup{stop};
    ASSERT_EQ(running.wait_for(1s), std::future_status::ready);
    auto result = running.get();
    ASSERT_FALSE(result);
    EXPECT_NE(result.error().msg().find("service:"), std::string::npos);
    EXPECT_FALSE((*scheduler)->findService("service")->in_flight.load());
    EXPECT_TRUE(source.flight_during_save.load());
}

class BlockingProbe final : public ProbeInterface
{
public:
    BlockingProbe(std::shared_future<void> release, std::promise<void>& started)
        : release(std::move(release)), started(started)
    {}

    mw::E<ProbeResult> probe() override
    {
        started.set_value();
        release.wait();
        return ProbeResult{ProbeStatus::GOOD, 123, 456};
    }

private:
    std::shared_future<void> release;
    std::promise<void>& started;
};

struct BlockingFactory
{
    std::shared_future<void> release;
    std::promise<void>& started;
    std::atomic<int>& calls;

    mw::E<std::unique_ptr<ProbeInterface>> operator()(
        const Service& service) const
    {
        if(service.config.id == "a_slow")
        {
            ++calls;
            return std::make_unique<BlockingProbe>(release, started);
        }
        return makeFake(service);
    }
};

TEST(Scheduler, SkipsInFlightAndFinishesAcceptedWorkOnStop)
{
    WatchingSource source;
    std::atomic<int> calls{0};
    std::promise<void> started;
    auto ready = started.get_future();
    std::stop_source stop;
    auto release = std::make_unique<std::promise<void>>();
    Configuration configuration{2, ":memory:", {{"Group", {
        serviceConfig("a_slow", 1ms), serviceConfig("ticker", 1ms)
    }}}};
    auto scheduler = Scheduler::create(configuration, source, BlockingFactory{
        release->get_future().share(), started, calls});
    ASSERT_TRUE(scheduler);
    source.scheduler = scheduler->get();
    auto running = std::async(std::launch::async, &Scheduler::run,
                              scheduler->get(), stop.get_token());
    // Unblock the worker before destroying running on assertion failure.
    auto release_on_exit = std::move(release);
    StopOnExit cleanup{stop};
    ASSERT_EQ(ready.wait_for(1s), std::future_status::ready);
    ASSERT_TRUE(source.waitForRecords("ticker", 4));
    EXPECT_EQ(calls.load(), 1);
    EXPECT_TRUE((*scheduler)->findService("a_slow")->in_flight.load());
    stop.request_stop();
    EXPECT_EQ(running.wait_for(20ms), std::future_status::timeout);
    release_on_exit->set_value();
    ASSERT_EQ(running.wait_for(1s), std::future_status::ready);
    EXPECT_TRUE(running.get());
    EXPECT_FALSE((*scheduler)->findService("a_slow")->in_flight.load());
    EXPECT_FALSE((*scheduler)->findService("ticker")->in_flight.load());
    EXPECT_TRUE(source.flight_during_save.load());
    EXPECT_TRUE(source.latest("a_slow").value());
}

TEST(Scheduler, SkipsWhenFullAndRetriesAtNextInterval)
{
    WatchingSource source;
    std::atomic<int> calls{0};
    std::promise<void> started;
    auto ready = started.get_future();
    std::stop_source stop;
    auto release = std::make_unique<std::promise<void>>();
    Configuration configuration{1, ":memory:", {{"Group", {
        serviceConfig("a_slow"), serviceConfig("retry", 1ms)
    }}}};
    auto scheduler = Scheduler::create(configuration, source, BlockingFactory{
        release->get_future().share(), started, calls});
    ASSERT_TRUE(scheduler);
    auto running = std::async(std::launch::async, &Scheduler::run,
                              scheduler->get(), stop.get_token());
    auto release_on_exit = std::move(release);
    StopOnExit cleanup{stop};
    ASSERT_EQ(ready.wait_for(1s), std::future_status::ready);
    EXPECT_FALSE(source.waitForRecords("retry", 1, 20ms));
    release_on_exit->set_value();
    ASSERT_TRUE(source.waitForRecords("retry", 1));
    stop.request_stop();
    ASSERT_EQ(running.wait_for(1s), std::future_status::ready);
    EXPECT_TRUE(running.get());
    EXPECT_FALSE((*scheduler)->findService("retry")->in_flight.load());
    EXPECT_EQ(calls.load(), 1);
}

TEST(Scheduler, InvalidConfiguration)
{
    WatchingSource source;
    auto configuration = oneService();
    configuration.worker_count = 0;
    EXPECT_FALSE(Scheduler::create(configuration, source));
    configuration = oneService();
    EXPECT_FALSE(Scheduler::create(configuration, source, {}));
    configuration.groups.front().services.front().id.clear();
    EXPECT_FALSE(Scheduler::create(configuration, source));
    configuration = oneService();
    configuration.groups.front().services.front().interval = 0s;
    EXPECT_FALSE(Scheduler::create(configuration, source));
    configuration.groups.front().services.front().interval = -1s;
    EXPECT_FALSE(Scheduler::create(configuration, source));
    configuration.groups.front().services.front().interval =
        Scheduler::Clock::duration::max();
    EXPECT_FALSE(Scheduler::create(configuration, source));
    configuration = oneService();
    configuration.groups.front().services.front().timeout = 0s;
    EXPECT_FALSE(Scheduler::create(configuration, source));
    configuration = oneService();
    configuration.groups.front().services.front().endpoint =
        TcpEndpoint{"localhost", 0};
    EXPECT_FALSE(Scheduler::create(configuration, source));
    configuration = oneService();
    configuration.groups.push_back(
        {"Another group", {serviceConfig("service")}});
    auto duplicate = Scheduler::create(configuration, source);
    ASSERT_FALSE(duplicate);
    EXPECT_NE(duplicate.error().msg().find("Duplicate service ID"),
              std::string::npos);
}

}

TEST(SchedulerDeathTest, UnexpectedProbeExceptionsTerminate)
{
    for(auto factory : {throwingProbe, throwingFactory})
    {
        ASSERT_DEATH({
            WatchingSource source;
            auto scheduler = Scheduler::create(oneService(), source, factory);
            (*scheduler)->run(std::stop_token{});
        }, "");
    }
}

TEST(SchedulerDeathTest, UnexpectedSaveExceptionTerminates)
{
    ASSERT_DEATH({
        WatchingSource source;
        source.mode = WatchingSource::SaveMode::EXCEPTION;
        auto scheduler = Scheduler::create(oneService(), source, makeFake);
        source.scheduler = scheduler->get();
        (*scheduler)->run(std::stop_token{});
    }, "");
}