/*
 * Performance tests for mediadb:
 * - BinDb CRUD timing (stores, albums, media) in memory-only mode
 * - Storage behaviour: file size growth, vacuum effectiveness
 * - Media data read throughput (sequential, random, range)
 * - Export/import round-trip performance
 * - Memory (RSS) tracking during bulk operations
 */
#include "../src/backend.h"

#include <chrono>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <numeric>
#include <string>
#include <vector>

#ifdef _WIN32
#include <process.h>
#include <io.h>
#include <windows.h>
#include <psapi.h>
#define test_getpid  _getpid
#define test_unlink  _unlink
#else
#include <unistd.h>
#define test_getpid  getpid
#define test_unlink  unlink
#endif

namespace fs = std::filesystem;
using namespace mediadb;

// ═════════════════════════════════════════════════════════════
//  Helpers
// ═════════════════════════════════════════════════════════════

static size_t getRSSKb() {
#ifdef _WIN32
    PROCESS_MEMORY_COUNTERS pmc;
    if (GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof(pmc)))
        return pmc.WorkingSetSize / 1024;
    return 0;
#else
    std::ifstream ifs("/proc/self/status");
    std::string line;
    while (std::getline(ifs, line)) {
        if (line.rfind("VmRSS:", 0) == 0) {
            size_t kb = 0;
            sscanf(line.c_str(), "VmRSS: %zu kB", &kb);
            return kb;
        }
    }
    return 0;
#endif
}

static std::string tempDir() {
#ifdef _WIN32
    const char *tmp = std::getenv("TEMP");
    if (!tmp) tmp = std::getenv("TMP");
    return tmp ? std::string(tmp) + "\\" : "C:\\Temp\\";
#else
    return "/tmp/";
#endif
}

using Clock = std::chrono::high_resolution_clock;

struct TimingStats {
    double min_us;
    double max_us;
    double avg_us;
    double total_ms;
};

static TimingStats computeStats(const std::vector<double> &samples_us) {
    TimingStats s{};
    if (samples_us.empty()) return s;
    s.min_us = *std::min_element(samples_us.begin(), samples_us.end());
    s.max_us = *std::max_element(samples_us.begin(), samples_us.end());
    s.total_ms = std::accumulate(samples_us.begin(), samples_us.end(), 0.0) / 1000.0;
    s.avg_us = (s.total_ms * 1000.0) / static_cast<double>(samples_us.size());
    return s;
}

static void printStats(const char *label, const TimingStats &s, size_t n) {
    std::cout << "  " << label << " (" << n << " ops): "
              << "min=" << s.min_us << " µs  "
              << "avg=" << s.avg_us << " µs  "
              << "max=" << s.max_us << " µs  "
              << "total=" << s.total_ms << " ms\n";
}

static std::vector<std::uint8_t> makeTestData(size_t size, uint8_t seed = 0xAB) {
    std::vector<std::uint8_t> d(size);
    for (size_t i = 0; i < size; i++)
        d[i] = static_cast<uint8_t>((seed + i) & 0xFF);
    return d;
}

// RAII temp directory
class TmpDir {
    fs::path path_;
public:
    TmpDir(const std::string &suffix) {
        path_ = fs::path(tempDir()) / ("mediadb_perf_" + std::to_string(test_getpid()) + "_" + suffix);
        fs::create_directories(path_);
    }
    ~TmpDir() { std::error_code ec; fs::remove_all(path_, ec); }
    const fs::path &path() const { return path_; }
};

// ═════════════════════════════════════════════════════════════
//  Minimal test framework (matches existing mediadb_test style)
// ═════════════════════════════════════════════════════════════

static int g_tests_run    = 0;
static int g_tests_passed = 0;
static int g_tests_failed = 0;

struct TestEntry { const char *name; void (*fn)(); };
static std::vector<TestEntry> &tests_vec() {
    static std::vector<TestEntry> v;
    return v;
}
#define tests tests_vec()

#define TEST(name)                                                      \
    static void test_##name();                                          \
    struct test_reg_##name {                                            \
        test_reg_##name() { tests.push_back({#name, test_##name}); }   \
    } test_reg_inst_##name;                                             \
    static void test_##name()

#define ASSERT_TRUE(cond)                                               \
    do { if (!(cond)) {                                                 \
        std::cerr << "  FAIL: " << #cond << " at line " << __LINE__    \
                  << "\n"; throw std::runtime_error("assertion");       \
    } } while(0)

