BareGit
#include "game_record_store.hpp"

#include <chrono>
#include <filesystem>
#include <optional>
#include <stdexcept>
#include <string>
#include <unistd.h>
#include <tuple>
#include <utility>
#include <vector>

namespace nethack_mcp
{

namespace
{

using RecordRow = std::tuple<
    std::string, std::string, std::string, std::int64_t, std::int64_t,
    std::optional<std::int64_t>, std::optional<std::string>,
    std::optional<std::string>, std::optional<int>, std::optional<int>>;

class WriteTimer
{
public:
    explicit WriteTimer(std::atomic<std::uint64_t>& latency)
            : latency_(latency), started_at_(std::chrono::steady_clock::now())
    {}

    ~WriteTimer()
    {
        latency_ = static_cast<std::uint64_t>(
            std::chrono::duration_cast<std::chrono::microseconds>(
                std::chrono::steady_clock::now() - started_at_).count());
    }

private:
    std::atomic<std::uint64_t>& latency_;
    std::chrono::steady_clock::time_point started_at_;
};

GameRecord makeRecord(const RecordRow& row)
{
    return {
        std::get<0>(row), std::get<1>(row), std::get<2>(row),
        std::get<3>(row), std::get<4>(row), std::get<5>(row),
        std::get<6>(row), std::get<7>(row), std::get<8>(row),
        std::get<9>(row),
    };
}

std::string recordSelect()
{
    return "SELECT game_id, character_name, model_slug, started_at_s, "
        "last_activity_at_s, ended_at_s, end_time_kind, end_reason, "
        "last_depth, deepest_depth FROM game_records ";
}

std::filesystem::path prepareDatabaseDirectory(
    const std::filesystem::path& database_path)
{
    const std::filesystem::path absolute_path =
        std::filesystem::absolute(database_path);
    const std::filesystem::path parent = absolute_path.parent_path();
    std::error_code error;
    const bool already_exists = std::filesystem::exists(parent, error);
    if(error)
    {
        throw std::filesystem::filesystem_error(
            "could not inspect database directory", parent, error);
    }
    if(!already_exists)
    {
        std::filesystem::create_directories(parent);
        std::filesystem::permissions(
            parent, std::filesystem::perms::owner_all,
            std::filesystem::perm_options::replace);
    }

    const auto status = std::filesystem::symlink_status(parent);
    if(std::filesystem::is_symlink(status)
       || !std::filesystem::is_directory(status))
    {
        throw std::runtime_error(
            "database parent must be a real directory");
    }
    if(::access(parent.c_str(), W_OK | X_OK) != 0)
    {
        throw std::runtime_error(
            "database parent must be writable and searchable");
    }

    const bool database_exists = std::filesystem::exists(absolute_path, error);
    if(error)
    {
        throw std::filesystem::filesystem_error(
            "could not inspect database file", absolute_path, error);
    }
    if(database_exists)
    {
        const auto database_status =
            std::filesystem::symlink_status(absolute_path);
        if(std::filesystem::is_symlink(database_status)
           || !std::filesystem::is_regular_file(database_status))
        {
            throw std::runtime_error(
                "database path must be a regular file, not a symlink");
        }
    }
    return absolute_path;
}

} // namespace

Json GameRecord::toJson() const
{
    return {
        {"game_id", game_id},
        {"character_name", character_name},
        {"model_slug", model_slug},
        {"started_at_s", started_at_s},
        {"last_activity_at_s", last_activity_at_s},
        {"ended_at_s", ended_at_s ? Json(*ended_at_s) : Json(nullptr)},
        {"end_time_kind", end_time_kind ? Json(*end_time_kind) : Json(nullptr)},
        {"end_reason", end_reason ? Json(*end_reason) : Json(nullptr)},
        {"last_depth", last_depth ? Json(*last_depth) : Json(nullptr)},
        {"deepest_depth", deepest_depth ? Json(*deepest_depth) : Json(nullptr)},
    };
}

GameRecordStore::GameRecordStore(std::unique_ptr<mw::SQLite> database)
        : database_(std::move(database))
{}

mw::E<std::unique_ptr<GameRecordStore>> GameRecordStore::open(
    const std::filesystem::path& database_path)
{
    std::filesystem::path absolute_database_path;
    try
    {
        absolute_database_path = prepareDatabaseDirectory(database_path);
    }
    catch(const std::exception& exception)
    {
        return std::unexpected(mw::runtimeError(exception.what()));
    }

    auto database = mw::SQLite::connectFile(
        absolute_database_path.string(), 5000);
    if(!database)
    {
        return std::unexpected(database.error());
    }
    auto store = std::unique_ptr<GameRecordStore>(
        new GameRecordStore(std::move(*database)));
    auto migrated = store->migrate();
    if(!migrated)
    {
        return std::unexpected(migrated.error());
    }

    try
    {
        std::filesystem::permissions(
            absolute_database_path,
            std::filesystem::perms::owner_read
                | std::filesystem::perms::owner_write,
            std::filesystem::perm_options::replace);
    }
    catch(const std::exception& exception)
    {
        return std::unexpected(mw::runtimeError(exception.what()));
    }
    return store;
}

mw::E<void> GameRecordStore::migrate()
{
    std::lock_guard lock(mutex_);
    auto version = database_->evalToValue<int>("PRAGMA user_version;");
    if(!version)
    {
        return std::unexpected(version.error());
    }
    if(*version > 1)
    {
        return std::unexpected(mw::runtimeError(
            "game database schema is newer than this server"));
    }
    if(*version == 1)
    {
        return {};
    }
    auto begun = database_->execute("BEGIN IMMEDIATE;");
    if(!begun)
    {
        return std::unexpected(begun.error());
    }
    auto table = database_->execute(
        "CREATE TABLE game_records ("
        "game_id TEXT PRIMARY KEY,"
        "character_name TEXT NOT NULL,"
        "model_slug TEXT NOT NULL CHECK (length(model_slug) BETWEEN 1 AND 128),"
        "started_at_s INTEGER NOT NULL,"
        "last_activity_at_s INTEGER NOT NULL,"
        "ended_at_s INTEGER,"
        "end_time_kind TEXT,"
        "end_reason TEXT,"
        "last_depth INTEGER,"
        "deepest_depth INTEGER,"
        "CHECK (end_reason IS NULL OR end_reason IN "
        "('ascended', 'escaped', 'died', 'quit', 'failed', "
        "'idle_timeout', 'time_limit', 'interrupted')),"
        "CHECK ((ended_at_s IS NULL) = (end_reason IS NULL))"
        ");");
    if(!table)
    {
        [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
        return std::unexpected(table.error());
    }
    auto index = database_->execute(
        "CREATE INDEX game_records_recent_idx "
        "ON game_records (ended_at_s DESC, game_id DESC) "
        "WHERE ended_at_s IS NOT NULL;");
    if(!index)
    {
        [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
        return std::unexpected(index.error());
    }
    auto migration = database_->execute("PRAGMA user_version = 1;");
    if(!migration)
    {
        [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
        return std::unexpected(migration.error());
    }
    return database_->execute("COMMIT;");
}

mw::E<bool> GameRecordStore::insertGame(const GameRecord& record)
{
    WriteTimer timer(last_write_latency_us_);
    std::lock_guard lock(mutex_);
    auto statement = database_->statementFromStr(
        "INSERT OR IGNORE INTO game_records "
        "(game_id, character_name, model_slug, started_at_s, "
        "last_activity_at_s) VALUES (?, ?, ?, ?, ?);");
    if(!statement)
    {
        return std::unexpected(statement.error());
    }
    auto bound = statement->bind(
        record.game_id, record.character_name, record.model_slug,
        record.started_at_s, record.last_activity_at_s);
    if(!bound)
    {
        return std::unexpected(bound.error());
    }
    auto inserted = database_->execute(std::move(*statement));
    if(!inserted)
    {
        return std::unexpected(inserted.error());
    }
    return database_->changedRowsCount() == 1;
}

mw::E<void> GameRecordStore::updateLocation(
    const std::string& game_id, int depth)
{
    WriteTimer timer(last_write_latency_us_);
    std::lock_guard lock(mutex_);
    auto statement = database_->statementFromStr(
        "UPDATE game_records SET last_depth = ?, "
        "deepest_depth = CASE WHEN deepest_depth IS NULL OR deepest_depth < ? "
        "THEN ? ELSE deepest_depth END "
        "WHERE game_id = ? AND ended_at_s IS NULL;");
    if(!statement)
    {
        return std::unexpected(statement.error());
    }
    auto bound = statement->bind(depth, depth, depth, game_id);
    if(!bound)
    {
        return std::unexpected(bound.error());
    }
    return database_->execute(std::move(*statement));
}

mw::E<void> GameRecordStore::updateActivity(
    const std::string& game_id, std::int64_t activity_at_s)
{
    WriteTimer timer(last_write_latency_us_);
    std::lock_guard lock(mutex_);
    auto statement = database_->statementFromStr(
        "UPDATE game_records SET last_activity_at_s = ? "
        "WHERE game_id = ? AND ended_at_s IS NULL;");
    if(!statement)
    {
        return std::unexpected(statement.error());
    }
    auto bound = statement->bind(activity_at_s, game_id);
    if(!bound)
    {
        return std::unexpected(bound.error());
    }
    return database_->execute(std::move(*statement));
}

mw::E<bool> GameRecordStore::finishGame(
    const std::string& game_id, std::int64_t ended_at_s,
    const std::string& end_time_kind, const std::string& end_reason)
{
    WriteTimer timer(last_write_latency_us_);
    std::lock_guard lock(mutex_);
    auto begun = database_->execute("BEGIN IMMEDIATE;");
    if(!begun)
    {
        return std::unexpected(begun.error());
    }
    auto statement = database_->statementFromStr(
        "UPDATE game_records SET ended_at_s = ?, end_time_kind = ?, "
        "end_reason = ? WHERE game_id = ? AND ended_at_s IS NULL;");
    if(!statement)
    {
        [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
        return std::unexpected(statement.error());
    }
    auto bound = statement->bind(
        ended_at_s, end_time_kind, end_reason, game_id);
    if(!bound)
    {
        [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
        return std::unexpected(bound.error());
    }
    auto updated = database_->execute(std::move(*statement));
    if(!updated)
    {
        [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
        return std::unexpected(updated.error());
    }
    const bool won = database_->changedRowsCount() == 1;
    auto committed = database_->execute("COMMIT;");
    if(!committed)
    {
        return std::unexpected(committed.error());
    }
    return won;
}

mw::E<std::vector<GameRecord>> GameRecordStore::recentGames()
{
    std::lock_guard lock(mutex_);
    auto statement = database_->statementFromStr(
        recordSelect() + "WHERE ended_at_s IS NOT NULL "
        "ORDER BY ended_at_s DESC, game_id DESC LIMIT 10;");
    if(!statement)
    {
        return std::unexpected(statement.error());
    }
    auto rows = database_->eval<
        std::string, std::string, std::string, std::int64_t, std::int64_t,
        std::optional<std::int64_t>, std::optional<std::string>,
        std::optional<std::string>, std::optional<int>, std::optional<int>>(
            std::move(*statement));
    if(!rows)
    {
        return std::unexpected(rows.error());
    }
    std::vector<GameRecord> records;
    records.reserve(rows->size());
    for(const RecordRow& row : *rows)
    {
        records.push_back(makeRecord(row));
    }
    return records;
}

mw::E<std::optional<GameRecord>> GameRecordStore::getGame(
    const std::string& game_id)
{
    std::lock_guard lock(mutex_);
    auto statement = database_->statementFromStr(
        recordSelect() + "WHERE game_id = ? LIMIT 1;");
    if(!statement)
    {
        return std::unexpected(statement.error());
    }
    auto bound = statement->bind(game_id);
    if(!bound)
    {
        return std::unexpected(bound.error());
    }
    auto rows = database_->eval<
        std::string, std::string, std::string, std::int64_t, std::int64_t,
        std::optional<std::int64_t>, std::optional<std::string>,
        std::optional<std::string>, std::optional<int>, std::optional<int>>(
            std::move(*statement));
    if(!rows)
    {
        return std::unexpected(rows.error());
    }
    if(rows->empty())
    {
        return std::optional<GameRecord>();
    }
    return std::optional<GameRecord>(makeRecord(rows->front()));
}

mw::E<void> GameRecordStore::recoverInterruptedGames(
    std::int64_t recovery_time_s)
{
    WriteTimer timer(last_write_latency_us_);
    std::lock_guard lock(mutex_);
    auto begun = database_->execute("BEGIN IMMEDIATE;");
    if(!begun)
    {
        return std::unexpected(begun.error());
    }
    auto statement = database_->statementFromStr(
        "UPDATE game_records SET ended_at_s = ?, end_time_kind = 'recovery', "
        "end_reason = 'interrupted' WHERE ended_at_s IS NULL;");
    if(!statement)
    {
        [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
        return std::unexpected(statement.error());
    }
    auto bound = statement->bind(recovery_time_s);
    if(!bound)
    {
        [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
        return std::unexpected(bound.error());
    }
    auto updated = database_->execute(std::move(*statement));
    if(!updated)
    {
        [[maybe_unused]] auto rolled_back = database_->execute("ROLLBACK;");
        return std::unexpected(updated.error());
    }
    return database_->execute("COMMIT;");
}

std::uint64_t GameRecordStore::lastWriteLatencyMicroseconds() const
{
    return last_write_latency_us_.load();
}

} // namespace nethack_mcp