BareGit
#include "game_manager.hpp"

#include "identity.hpp"

#include <algorithm>
#include <chrono>
#include <cstdio>
#include <filesystem>
#include <string>
#include <stdexcept>
#include <unistd.h>
#include <utility>

namespace nethack_mcp
{

namespace
{

std::int64_t wallClockSeconds()
{
    return std::chrono::duration_cast<std::chrono::seconds>(
        std::chrono::system_clock::now().time_since_epoch()).count();
}

bool validPrintableAscii(const std::string& value)
{
    return std::all_of(value.begin(), value.end(), [](unsigned char byte) {
        return byte >= 0x20 && byte <= 0x7e;
    });
}

ToolResult managerError(std::string code, std::string message,
                        Json value = Json::object())
{
    return {false, std::move(value), std::move(code), std::move(message)};
}

void preparePrivateDirectory(const std::filesystem::path& path)
{
    std::error_code error;
    const bool already_exists = std::filesystem::exists(path, error);
    if(error)
    {
        throw std::filesystem::filesystem_error(
            "could not inspect private directory", path, error);
    }
    if(!already_exists)
    {
        std::filesystem::create_directories(path);
        std::filesystem::permissions(
            path, std::filesystem::perms::owner_all,
            std::filesystem::perm_options::replace);
    }

    const auto status = std::filesystem::symlink_status(path);
    if(std::filesystem::is_symlink(status)
       || !std::filesystem::is_directory(status))
    {
        throw std::runtime_error("private data root must be a real directory");
    }
    constexpr auto SHARED_ACCESS = std::filesystem::perms::group_all
        | std::filesystem::perms::others_all;
    if((status.permissions() & SHARED_ACCESS) != std::filesystem::perms::none
       || ::access(path.c_str(), W_OK | X_OK) != 0)
    {
        throw std::runtime_error(
            "private data root must be writable and accessible only to its owner");
    }
}

} // namespace

mw::E<std::unique_ptr<GameManager>> GameManager::create(
    ServerConfig config, std::filesystem::path runtime_source)
{
    auto opened = GameRecordStore::open(config.database_path);
    if(!opened)
    {
        return std::unexpected(opened.error());
    }
    auto records = std::shared_ptr<GameRecordStore>(std::move(*opened));
    auto recovered = records->recoverInterruptedGames(wallClockSeconds());
    if(!recovered)
    {
        return std::unexpected(recovered.error());
    }
    try
    {
        auto manager = std::unique_ptr<GameManager>(new GameManager(
            std::move(config), std::move(runtime_source), std::move(records)));
        manager->removeOrphanedDirectories();
        manager->sweep_thread_ = std::thread(&GameManager::sweepLoop,
                                             manager.get());
        return manager;
    }
    catch(const std::exception& exception)
    {
        return std::unexpected(mw::runtimeError(exception.what()));
    }
}

GameManager::GameManager(ServerConfig config,
                         std::filesystem::path runtime_source,
                         std::shared_ptr<GameRecordStore> records)
        : config_(std::move(config)), runtime_source_(std::move(runtime_source)),
          records_(std::move(records))
{
    preparePrivateDirectory(config_.data_root);
}

GameManager::~GameManager()
{
    shutdown();
}

ToolResult GameManager::createGame(const Json& arguments,
                                   const std::string& client_id)
{
    if(!arguments.contains("model_slug")
       || !arguments.at("model_slug").is_string())
    {
        return managerError(
            "INVALID_RESPONSE", "model_slug is required and must be a string");
    }
    const std::string model_slug =
        arguments.at("model_slug").get<std::string>();
    if(model_slug.empty() || model_slug.size() > config_.max_model_slug_bytes
       || !validPrintableAscii(model_slug))
    {
        return managerError(
            "INVALID_RESPONSE",
            "model_slug must be 1 to 128 printable ASCII bytes");
    }

    if(arguments.contains("name") && !arguments.at("name").is_string())
    {
        return managerError("INVALID_RESPONSE", "name must be a string");
    }
    const std::string character_name = arguments.value(
        "name", std::string("Agent"));
    if(character_name.empty()
       || character_name.size() > config_.max_character_name_bytes
       || !validPrintableAscii(character_name))
    {
        return managerError(
            "INVALID_RESPONSE",
            "name must be printable ASCII and within the configured limit");
    }

    const auto now = std::chrono::steady_clock::now();
    std::chrono::steady_clock::time_point accepted_at;
    std::int64_t accepted_at_s = 0;
    {
        std::lock_guard registry_lock(registry_mutex_);
        if(sessions_.size() + pending_creations_ >= config_.max_active_games)
        {
            return managerError(
                "CAPACITY_REACHED", "active game capacity has been reached",
                {{"retry_after_seconds",
                  config_.lifecycle_sweep_interval.count()}});
        }
        if(!allowCreation(client_id, now))
        {
            return managerError(
                "RATE_LIMITED", "new game creation rate limit reached",
                {{"retry_after_seconds", config_.new_game_rate_window.count()}});
        }
        accepted_at = std::chrono::steady_clock::now();
        accepted_at_s = wallClockSeconds();
        ++pending_creations_;
    }

    std::string game_id;
    std::string control_token;
    bool record_inserted = false;
    std::string error;
    for(int attempt = 0; attempt < 8; ++attempt)
    {
        game_id = makeGameId();
        control_token = makeControlToken();
        if(game_id.empty() || control_token.empty())
        {
            error = "operating system random source failed";
            break;
        }
        {
            std::lock_guard registry_lock(registry_mutex_);
            if(sessions_.contains(game_id) || reserved_ids_.contains(game_id))
            {
                game_id.clear();
                continue;
            }
            reserved_ids_.insert(game_id);
        }

        GameRecord record;
        record.game_id = game_id;
        record.character_name = character_name;
        record.model_slug = model_slug;
        record.started_at_s = accepted_at_s;
        record.last_activity_at_s = accepted_at_s;
        auto inserted = records_->insertGame(record);
        if(!inserted)
        {
            error = mw::errorMsg(inserted.error());
            break;
        }
        if(*inserted)
        {
            record_inserted = true;
            break;
        }
        {
            std::lock_guard registry_lock(registry_mutex_);
            reserved_ids_.erase(game_id);
        }
    }

    if(!record_inserted)
    {
        releaseReservation(game_id);
        if(error.empty())
        {
            error = "could not allocate a unique game identifier";
        }
        return managerError("RECORD_FAILURE", std::move(error));
    }

    const std::string viewer_url = config_.public_base_url + "g/" + game_id;
    std::shared_ptr<GameSession> session;
    try
    {
        session = std::make_shared<GameSession>(
            config_.data_root, runtime_source_, game_id,
            control_token, character_name, model_slug, accepted_at, records_,
            config_);
    }
    catch(const std::exception& exception)
    {
        auto finished = records_->finishGame(
            game_id, wallClockSeconds(), "observed", "failed");
        [[maybe_unused]] const bool ignored = finished.has_value();
        releaseReservation(game_id);
        return managerError("ENGINE_FAILURE", exception.what());
    }

    ToolResult started;
    try
    {
        started = session->startGame(arguments);
    }
    catch(const std::exception& exception)
    {
        auto finished = records_->finishGame(
            game_id, wallClockSeconds(), "observed", "failed");
        if(!finished)
        {
            std::fprintf(stderr, "could not record failed game %s: %s\n",
                         game_id.c_str(), mw::errorMsg(finished.error()).c_str());
        }
        session->cleanup();
        releaseReservation(game_id);
        return managerError("ENGINE_FAILURE", exception.what());
    }
    if(!started.success)
    {
        auto finished = records_->finishGame(
            game_id, wallClockSeconds(), "observed", "failed");
        if(!finished)
        {
            std::fprintf(stderr, "could not record failed game %s: %s\n",
                         game_id.c_str(), mw::errorMsg(finished.error()).c_str());
        }
        session->cleanup();
        releaseReservation(game_id);
        return started;
    }

    {
        std::lock_guard registry_lock(registry_mutex_);
        --pending_creations_;
        reserved_ids_.erase(game_id);
        sessions_.emplace(game_id, session);
    }
    sweep_condition_.notify_one();
    return {
        true,
        {
            {"game_id", game_id},
            {"control_token", control_token},
            {"viewer_url", viewer_url},
            {"state", std::move(started.value)},
        },
        {},
        {},
    };
}

ToolResult GameManager::dispatch(const std::string& tool_name,
                                const Json& arguments,
                                const std::string& client_id)
{
    if(!arguments.contains("game_id") || !arguments.at("game_id").is_string())
    {
        return managerError("GAME_NOT_FOUND", "game was not found");
    }
    const std::string game_id = arguments.at("game_id").get<std::string>();
    if(!validGameId(game_id))
    {
        return managerError("GAME_NOT_FOUND", "game was not found");
    }
    auto session = findActive(game_id);
    if(!session)
    {
        return managerError("GAME_NOT_FOUND", "game was not found");
    }

    if(!allowControlAttempt(client_id))
    {
        return managerError(
            "RATE_LIMITED", "too many invalid game control credentials",
            {{"retry_after_seconds", config_.control_failure_window.count()}});
    }
    ToolResult authorized = session->authorize(arguments);
    if(!authorized.success)
    {
        if(authorized.code == "FORBIDDEN")
        {
            recordControlFailure(client_id);
        }
        return authorized;
    }
    clearControlFailures(client_id);

    if(tool_name == "observe") return session->observe(arguments);
    if(tool_name == "press") return session->press(arguments);
    if(tool_name == "select_menu") return session->selectMenu(arguments);
    if(tool_name == "respond") return session->respond(arguments);
    if(tool_name == "quit_game") return session->quitGame(arguments);
    return managerError("UNKNOWN_TOOL", "unknown game tool");
}

std::shared_ptr<GameSession> GameManager::findActive(
    const std::string& game_id) const
{
    std::lock_guard registry_lock(registry_mutex_);
    const auto found = sessions_.find(game_id);
    return found == sessions_.end() ? nullptr : found->second;
}

mw::E<std::optional<GameRecord>> GameManager::getRecord(
    const std::string& game_id)
{
    if(!validGameId(game_id))
    {
        return std::optional<GameRecord>();
    }
    return records_->getGame(game_id);
}

mw::E<std::vector<GameRecord>> GameManager::recentGames()
{
    return records_->recentGames();
}

void GameManager::shutdown()
{
    if(stopping_.exchange(true))
    {
        return;
    }
    sweep_condition_.notify_all();
    if(sweep_thread_.joinable())
    {
        sweep_thread_.join();
    }
    std::vector<std::shared_ptr<GameSession>> sessions;
    {
        std::lock_guard registry_lock(registry_mutex_);
        sessions.reserve(sessions_.size());
        for(const auto& [game_id, session] : sessions_)
        {
            [[maybe_unused]] const std::string& ignored = game_id;
            sessions.push_back(session);
        }
    }
    for(const auto& session : sessions)
    {
        session->shutdown();
    }
}

const std::string& GameManager::publicBaseUrl() const
{
    return config_.public_base_url;
}

const ServerConfig& GameManager::config() const
{
    return config_;
}

std::size_t GameManager::activeGameCount() const
{
    std::lock_guard registry_lock(registry_mutex_);
    return sessions_.size();
}

std::size_t GameManager::activeWorkerCount() const
{
    std::vector<std::shared_ptr<GameSession>> sessions;
    {
        std::lock_guard registry_lock(registry_mutex_);
        sessions.reserve(sessions_.size());
        for(const auto& [game_id, session] : sessions_)
        {
            [[maybe_unused]] const std::string& ignored = game_id;
            sessions.push_back(session);
        }
    }
    return static_cast<std::size_t>(std::count_if(
        sessions.begin(), sessions.end(), [](const auto& session) {
            return session->workerRunning();
        }));
}

std::uint64_t GameManager::runtimeBytes() const
{
    std::uint64_t size = 0;
    std::error_code error;
    for(std::filesystem::recursive_directory_iterator iterator(
            config_.data_root, error), end;
        !error && iterator != end; iterator.increment(error))
    {
        if(iterator->is_regular_file(error))
        {
            size += iterator->file_size(error);
        }
        if(error)
        {
            error.clear();
        }
    }
    return size;
}

std::uint64_t GameManager::expiryCleanupFailures() const
{
    return expiry_cleanup_failures_.load();
}

std::uint64_t GameManager::databaseWriteLatencyMicroseconds() const
{
    return records_->lastWriteLatencyMicroseconds();
}

void GameManager::sweepLoop()
{
    std::unique_lock wait_lock(sweep_mutex_);
    while(!stopping_)
    {
        std::chrono::steady_clock::duration wait_duration =
            config_.lifecycle_sweep_interval;
        std::vector<std::shared_ptr<GameSession>> scheduled_sessions;
        {
            std::lock_guard registry_lock(registry_mutex_);
            scheduled_sessions.reserve(sessions_.size());
            for(const auto& [game_id, session] : sessions_)
            {
                [[maybe_unused]] const std::string& ignored = game_id;
                scheduled_sessions.push_back(session);
            }
        }
        const auto now = std::chrono::steady_clock::now();
        for(const auto& session : scheduled_sessions)
        {
            const auto deadline = session->nextDeadline();
            const auto until_deadline = deadline <= now
                ? std::chrono::steady_clock::duration::zero()
                : deadline - now;
            wait_duration = std::min(wait_duration, until_deadline);
        }
        sweep_condition_.wait_for(wait_lock, wait_duration);
        if(stopping_)
        {
            break;
        }
        wait_lock.unlock();

        std::vector<std::pair<std::string, std::shared_ptr<GameSession>>> copy;
        {
            std::lock_guard registry_lock(registry_mutex_);
            copy.reserve(sessions_.size());
            for(const auto& item : sessions_)
            {
                copy.push_back(item);
            }
        }
        for(const auto& [game_id, session] : copy)
        {
            session->expireIfNeeded();
            if(session->terminal())
            {
                if(!session->cleanup())
                {
                    ++expiry_cleanup_failures_;
                    continue;
                }
                std::lock_guard registry_lock(registry_mutex_);
                const auto found = sessions_.find(game_id);
                if(found != sessions_.end() && found->second == session)
                {
                    sessions_.erase(found);
                }
            }
        }
        removeOrphanedDirectories();
        wait_lock.lock();
    }
}

void GameManager::removeOrphanedDirectories()
{
    std::unordered_set<std::string> active_ids;
    {
        std::lock_guard registry_lock(registry_mutex_);
        active_ids.reserve(sessions_.size() + reserved_ids_.size());
        for(const auto& [game_id, session] : sessions_)
        {
            [[maybe_unused]] const std::shared_ptr<GameSession>& ignored =
                session;
            active_ids.insert(game_id);
        }
        active_ids.insert(reserved_ids_.begin(), reserved_ids_.end());
    }
    std::error_code error;
    if(!std::filesystem::exists(config_.data_root, error))
    {
        return;
    }
    for(std::filesystem::directory_iterator iterator(config_.data_root, error),
        end;
        !error && iterator != end; iterator.increment(error))
    {
        if(!iterator->is_directory(error))
        {
            continue;
        }
        const std::string game_id = iterator->path().filename().string();
        if(!validGameId(game_id) || active_ids.contains(game_id))
        {
            continue;
        }
        std::error_code remove_error;
        std::filesystem::remove_all(iterator->path(), remove_error);
        if(remove_error)
        {
            ++expiry_cleanup_failures_;
            std::fprintf(stderr, "could not remove orphaned game directory %s: %s\n",
                         iterator->path().c_str(), remove_error.message().c_str());
        }
    }
    if(error)
    {
        std::fprintf(stderr, "could not scan game data root: %s\n",
                     error.message().c_str());
    }
}

void GameManager::releaseReservation(const std::string& game_id)
{
    std::lock_guard registry_lock(registry_mutex_);
    if(pending_creations_ > 0)
    {
        --pending_creations_;
    }
    reserved_ids_.erase(game_id);
}

bool GameManager::allowCreation(
    const std::string& client_id,
    std::chrono::steady_clock::time_point now)
{
    const std::string key = client_id.empty() ? "unknown" : client_id;
    pruneClientHistory(now);
    auto found = client_history_.find(key);
    if(found == client_history_.end())
    {
        if(client_history_.size() >= config_.max_rate_limit_clients)
        {
            return false;
        }
        found = client_history_.try_emplace(key).first;
    }
    auto& timestamps = found->second.creation_times;
    if(timestamps.size() >= config_.new_games_per_client)
    {
        return false;
    }
    timestamps.push_back(now);
    return true;
}

bool GameManager::allowControlAttempt(const std::string& client_id)
{
    std::lock_guard registry_lock(registry_mutex_);
    const auto now = std::chrono::steady_clock::now();
    pruneClientHistory(now);
    const std::string key = client_id.empty() ? "unknown" : client_id;
    auto found = client_history_.find(key);
    if(found == client_history_.end())
    {
        if(client_history_.size() >= config_.max_rate_limit_clients)
        {
            return false;
        }
        found = client_history_.try_emplace(key).first;
    }
    return found->second.auth_failures.size()
        < config_.control_failures_per_client;
}

void GameManager::recordControlFailure(const std::string& client_id)
{
    std::lock_guard registry_lock(registry_mutex_);
    const auto now = std::chrono::steady_clock::now();
    pruneClientHistory(now);
    const std::string key = client_id.empty() ? "unknown" : client_id;
    auto found = client_history_.find(key);
    if(found == client_history_.end())
    {
        if(client_history_.size() >= config_.max_rate_limit_clients)
        {
            return;
        }
        found = client_history_.try_emplace(key).first;
    }
    found->second.auth_failures.push_back(now);
}

void GameManager::clearControlFailures(const std::string& client_id)
{
    std::lock_guard registry_lock(registry_mutex_);
    const std::string key = client_id.empty() ? "unknown" : client_id;
    auto found = client_history_.find(key);
    if(found != client_history_.end())
    {
        found->second.auth_failures.clear();
        if(found->second.creation_times.empty())
        {
            client_history_.erase(found);
        }
    }
}

void GameManager::pruneClientHistory(
    std::chrono::steady_clock::time_point now)
{
    const auto creation_cutoff = now - config_.new_game_rate_window;
    const auto auth_cutoff = now - config_.control_failure_window;
    for(auto iterator = client_history_.begin();
        iterator != client_history_.end();)
    {
        auto& history = iterator->second;
        while(!history.creation_times.empty()
              && history.creation_times.front() <= creation_cutoff)
        {
            history.creation_times.pop_front();
        }
        while(!history.auth_failures.empty()
              && history.auth_failures.front() <= auth_cutoff)
        {
            history.auth_failures.pop_front();
        }
        if(history.creation_times.empty() && history.auth_failures.empty())
        {
            iterator = client_history_.erase(iterator);
        }
        else
        {
            ++iterator;
        }
    }
}

} // namespace nethack_mcp