#define ASSERT_EQ(a, b)                                                 \
    do { if ((a) != (b)) {                                              \
        std::cerr << "  FAIL: " << #a << " == " << #b << " at line "   \
                  << __LINE__ << "  (" << (a) << " vs " << (b) << ")\n"; \
        throw std::runtime_error("assertion");                          \
    } } while(0)

// ═════════════════════════════════════════════════════════════
//  1.  Store + Album + Media CRUD benchmark (memory-only)
// ═════════════════════════════════════════════════════════════

TEST(store_album_media_crud_benchmark) {
    constexpr size_t NUM_STORES = 20;
    constexpr size_t ALBUMS_PER_STORE = 10;
    constexpr size_t MEDIA_PER_ALBUM = 20;
    constexpr size_t MEDIA_SIZE = 4096;

    BinDb db;

    std::vector<double> storeCreate, albumCreate, mediaAdd;
    std::vector<double> storeList, albumList, mediaList;
    std::vector<double> mediaRead, mediaDelete;

    std::vector<std::string> storeIds;
    std::vector<std::string> albumIds;
    std::vector<std::string> mediaIds;

    size_t rssBefore = getRSSKb();

    // ── CREATE stores ──
    for (size_t s = 0; s < NUM_STORES; ++s) {
        auto t0 = Clock::now();
        auto store = db.create_store("store_" + std::to_string(s));
        auto t1 = Clock::now();
        storeCreate.push_back(std::chrono::duration<double, std::micro>(t1 - t0).count());
        storeIds.push_back(store.id);
    }

    // ── CREATE albums ──
    for (auto &sid : storeIds) {
        for (size_t a = 0; a < ALBUMS_PER_STORE; ++a) {
            auto t0 = Clock::now();
            auto album = db.create_album(sid, "album_" + std::to_string(a));
            auto t1 = Clock::now();
            albumCreate.push_back(std::chrono::duration<double, std::micro>(t1 - t0).count());
            ASSERT_TRUE(album.has_value());
            albumIds.push_back(album->id);
        }
    }

    // ── ADD media ──
    auto data = makeTestData(MEDIA_SIZE);
    for (auto &aid : albumIds) {
        for (size_t m = 0; m < MEDIA_PER_ALBUM; ++m) {
            auto t0 = Clock::now();
            auto media = db.add_media(aid, "img_" + std::to_string(m) + ".jpg",
                                      "image/jpeg", data);
            auto t1 = Clock::now();
            mediaAdd.push_back(std::chrono::duration<double, std::micro>(t1 - t0).count());
            ASSERT_TRUE(media.has_value());
            mediaIds.push_back(media->id);
        }
    }

    size_t rssAfterCreate = getRSSKb();

    // ── LIST stores ──
    {
        auto t0 = Clock::now();
        auto stores = db.list_stores();
        auto t1 = Clock::now();
        storeList.push_back(std::chrono::duration<double, std::micro>(t1 - t0).count());
        ASSERT_EQ(stores.size(), NUM_STORES);
    }

    // ── LIST albums (per store) ──
    for (auto &sid : storeIds) {
        auto t0 = Clock::now();
        auto albums = db.list_albums(sid);
        auto t1 = Clock::now();
        albumList.push_back(std::chrono::duration<double, std::micro>(t1 - t0).count());
        ASSERT_EQ(albums.size(), ALBUMS_PER_STORE);
    }

    // ── LIST media (per album, sample first 50) ──
    for (size_t i = 0; i < std::min<size_t>(50, albumIds.size()); ++i) {
        auto t0 = Clock::now();
        auto media = db.list_media(albumIds[i]);
        auto t1 = Clock::now();
        mediaList.push_back(std::chrono::duration<double, std::micro>(t1 - t0).count());
        ASSERT_EQ(media.size(), MEDIA_PER_ALBUM);
    }

    // ── READ media data (sample 200) ──
    for (size_t i = 0; i < std::min<size_t>(200, mediaIds.size()); ++i) {
        auto t0 = Clock::now();
        auto raw = db.read_media_data(mediaIds[i]);
        auto t1 = Clock::now();
        mediaRead.push_back(std::chrono::duration<double, std::micro>(t1 - t0).count());
        ASSERT_EQ(raw.size(), MEDIA_SIZE);
    }

    size_t rssAfterRead = getRSSKb();

    // ── DELETE media (sample 200) ──
    for (size_t i = 0; i < std::min<size_t>(200, mediaIds.size()); ++i) {
        auto t0 = Clock::now();
        bool ok = db.delete_media(mediaIds[i]);
        auto t1 = Clock::now();
        mediaDelete.push_back(std::chrono::duration<double, std::micro>(t1 - t0).count());
        ASSERT_TRUE(ok);
    }

    size_t rssAfterAll = getRSSKb();

    size_t totalMedia = NUM_STORES * ALBUMS_PER_STORE * MEDIA_PER_ALBUM;
    std::cout << "\n=== Store/Album/Media CRUD Benchmark ===\n"
              << "  " << NUM_STORES << " stores, " << ALBUMS_PER_STORE << " albums/store, "
              << MEDIA_PER_ALBUM << " media/album (" << totalMedia << " total media, "
              << MEDIA_SIZE << " B each)\n";
    printStats("STORE CREATE ", computeStats(storeCreate), NUM_STORES);
    printStats("ALBUM CREATE ", computeStats(albumCreate), albumIds.size());
    printStats("MEDIA ADD    ", computeStats(mediaAdd), mediaIds.size());
    printStats("STORE LIST   ", computeStats(storeList), 1);
    printStats("ALBUM LIST   ", computeStats(albumList), storeIds.size());
    printStats("MEDIA LIST   ", computeStats(mediaList), std::min<size_t>(50, albumIds.size()));
    printStats("MEDIA READ   ", computeStats(mediaRead), std::min<size_t>(200, mediaIds.size()));
    printStats("MEDIA DELETE ", computeStats(mediaDelete), std::min<size_t>(200, mediaIds.size()));

    std::cout << "\n  Memory (RSS kB):\n"
              << "    before       = " << rssBefore << "\n"
              << "    after create = " << rssAfterCreate << "\n"
              << "    after read   = " << rssAfterRead << "\n"
              << "    after all    = " << rssAfterAll << "\n"
              << "    delta create = " << (long long)rssAfterCreate - (long long)rssBefore << "\n";
}

// ═════════════════════════════════════════════════════════════
//  2.  Large media throughput (memory-only)
// ═════════════════════════════════════════════════════════════

TEST(large_media_throughput) {
    constexpr size_t NUM_FILES = 20;
    constexpr size_t FILE_SIZE = 1024 * 1024; // 1 MB each

    BinDb db;
    auto store = db.create_store("bigfiles");
    auto album = db.create_album(store.id, "large");
    ASSERT_TRUE(album.has_value());

    auto data = makeTestData(FILE_SIZE, 0x42);

    std::vector<std::string> ids;
    std::vector<double> writeTimes, readTimes, rangeTimes;

    size_t rssBefore = getRSSKb();

    // ── WRITE ──
    for (size_t i = 0; i < NUM_FILES; ++i) {
        auto t0 = Clock::now();
        auto m = db.add_media(album->id, "big_" + std::to_string(i) + ".bin",
                              "application/octet-stream", data);
        auto t1 = Clock::now();
        writeTimes.push_back(std::chrono::duration<double, std::micro>(t1 - t0).count());
        ASSERT_TRUE(m.has_value());
        ids.push_back(m->id);
    }

    size_t rssAfterWrite = getRSSKb();

    // ── SEQUENTIAL READ ──
    for (auto &id : ids) {
        auto t0 = Clock::now();
        auto raw = db.read_media_data(id);
        auto t1 = Clock::now();
        readTimes.push_back(std::chrono::duration<double, std::micro>(t1 - t0).count());
        ASSERT_EQ(raw.size(), FILE_SIZE);
    }

    // ── RANGE READ (64 KB from middle) ──
    constexpr size_t RANGE_SIZE = 64 * 1024;
    constexpr size_t RANGE_OFFSET = FILE_SIZE / 2;
    for (auto &id : ids) {
        auto t0 = Clock::now();
        auto range = db.read_media_data_range(id, RANGE_OFFSET, RANGE_SIZE);
        auto t1 = Clock::now();
        rangeTimes.push_back(std::chrono::duration<double, std::micro>(t1 - t0).count());
        ASSERT_EQ(range.size(), RANGE_SIZE);
    }

    auto ws = computeStats(writeTimes);
    auto rs = computeStats(readTimes);
    auto rng = computeStats(rangeTimes);

    double writeMBs = (double)(NUM_FILES * FILE_SIZE) / (1024.0 * 1024.0) / (ws.total_ms / 1000.0);
    double readMBs  = (double)(NUM_FILES * FILE_SIZE) / (1024.0 * 1024.0) / (rs.total_ms / 1000.0);

    std::cout << "\n=== Large Media Throughput (" << NUM_FILES << " × "
              << FILE_SIZE / 1024 << " KB) ===\n";
    printStats("WRITE      ", ws, NUM_FILES);
    printStats("READ       ", rs, NUM_FILES);
    printStats("RANGE 64KB ", rng, NUM_FILES);
    std::cout << "  Write throughput = " << writeMBs << " MB/s\n"
              << "  Read  throughput = " << readMBs << " MB/s\n"
              << "  Memory delta     = " << (long long)rssAfterWrite - (long long)rssBefore << " kB\n";
}

// ═════════════════════════════════════════════════════════════
//  3.  Storage growth linearity (disk-backed)
// ═════════════════════════════════════════════════════════════

TEST(storage_growth_linearity) {
    TmpDir tmp("growth");
    constexpr size_t BATCH = 50;
    constexpr size_t BATCHES = 5;
    constexpr size_t MEDIA_SIZE = 2048;

    BinDb db(tmp.path().string());
    auto store = db.create_store("growth_test");
    auto album = db.create_album(store.id, "batch_album");
    ASSERT_TRUE(album.has_value());

    auto data = makeTestData(MEDIA_SIZE);
    std::vector<size_t> sizes;
    std::vector<double> deltas;

    for (size_t b = 0; b < BATCHES; ++b) {
        for (size_t i = 0; i < BATCH; ++i) {
            db.add_media(album->id, "f_" + std::to_string(b * BATCH + i) + ".dat",
                         "application/octet-stream", data);
        }

        // Measure store file size
        auto sf = tmp.path() / (store.id + ".mdb");
        size_t sz = 0;
        if (fs::exists(sf)) sz = static_cast<size_t>(fs::file_size(sf));
        sizes.push_back(sz);
        if (sizes.size() > 1) {
            deltas.push_back(static_cast<double>(sizes.back()) - static_cast<double>(sizes[sizes.size() - 2]));
        }
    }

    std::cout << "\n=== Storage Growth (" << BATCH << " media × " << BATCHES
              << " batches, " << MEDIA_SIZE << " B each) ===\n";
    for (size_t b = 0; b < BATCHES; ++b) {
        std::cout << "  batch " << (b + 1) << ": store_size=" << sizes[b] << " bytes";
        if (b > 0) std::cout << "  Δ=" << (sizes[b] - sizes[b - 1]);
        std::cout << "\n";
    }

    // Check growth is approximately linear
    if (deltas.size() >= 2) {
        double maxDelta = *std::max_element(deltas.begin(), deltas.end());
        double minDelta = *std::min_element(deltas.begin(), deltas.end());
        double ratio = maxDelta / std::max(minDelta, 1.0);
        std::cout << "  Growth ratio (max/min delta) = " << ratio << " (1.0 = perfectly linear)\n";
        ASSERT_TRUE(ratio < 1.1); // allow 10% variance
    }
}

// ═════════════════════════════════════════════════════════════
//  4.  Vacuum effectiveness (disk-backed)
// ═════════════════════════════════════════════════════════════

TEST(vacuum_effectiveness) {
    TmpDir tmp("vacuum");
    constexpr size_t N = 100;
    constexpr size_t MEDIA_SIZE = 4096;

    BinDb db(tmp.path().string());
    auto store = db.create_store("vacuum_store");
    auto album = db.create_album(store.id, "vac_album");
    ASSERT_TRUE(album.has_value());

    auto data = makeTestData(MEDIA_SIZE);
    std::vector<std::string> ids;

    for (size_t i = 0; i < N; ++i) {
        auto m = db.add_media(album->id, "v_" + std::to_string(i) + ".bin",
                              "application/octet-stream", data);
        ASSERT_TRUE(m.has_value());
        ids.push_back(m->id);
    }

    auto sf = tmp.path() / (store.id + ".mdb");
    size_t sizeBeforeDelete = fs::exists(sf) ? static_cast<size_t>(fs::file_size(sf)) : 0;

    // Delete half
    for (size_t i = 0; i < N / 2; ++i)
        db.delete_media(ids[i]);

    size_t sizeAfterDelete = fs::exists(sf) ? static_cast<size_t>(fs::file_size(sf)) : 0;

    // Vacuum
    auto t0 = Clock::now();
    db.vacuum_store(store.id);
    auto t1 = Clock::now();
    double vacuumMs = std::chrono::duration<double, std::milli>(t1 - t0).count();

    size_t sizeAfterVacuum = fs::exists(sf) ? static_cast<size_t>(fs::file_size(sf)) : 0;

    double reclaimedPct = (sizeBeforeDelete > 0)
        ? 100.0 * (1.0 - (double)sizeAfterVacuum / (double)sizeBeforeDelete)
        : 0.0;

    std::cout << "\n=== Vacuum Effectiveness (" << N << " media, " << MEDIA_SIZE << " B, delete 50%) ===\n"
              << "  before delete = " << sizeBeforeDelete << " bytes\n"
              << "  after delete  = " << sizeAfterDelete << " bytes (soft-delete, same size)\n"
              << "  after vacuum  = " << sizeAfterVacuum << " bytes\n"
              << "  reclaimed     = " << reclaimedPct << "%\n"
              << "  vacuum time   = " << vacuumMs << " ms\n";

    // File should shrink after vacuum (at least 30% reclaimed since we deleted 50%)
    ASSERT_TRUE(reclaimedPct > 30.0);
}

// ═════════════════════════════════════════════════════════════
//  5.  Export / Import round-trip (memory-only)
// ═════════════════════════════════════════════════════════════

TEST(export_import_roundtrip) {
    constexpr size_t NUM_STORES = 5;
    constexpr size_t ALBUMS_PER = 5;
    constexpr size_t MEDIA_PER = 10;
    constexpr size_t MEDIA_SIZE = 2048;

    BinDb db;

    // Populate
    auto data = makeTestData(MEDIA_SIZE);
    size_t totalMedia = 0;
    for (size_t s = 0; s < NUM_STORES; ++s) {
        auto store = db.create_store("exp_store_" + std::to_string(s));
        for (size_t a = 0; a < ALBUMS_PER; ++a) {
            auto album = db.create_album(store.id, "exp_album_" + std::to_string(a));
            ASSERT_TRUE(album.has_value());
            for (size_t m = 0; m < MEDIA_PER; ++m) {
                auto media = db.add_media(album->id, "exp_" + std::to_string(m) + ".bin",
                                          "application/octet-stream", data);
                ASSERT_TRUE(media.has_value());
                totalMedia++;
            }
        }
    }

    // Export
    auto t0 = Clock::now();
    auto buf = db.export_db_to_buffer();
    auto t1 = Clock::now();
    double exportMs = std::chrono::duration<double, std::milli>(t1 - t0).count();

    ASSERT_TRUE(!buf.empty());

    // Import into fresh DB
    BinDb db2;
    auto t2 = Clock::now();
    bool ok = db2.import_db_from_buffer(buf);
    auto t3 = Clock::now();
    double importMs = std::chrono::duration<double, std::milli>(t3 - t2).count();

    ASSERT_TRUE(ok);
    ASSERT_EQ(db2.list_stores().size(), NUM_STORES);

    double totalDataMB = (double)(totalMedia * MEDIA_SIZE) / (1024.0 * 1024.0);

    std::cout << "\n=== Export/Import Round-Trip (" << totalMedia << " media, "
              << totalDataMB << " MB) ===\n"
              << "  export: " << exportMs << " ms (" << buf.size() << " bytes)\n"
              << "  import: " << importMs << " ms\n"
              << "  export throughput = " << (totalDataMB / (exportMs / 1000.0)) << " MB/s\n"
              << "  import throughput = " << (totalDataMB / (importMs / 1000.0)) << " MB/s\n";
}

// ═════════════════════════════════════════════════════════════
//  6.  Disk persistence round-trip timing
// ═════════════════════════════════════════════════════════════

TEST(disk_persistence_roundtrip) {
    TmpDir tmp("persist");
    constexpr size_t N = 200;
    constexpr size_t MEDIA_SIZE = 8192;

    auto data = makeTestData(MEDIA_SIZE);

    // WRITE phase
    double writeMs;
    std::string storeId, albumId;
    {
        BinDb db(tmp.path().string());
        auto store = db.create_store("persist_store");
        storeId = store.id;
        auto album = db.create_album(store.id, "persist_album");
        ASSERT_TRUE(album.has_value());
        albumId = album->id;

        auto t0 = Clock::now();
        for (size_t i = 0; i < N; ++i) {
            db.add_media(album->id, "p_" + std::to_string(i) + ".bin",
                         "application/octet-stream", data);
        }
        auto t1 = Clock::now();
        writeMs = std::chrono::duration<double, std::milli>(t1 - t0).count();
    }
    // DB destructor flushes to disk

    // READ phase: new BinDb instance, loads from disk
    double loadMs;
    {
        auto t0 = Clock::now();
        BinDb db2(tmp.path().string());
        auto t1 = Clock::now();
        loadMs = std::chrono::duration<double, std::milli>(t1 - t0).count();

        auto stores = db2.list_stores();
        ASSERT_EQ(stores.size(), 1u);
        auto media = db2.list_media(albumId);
        ASSERT_EQ(media.size(), N);

        // Verify data integrity (spot-check first media)
        auto raw = db2.read_media_data(media[0].id);
        ASSERT_EQ(raw.size(), MEDIA_SIZE);
    }

    double totalMB = (double)(N * MEDIA_SIZE) / (1024.0 * 1024.0);

    std::cout << "\n=== Disk Persistence Round-Trip (" << N << " × " << MEDIA_SIZE / 1024
              << " KB = " << totalMB << " MB) ===\n"
              << "  write (populate) = " << writeMs << " ms\n"
              << "  load (from disk) = " << loadMs << " ms\n"
              << "  write throughput = " << (totalMB / (writeMs / 1000.0)) << " MB/s\n";
}

// ═════════════════════════════════════════════════════════════
//  7.  Cascade delete performance
// ═════════════════════════════════════════════════════════════

TEST(cascade_delete_benchmark) {
    constexpr size_t NUM_ALBUMS = 50;
    constexpr size_t MEDIA_PER = 20;
    constexpr size_t MEDIA_SIZE = 1024;

    BinDb db;
    auto store = db.create_store("cascade_store");
    auto data = makeTestData(MEDIA_SIZE);

    for (size_t a = 0; a < NUM_ALBUMS; ++a) {
        auto album = db.create_album(store.id, "casc_album_" + std::to_string(a));
        ASSERT_TRUE(album.has_value());
        for (size_t m = 0; m < MEDIA_PER; ++m) {
            db.add_media(album->id, "cm_" + std::to_string(m) + ".dat",
                         "application/octet-stream", data);
        }
    }

    size_t totalMedia = NUM_ALBUMS * MEDIA_PER;
    auto allMedia = db.media_ids();
    ASSERT_EQ(allMedia.size(), totalMedia);

    // Delete entire store (cascades to all albums and media)
    auto t0 = Clock::now();
    bool ok = db.delete_store(store.id);
    auto t1 = Clock::now();
    double deleteMs = std::chrono::duration<double, std::milli>(t1 - t0).count();

    ASSERT_TRUE(ok);
    ASSERT_EQ(db.list_stores().size(), 0u);

    std::cout << "\n=== Cascade Delete Benchmark ===\n"
              << "  1 store + " << NUM_ALBUMS << " albums + " << totalMedia << " media\n"
              << "  delete time = " << deleteMs << " ms\n"
              << "  per-media   = " << (deleteMs * 1000.0 / totalMedia) << " µs\n";
}

// ═════════════════════════════════════════════════════════════
//  main
// ═════════════════════════════════════════════════════════════

int main() {
    std::cout << "Running " << tests.size() << " mediadb performance tests\n"
              << std::string(60, '=') << "\n";

    for (auto &t : tests) {
        ++g_tests_run;
        std::cout << "[RUN ] " << t.name << "\n";
        try {
            t.fn();
            ++g_tests_passed;
            std::cout << "[PASS] " << t.name << "\n";
        } catch (const std::exception &e) {
            ++g_tests_failed;
            std::cout << "[FAIL] " << t.name << " (" << e.what() << ")\n";
        }
    }

    std::cout << std::string(60, '=') << "\n"
              << g_tests_passed << "/" << g_tests_run << " passed";
    if (g_tests_failed > 0)
        std::cout << ", " << g_tests_failed << " FAILED";
    std::cout << "\n";

    return g_tests_failed > 0 ? 1 : 0;
}
