#include "../src/backend.h"

#include <cassert>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <sstream>
#include <vector>

namespace fs = std::filesystem;

// ---------- minimal test framework ----------

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

#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()

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 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)

// ---------- RAII temp directory ----------

class TmpDir {
    fs::path path_;
public:
    TmpDir() {
        path_ = fs::temp_directory_path() / ("mediadb_test_" + std::to_string(getpid())
                 + "_" + std::to_string(g_tests_run));
        fs::create_directories(path_);
    }
    ~TmpDir() { std::error_code ec; fs::remove_all(path_, ec); }
    const fs::path& path() const { return path_; }
};

// helper: create some test data
static std::vector<std::uint8_t> make_data(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;
}

using namespace mediadb;

// ===================================================================
// 1. Memory-only BinDb: basic CRUD
// ===================================================================

TEST(memory_only_create_store) {
    BinDb db;
    auto s = db.create_store("photos");
    ASSERT_TRUE(!s.id.empty());
    ASSERT_EQ(s.name, "photos");
    ASSERT_TRUE(!s.created_at.empty());

    auto stores = db.list_stores();
    ASSERT_EQ(stores.size(), 1u);
    ASSERT_EQ(stores[0].id, s.id);
}

TEST(memory_only_create_album) {
    BinDb db;
    auto s = db.create_store("s1");
    auto a = db.create_album(s.id, "vacation");
    ASSERT_TRUE(a.has_value());
    ASSERT_EQ(a->name, "vacation");
    ASSERT_EQ(a->store_id, s.id);

    auto albums = db.list_albums(s.id);
    ASSERT_EQ(albums.size(), 1u);
}

TEST(memory_only_create_album_bad_store) {
    BinDb db;
    auto a = db.create_album("nonexistent", "test");
    ASSERT_TRUE(!a.has_value());
}

TEST(memory_only_add_media) {
    BinDb db;
    auto s = db.create_store("s1");
    auto a = db.create_album(s.id, "a1");
    ASSERT_TRUE(a.has_value());

    auto data = make_data(1024);
    auto m = db.add_media(a->id, "photo.jpg", "image/jpeg", data);
    ASSERT_TRUE(m.has_value());
    ASSERT_EQ(m->original_filename, "photo.jpg");
    ASSERT_EQ(m->content_type, "image/jpeg");
    ASSERT_EQ(m->media_kind, "image");
    ASSERT_EQ(m->size_bytes, 1024u);
    ASSERT_EQ(m->album_id, a->id);
    ASSERT_EQ(m->store_id, s.id);
}

TEST(memory_only_add_media_bad_album) {
    BinDb db;
    auto m = db.add_media("nonexistent", "f.jpg", "image/jpeg", make_data(10));
    ASSERT_TRUE(!m.has_value());
}

TEST(memory_only_read_media_data) {
    BinDb db;
    auto s = db.create_store("s1");
    auto a = db.create_album(s.id, "a1");
    auto data = make_data(512, 0x42);
    auto m = db.add_media(a->id, "test.png", "image/png", data);
    ASSERT_TRUE(m.has_value());

    auto readback = db.read_media_data(m->id);
    ASSERT_EQ(readback.size(), 512u);
    ASSERT_TRUE(readback == data);
}

TEST(memory_only_read_media_data_range) {
    BinDb db;
    auto s = db.create_store("s1");
    auto a = db.create_album(s.id, "a1");
    auto data = make_data(1000, 0x10);
    auto m = db.add_media(a->id, "video.mp4", "video/mp4", data);
    ASSERT_TRUE(m.has_value());

    // Read middle portion
    auto range = db.read_media_data_range(m->id, 100, 200);
    ASSERT_EQ(range.size(), 200u);
    for (size_t i = 0; i < 200; i++) {
        ASSERT_EQ(range[i], data[100 + i]);
    }

    // Read past end (should clamp)
    auto range2 = db.read_media_data_range(m->id, 900, 500);
    ASSERT_EQ(range2.size(), 100u);  // only 100 bytes remaining

    // Read from offset past size (should return empty)
    auto range3 = db.read_media_data_range(m->id, 1000, 10);
    ASSERT_TRUE(range3.empty());

    // Nonexistent media
    auto range4 = db.read_media_data_range("no-such-id", 0, 10);
    ASSERT_TRUE(range4.empty());
}

TEST(memory_only_get_media_size) {
    BinDb db;
    auto s = db.create_store("s1");
    auto a = db.create_album(s.id, "a1");
    auto m = db.add_media(a->id, "f.jpg", "image/jpeg", make_data(777));
    ASSERT_TRUE(m.has_value());
    ASSERT_EQ(db.get_media_size(m->id), 777u);
    ASSERT_EQ(db.get_media_size("nonexistent"), 0u);
}

TEST(memory_only_delete_media) {
    BinDb db;
    auto s = db.create_store("s1");
    auto a = db.create_album(s.id, "a1");
    auto m = db.add_media(a->id, "f.jpg", "image/jpeg", make_data(100));
    ASSERT_TRUE(m.has_value());

    ASSERT_TRUE(db.delete_media(m->id));
    ASSERT_TRUE(!db.get_media(m->id).has_value());
    ASSERT_TRUE(db.read_media_data(m->id).empty());

    // Album should have no media
    auto media_list = db.list_media(a->id);
    ASSERT_TRUE(media_list.empty());

    // Double delete should fail
    ASSERT_TRUE(!db.delete_media(m->id));
}

TEST(memory_only_delete_album_cascade) {
    BinDb db;
    auto s = db.create_store("s1");
    auto a = db.create_album(s.id, "a1");
    auto m1 = db.add_media(a->id, "f1.jpg", "image/jpeg", make_data(100));
    auto m2 = db.add_media(a->id, "f2.png", "image/png", make_data(200));
    ASSERT_TRUE(m1.has_value());
    ASSERT_TRUE(m2.has_value());

    ASSERT_TRUE(db.delete_album(a->id));

    // Album gone
    ASSERT_TRUE(!db.get_album(a->id).has_value());
    // Media gone
    ASSERT_TRUE(!db.get_media(m1->id).has_value());
    ASSERT_TRUE(!db.get_media(m2->id).has_value());
    // Store still exists, no albums
    auto albums = db.list_albums(s.id);
    ASSERT_TRUE(albums.empty());
}

TEST(memory_only_delete_store_cascade) {
    BinDb db;
    auto s = db.create_store("s1");
    auto a = db.create_album(s.id, "a1");
    db.add_media(a->id, "f.jpg", "image/jpeg", make_data(50));

    ASSERT_TRUE(db.delete_store(s.id));
    ASSERT_TRUE(!db.get_store(s.id).has_value());
    ASSERT_TRUE(db.list_stores().empty());
}

TEST(memory_only_rename_store) {
    BinDb db;
    auto s = db.create_store("old_name");
    ASSERT_TRUE(db.rename_store(s.id, "new_name"));
    auto s2 = db.get_store(s.id);
    ASSERT_TRUE(s2.has_value());
    ASSERT_EQ(s2->name, "new_name");

    ASSERT_TRUE(!db.rename_store("nonexistent", "x"));
}

// ===================================================================
// 2. Store auth domains
// ===================================================================

TEST(memory_only_auth_domains) {
    BinDb db;
    auto s = db.create_store("s1");

    AuthDomain d;
    d.label = "keycloak";
    d.url = "https://auth.example.com";
    d.client_name = "mediadb";
    d.client_secret = "secret123";
    auto did = db.add_store_domain(s.id, d);
    ASSERT_TRUE(!did.empty());

    auto domains = db.list_store_domains(s.id);
    ASSERT_EQ(domains.size(), 1u);
    ASSERT_EQ(domains[0].label, "keycloak");

    auto found = db.find_domain_by_label("keycloak");
    ASSERT_TRUE(found.has_value());
    ASSERT_EQ(found->url, "https://auth.example.com");

    ASSERT_TRUE(!db.find_domain_by_label("nonexistent").has_value());

    ASSERT_TRUE(db.remove_store_domain(s.id, did));
    ASSERT_TRUE(db.list_store_domains(s.id).empty());
}

// ===================================================================
// 3. Store ACLs
// ===================================================================

TEST(memory_only_acls) {
    BinDb db;
    auto s = db.create_store("s1");

    StoreACL acl;
    acl.group_id = "grp-001";
    acl.group_name = "editors";
    acl.can_read = true;
    acl.can_write = true;
    auto aid = db.add_store_acl(s.id, acl);
    ASSERT_TRUE(!aid.empty());

    auto acls = db.list_store_acls(s.id);
    ASSERT_EQ(acls.size(), 1u);
    ASSERT_EQ(acls[0].group_name, "editors");
    ASSERT_TRUE(acls[0].can_read);
    ASSERT_TRUE(acls[0].can_write);

    auto groups = db.all_acl_group_ids();
    ASSERT_EQ(groups.size(), 1u);
    ASSERT_EQ(groups[0], "grp-001");

    ASSERT_TRUE(db.remove_store_acl(s.id, aid));
    ASSERT_TRUE(db.list_store_acls(s.id).empty());
}

// ===================================================================
// 4. Album public flag
// ===================================================================

TEST(memory_only_album_public) {
    BinDb db;
    auto s = db.create_store("s1");
    auto a = db.create_album(s.id, "public_album");
    ASSERT_TRUE(a.has_value());
    ASSERT_TRUE(!a->is_public);
    ASSERT_TRUE(!db.is_album_public(a->id));

    ASSERT_TRUE(db.set_album_public(a->id, true));
    ASSERT_TRUE(db.is_album_public(a->id));

    auto pub = db.list_public_albums();
    ASSERT_EQ(pub.size(), 1u);
    ASSERT_EQ(pub[0].id, a->id);

    ASSERT_TRUE(db.set_album_public(a->id, false));
    ASSERT_TRUE(!db.is_album_public(a->id));
    ASSERT_TRUE(db.list_public_albums().empty());
}

TEST(memory_only_is_media_public) {
    BinDb db;
    auto s = db.create_store("s1");
    auto a = db.create_album(s.id, "a1");
    auto m = db.add_media(a->id, "f.jpg", "image/jpeg", make_data(10));
    ASSERT_TRUE(m.has_value());

    ASSERT_TRUE(!db.is_media_public(m->id));
    db.set_album_public(a->id, true);
    ASSERT_TRUE(db.is_media_public(m->id));
}

// ===================================================================
// 5. Media kind detection
// ===================================================================

TEST(media_kind_detection) {
    BinDb db;
    auto s = db.create_store("s1");
    auto a = db.create_album(s.id, "a1");

    auto test_kind = [&](const std::string& fname, const std::string& expected_kind) {
        auto m = db.add_media(a->id, fname, "application/octet-stream", make_data(1));
        ASSERT_TRUE(m.has_value());
        ASSERT_EQ(m->media_kind, expected_kind);
    };

    test_kind("photo.jpg", "image");
    test_kind("photo.JPEG", "image");
    test_kind("photo.png", "image");
    test_kind("photo.webp", "image");
    test_kind("photo.gif", "image");
    test_kind("photo.avif", "image");
    test_kind("video.mp4", "video");
    test_kind("video.MOV", "video");
    test_kind("video.mkv", "video");
    test_kind("video.webm", "video");
    test_kind("audio.mp3", "audio");
    test_kind("audio.flac", "audio");
    test_kind("audio.wav", "audio");
    test_kind("audio.ogg", "audio");
    test_kind("file.txt", "unknown");
    test_kind("file.pdf", "unknown");
}

// ===================================================================
// 6. Binary I/O helpers roundtrip
// ===================================================================

TEST(binary_io_roundtrip) {
    // u32
    {
        std::ostringstream oss(std::ios::binary);
        bin_write_u32(oss, 0x12345678);
        bin_write_u32(oss, 0);
        bin_write_u32(oss, UINT32_MAX);
        std::string s = oss.str();
        std::istringstream iss(s, std::ios::binary);
        ASSERT_EQ(bin_read_u32(iss), 0x12345678u);
        ASSERT_EQ(bin_read_u32(iss), 0u);
        ASSERT_EQ(bin_read_u32(iss), UINT32_MAX);
    }
    // u64
    {
        std::ostringstream oss(std::ios::binary);
        bin_write_u64(oss, 0x123456789ABCDEF0ULL);
        bin_write_u64(oss, 0);
        std::string s = oss.str();
        std::istringstream iss(s, std::ios::binary);
        ASSERT_EQ(bin_read_u64(iss), 0x123456789ABCDEF0ULL);
        ASSERT_EQ(bin_read_u64(iss), 0ULL);
    }
    // string
    {
        std::ostringstream oss(std::ios::binary);
        bin_write_str(oss, "hello");
        bin_write_str(oss, "");
        bin_write_str(oss, std::string(1000, 'X'));
        std::string s = oss.str();
        std::istringstream iss(s, std::ios::binary);
        ASSERT_EQ(bin_read_str(iss), "hello");
        ASSERT_EQ(bin_read_str(iss), "");
        ASSERT_EQ(bin_read_str(iss), std::string(1000, 'X'));
    }
}

// ===================================================================
// 7. Export → import roundtrip (memory-only)
// ===================================================================

TEST(export_import_roundtrip_memory) {
    BinDb db;
    auto s = db.create_store("mystore");
    auto a1 = db.create_album(s.id, "album1");
    auto a2 = db.create_album(s.id, "album2");
    ASSERT_TRUE(a1.has_value());
    ASSERT_TRUE(a2.has_value());

    db.set_album_public(a1->id, true);

    auto data1 = make_data(500, 0x11);
    auto data2 = make_data(300, 0x22);
    auto data3 = make_data(100, 0x33);
    auto m1 = db.add_media(a1->id, "img1.jpg", "image/jpeg", data1);
    auto m2 = db.add_media(a1->id, "img2.png", "image/png", data2);
    auto m3 = db.add_media(a2->id, "vid.mp4", "video/mp4", data3);
    ASSERT_TRUE(m1.has_value());
    ASSERT_TRUE(m2.has_value());
    ASSERT_TRUE(m3.has_value());

    // Add auth domain + ACL to the store
    AuthDomain d;
    d.label = "test-idp";
    d.url = "https://idp.example.com";
    d.client_name = "test";
    d.client_secret = "secret";
    db.add_store_domain(s.id, d);

    StoreACL acl;
    acl.group_id = "grp-1";
    acl.group_name = "viewers";
    acl.can_read = true;
    auto acl_id = db.add_store_acl(s.id, acl);
    ASSERT_TRUE(!acl_id.empty());

    // Export
    auto buf = db.export_db_to_buffer();
    ASSERT_TRUE(!buf.empty());

    // Import into fresh DB
    BinDb db2;
    ASSERT_TRUE(db2.import_db_from_buffer(buf));

    // Verify stores
    auto stores2 = db2.list_stores();
    ASSERT_EQ(stores2.size(), 1u);
    ASSERT_EQ(stores2[0].name, "mystore");

    // Note: auth domains and ACLs are in the index, not in export format
    // Export format only contains store/album/media hierarchy

    // Verify albums
    auto albums2 = db2.list_albums(s.id);
    ASSERT_EQ(albums2.size(), 2u);

    // Verify album1 public flag
    ASSERT_TRUE(db2.is_album_public(a1->id));
    ASSERT_TRUE(!db2.is_album_public(a2->id));

    // Verify media count
    auto media1 = db2.list_media(a1->id);
    ASSERT_EQ(media1.size(), 2u);
    auto media2 = db2.list_media(a2->id);
    ASSERT_EQ(media2.size(), 1u);

    // Verify media data integrity
    auto d1 = db2.read_media_data(m1->id);
    ASSERT_EQ(d1.size(), 500u);
    ASSERT_TRUE(d1 == data1);

    auto d2 = db2.read_media_data(m2->id);
    ASSERT_EQ(d2.size(), 300u);
    ASSERT_TRUE(d2 == data2);

    auto d3 = db2.read_media_data(m3->id);
    ASSERT_EQ(d3.size(), 100u);
    ASSERT_TRUE(d3 == data3);
}

// ===================================================================
// 8. Disk-based persistence: create, close, reload
// ===================================================================

TEST(disk_persistence_roundtrip) {
    TmpDir tmp;
    std::string store_id, album_id, media_id;
    auto data = make_data(256, 0x55);

    // Phase 1: create and populate
    {
        BinDb db(tmp.path().string());
        auto s = db.create_store("persistent");
        auto a = db.create_album(s.id, "album");
        auto m = db.add_media(a->id, "file.jpg", "image/jpeg", data);
        ASSERT_TRUE(m.has_value());
        store_id = s.id;
        album_id = a->id;
        media_id = m->id;
    }

    // Phase 2: reload and verify
    {
        BinDb db(tmp.path().string());
        auto s = db.get_store(store_id);
        ASSERT_TRUE(s.has_value());
        ASSERT_EQ(s->name, "persistent");

        auto a = db.get_album(album_id);
        ASSERT_TRUE(a.has_value());
        ASSERT_EQ(a->name, "album");

        auto m = db.get_media(media_id);
        ASSERT_TRUE(m.has_value());
        ASSERT_EQ(m->size_bytes, 256u);

        auto readback = db.read_media_data(media_id);
        ASSERT_EQ(readback.size(), 256u);
        ASSERT_TRUE(readback == data);
    }
}

// ===================================================================
// 9. Disk-based: export → import to file
// ===================================================================

TEST(disk_export_import_file) {
    TmpDir tmp;
    auto data = make_data(1024, 0x77);
    std::string store_id, album_id, media_id;

    {
        BinDb db(tmp.path().string());
        auto s = db.create_store("s1");
        auto a = db.create_album(s.id, "a1");
        auto m = db.add_media(a->id, "big.mp4", "video/mp4", data);
        ASSERT_TRUE(m.has_value());
        store_id = s.id;
        album_id = a->id;
        media_id = m->id;

        auto export_path = (tmp.path() / "export.mdb").string();
        ASSERT_TRUE(db.export_db(export_path));

        // Import into a separate directory
        fs::path import_dir = tmp.path() / "import";
        fs::create_directories(import_dir);
        BinDb db2(import_dir.string());
        ASSERT_TRUE(db2.import_db(export_path));

        auto readback = db2.read_media_data(media_id);
        ASSERT_EQ(readback.size(), 1024u);
        ASSERT_TRUE(readback == data);
    }
}

// ===================================================================
// 10. Vacuum: verify data preserved after vacuum
// ===================================================================

TEST(disk_vacuum_preserves_data) {
    TmpDir tmp;
    auto data1 = make_data(200, 0xAA);
    auto data2 = make_data(300, 0xBB);

    BinDb db(tmp.path().string());
    auto s = db.create_store("s1");
    auto a = db.create_album(s.id, "a1");
    auto m1 = db.add_media(a->id, "f1.jpg", "image/jpeg", data1);
    auto m2 = db.add_media(a->id, "f2.jpg", "image/jpeg", data2);
    ASSERT_TRUE(m1.has_value());
    ASSERT_TRUE(m2.has_value());

    // Delete first media, then vacuum to reclaim
    db.delete_media(m1->id);
    db.vacuum_store(s.id);

    // m2 should still be intact
    auto r2 = db.read_media_data(m2->id);
    ASSERT_EQ(r2.size(), 300u);
    ASSERT_TRUE(r2 == data2);

    // m1 should be gone
    ASSERT_TRUE(db.read_media_data(m1->id).empty());
}

TEST(disk_vacuum_after_album_delete) {
    TmpDir tmp;
    auto data = make_data(128, 0xCC);

    BinDb db(tmp.path().string());
    auto s = db.create_store("s1");
    auto a1 = db.create_album(s.id, "a1");
    auto a2 = db.create_album(s.id, "a2");
    auto m1 = db.add_media(a1->id, "f1.jpg", "image/jpeg", data);
    auto m2 = db.add_media(a2->id, "f2.jpg", "image/jpeg", make_data(64, 0xDD));
    ASSERT_TRUE(m1.has_value());
    ASSERT_TRUE(m2.has_value());

    // Delete first album (cascades to its media)
    db.delete_album(a1->id);
    db.vacuum_store(s.id);

    // a2 and its media still fine
    auto r = db.get_album(a2->id);
    ASSERT_TRUE(r.has_value());
    ASSERT_EQ(r->name, "a2");
    auto media = db.list_media(a2->id);
    ASSERT_EQ(media.size(), 1u);
    auto d = db.read_media_data(m2->id);
    ASSERT_EQ(d.size(), 64u);
}

// ===================================================================
// 11. Disk-based: vacuum then reload
// ===================================================================

TEST(disk_vacuum_then_reload) {
    TmpDir tmp;
    auto data = make_data(512, 0xEE);
    std::string store_id, album_id, media_id;

    {
        BinDb db(tmp.path().string());
        auto s = db.create_store("s1");
        auto a = db.create_album(s.id, "a1");
        auto m1 = db.add_media(a->id, "del.jpg", "image/jpeg", make_data(100));
        auto m2 = db.add_media(a->id, "keep.jpg", "image/jpeg", data);
        ASSERT_TRUE(m1.has_value());
        ASSERT_TRUE(m2.has_value());

        db.delete_media(m1->id);
        db.vacuum_store(s.id);
        store_id = s.id;
        album_id = a->id;
        media_id = m2->id;
    }

    // Reload from disk
    BinDb db2(tmp.path().string());
    auto m = db2.get_media(media_id);
    ASSERT_TRUE(m.has_value());
    auto r = db2.read_media_data(media_id);
    ASSERT_EQ(r.size(), 512u);
    ASSERT_TRUE(r == data);

    auto media_list = db2.list_media(album_id);
    ASSERT_EQ(media_list.size(), 1u);
}

// ===================================================================
// 12. Index save/load buffer roundtrip (with auth domains + ACLs)
// ===================================================================

TEST(index_buffer_roundtrip) {
    BinDb db;
    auto s = db.create_store("bufstore");

    AuthDomain d;
    d.label = "idp1";
    d.url = "https://auth.test";
    d.client_name = "cn";
    d.client_secret = "cs";
    db.add_store_domain(s.id, d);

    StoreACL acl;
    acl.group_id = "g1";
    acl.group_name = "readers";
    acl.can_read = true;
    acl.can_write = false;
    db.add_store_acl(s.id, acl);

    auto buf = db.save_index_to_buffer();
    ASSERT_TRUE(!buf.empty());

    BinDb db2;
    // Need a store to exist before loading index
    // Actually load_index_from_buffer creates stores
    db2.load_index_from_buffer(buf);

    auto s2 = db2.get_store(s.id);
    ASSERT_TRUE(s2.has_value());
    ASSERT_EQ(s2->name, "bufstore");
    ASSERT_EQ(s2->auth_domains.size(), 1u);
    ASSERT_EQ(s2->auth_domains[0].label, "idp1");
    ASSERT_EQ(s2->auth_domains[0].url, "https://auth.test");
    ASSERT_EQ(s2->acls.size(), 1u);
    ASSERT_EQ(s2->acls[0].group_id, "g1");
    ASSERT_TRUE(s2->acls[0].can_read);
    ASSERT_TRUE(!s2->acls[0].can_write);
}

// ===================================================================
// 13. Store buffer roundtrip (with media data)
// ===================================================================

TEST(store_buffer_roundtrip) {
    BinDb db;
    auto s = db.create_store("s1");
    auto a = db.create_album(s.id, "a1");
    auto data = make_data(256, 0xF0);
    auto m = db.add_media(a->id, "f.jpg", "image/jpeg", data);
    ASSERT_TRUE(m.has_value());

    auto buf = db.save_store_to_buffer(s.id);
    ASSERT_TRUE(!buf.empty());

    // Load into another DB that already has the store in its index
    BinDb db2;
    db2.create_store("dummy");  // just to have one store; we'll load into the right one
    // Create the target store explicitly
    auto idx_buf = db.save_index_to_buffer();
    db2.load_index_from_buffer(idx_buf);
    db2.load_store_from_buffer(s.id, buf);

    auto m2 = db2.get_media(m->id);
    ASSERT_TRUE(m2.has_value());
    ASSERT_EQ(m2->size_bytes, 256u);

    auto r = db2.read_media_data(m->id);
    ASSERT_EQ(r.size(), 256u);
    ASSERT_TRUE(r == data);
}

// ===================================================================
// 14. Metadata-only store buffer (no raw data)
// ===================================================================

TEST(metadata_store_buffer) {
    BinDb db;
    auto s = db.create_store("s1");
    auto a = db.create_album(s.id, "a1");
    auto m = db.add_media(a->id, "f.jpg", "image/jpeg", make_data(1000));
    ASSERT_TRUE(m.has_value());

    auto full_buf = db.save_store_to_buffer(s.id);
    auto meta_buf = db.save_store_metadata_to_buffer(s.id);
    ASSERT_TRUE(!meta_buf.empty());
    // Metadata buffer should be smaller (no raw 1000 bytes)
    ASSERT_TRUE(meta_buf.size() < full_buf.size());

    BinDb db2;
    auto idx = db.save_index_to_buffer();
    db2.load_index_from_buffer(idx);
    db2.load_store_metadata_from_buffer(s.id, meta_buf);

    // Media record exists but has no data
    auto m2 = db2.get_media(m->id);
    ASSERT_TRUE(m2.has_value());
    ASSERT_EQ(m2->size_bytes, 1000u);
    // read_media_data returns empty because no raw data loaded
    auto rd = db2.read_media_data(m->id);
    ASSERT_TRUE(rd.empty());
}

// ===================================================================
// 15. MmapRegion move semantics
// ===================================================================

TEST(mmap_region_move) {
    // Default constructed — invalid
    MmapRegion r;
    ASSERT_TRUE(!r.valid());
    ASSERT_EQ(r.size, 0u);

    // Move-construct from default
    MmapRegion r2(std::move(r));
    ASSERT_TRUE(!r2.valid());

    // Open non-existent file
    MmapRegion r3 = MmapRegion::open("/tmp/nonexistent_mediadb_test_file");
    ASSERT_TRUE(!r3.valid());

    // Create a real file and mmap it
    TmpDir tmp;
    auto fpath = tmp.path() / "mmtest.bin";
    {
        std::ofstream out(fpath, std::ios::binary);
        std::vector<uint8_t> data(4096, 0x42);
        out.write(reinterpret_cast<const char*>(data.data()), data.size());
    }

    MmapRegion r4 = MmapRegion::open(fpath);
    ASSERT_TRUE(r4.valid());
    ASSERT_EQ(r4.size, 4096u);
    ASSERT_EQ(r4.data()[0], 0x42);

    // Move-construct
    MmapRegion r5(std::move(r4));
    ASSERT_TRUE(r5.valid());
    ASSERT_TRUE(!r4.valid());  // source invalidated  // NOLINT
    ASSERT_EQ(r5.data()[0], 0x42);

    // Move-assign
    MmapRegion r6;
    r6 = std::move(r5);
    ASSERT_TRUE(r6.valid());
    ASSERT_TRUE(!r5.valid());  // NOLINT
    ASSERT_EQ(r6.size, 4096u);

    // Self-move-assign (should not crash)
    // (skip — UB in standard, just ensure no crash on unmap)
    r6.unmap();
    ASSERT_TRUE(!r6.valid());
}

// ===================================================================
// 16. HttpResponse::json frees json_object (no leak)
// ===================================================================

TEST(http_response_json_ownership) {
    // json_object_new_object allocates; HttpResponse::json calls json_object_put
    json_object* obj = json_object_new_object();
    json_object_object_add(obj, "key", json_object_new_string("value"));

    auto resp = HttpResponse::json(200, obj);
    // After this, obj is freed (json_object_put was called)
    ASSERT_EQ(resp.status, 200);
    ASSERT_TRUE(!resp.body.empty());
    // Verify JSON content
    std::string body_str(resp.body.begin(), resp.body.end());
    ASSERT_TRUE(body_str.find("key") != std::string::npos);
    ASSERT_TRUE(body_str.find("value") != std::string::npos);
}

TEST(http_response_json_null_data) {
    // Passing nullptr json should not crash
    json_object* obj = json_object_new_object();
    // json_object_to_json_string should return valid string
    auto resp = HttpResponse::json(404, obj);
    ASSERT_EQ(resp.status, 404);
}

TEST(http_response_text) {
    auto resp = HttpResponse::text(200, "hello world", "text/plain");
    ASSERT_EQ(resp.status, 200);
    ASSERT_EQ(resp.content_type, "text/plain");
    std::string s(resp.body.begin(), resp.body.end());
    ASSERT_EQ(s, "hello world");
}

// ===================================================================
// 17. Incremental import session (BinDbImportSession)
// ===================================================================

TEST(incremental_import_session) {
    // Build an export buffer first
    BinDb db;
    auto s = db.create_store("s1");
    auto a = db.create_album(s.id, "a1");
    auto data = make_data(1024, 0x99);
    auto m = db.add_media(a->id, "f.jpg", "image/jpeg", data);
    ASSERT_TRUE(m.has_value());

    auto exported = db.export_db_to_buffer();
    ASSERT_TRUE(!exported.empty());

    // Feed to a fresh DB via incremental import in small chunks
    BinDb db2;
    auto session = db2.begin_import();
    ASSERT_TRUE(session != nullptr);

    // Feed in 100-byte chunks
    size_t offset = 0;
    while (offset < exported.size() && !session->done()) {
        size_t chunk = std::min<size_t>(100, exported.size() - offset);
        session->feed(reinterpret_cast<const char*>(exported.data() + offset), chunk);
        offset += chunk;
    }

    ASSERT_TRUE(session->done());
    ASSERT_TRUE(session->ok());

    // Verify imported data
    auto m2 = db2.get_media(m->id);
    ASSERT_TRUE(m2.has_value());
    ASSERT_EQ(m2->size_bytes, 1024u);

    auto rd = db2.read_media_data(m->id);
    ASSERT_EQ(rd.size(), 1024u);
    ASSERT_TRUE(rd == data);
}

TEST(incremental_import_single_feed) {
    // Feed entire buffer at once
    BinDb db;
    auto s = db.create_store("s1");
    auto a = db.create_album(s.id, "a1");
    auto data = make_data(500, 0x77);
    db.add_media(a->id, "v.mp4", "video/mp4", data);

    auto exported = db.export_db_to_buffer();

    BinDb db2;
    auto session = db2.begin_import();
    session->feed(reinterpret_cast<const char*>(exported.data()), exported.size());
    ASSERT_TRUE(session->done());
    ASSERT_TRUE(session->ok());
    ASSERT_EQ(db2.list_stores().size(), 1u);
}

// ===================================================================
// 18. Multiple stores with multiple albums
// ===================================================================

TEST(multiple_stores_and_albums) {
    BinDb db;
    auto s1 = db.create_store("store1");
    auto s2 = db.create_store("store2");
    auto a1 = db.create_album(s1.id, "photos");
    auto a2 = db.create_album(s1.id, "videos");
    auto a3 = db.create_album(s2.id, "music");

    for (int i = 0; i < 10; i++) {
        db.add_media(a1->id, "img" + std::to_string(i) + ".jpg", "image/jpeg",
                     make_data(100 + i * 10, static_cast<uint8_t>(i)));
    }
    for (int i = 0; i < 5; i++) {
        db.add_media(a2->id, "vid" + std::to_string(i) + ".mp4", "video/mp4",
                     make_data(200 + i * 20, static_cast<uint8_t>(i + 50)));
    }
    for (int i = 0; i < 3; i++) {
        db.add_media(a3->id, "song" + std::to_string(i) + ".mp3", "audio/mpeg",
                     make_data(300, static_cast<uint8_t>(i + 100)));
    }

    ASSERT_EQ(db.list_stores().size(), 2u);
    ASSERT_EQ(db.list_albums(s1.id).size(), 2u);
    ASSERT_EQ(db.list_albums(s2.id).size(), 1u);
    ASSERT_EQ(db.list_media(a1->id).size(), 10u);
    ASSERT_EQ(db.list_media(a2->id).size(), 5u);
    ASSERT_EQ(db.list_media(a3->id).size(), 3u);

    // Export and reimport
    auto buf = db.export_db_to_buffer();
    BinDb db2;
    ASSERT_TRUE(db2.import_db_from_buffer(buf));
    ASSERT_EQ(db2.list_stores().size(), 2u);
    ASSERT_EQ(db2.list_media(a1->id).size(), 10u);
    ASSERT_EQ(db2.list_media(a2->id).size(), 5u);
    ASSERT_EQ(db2.list_media(a3->id).size(), 3u);

    // Verify one specific media data integrity
    auto media_list = db.list_media(a1->id);
    for (const auto& m : media_list) {
        auto d1 = db.read_media_data(m.id);
        auto d2 = db2.read_media_data(m.id);
        ASSERT_EQ(d1.size(), d2.size());
        ASSERT_TRUE(d1 == d2);
    }
}

// ===================================================================
// 19. BUG TEST: export media count mismatch with orphaned media_ids
//     (album.media_ids contains an ID not in media_ map)
// ===================================================================

TEST(bug_export_media_count_mismatch) {
    // This test detects a bug: if an album's media_ids vector contains
    // an ID that doesn't exist in the media_ map, export writes
    // media_ids.size() as the count but skips the missing entry.
    // The importer then reads too few items and the stream is corrupted.
    //
    // To trigger this, we'd need to manipulate internal state.
    // We can approximate by: create media, delete it manually from
    // the media_ map (simulating corruption), then export.
    // Since we can't access private members directly, we'll test
    // the export→import roundtrip after delete_media which correctly
    // removes from both maps, verifying the fix works.

    TmpDir tmp;
    BinDb db(tmp.path().string());
    auto s = db.create_store("s1");
    auto a = db.create_album(s.id, "a1");
    auto m1 = db.add_media(a->id, "f1.jpg", "image/jpeg", make_data(100));
    auto m2 = db.add_media(a->id, "f2.jpg", "image/jpeg", make_data(200));
    ASSERT_TRUE(m1.has_value() && m2.has_value());

    // Delete one media (this removes from both album.media_ids and media_)
    db.delete_media(m1->id);

    // Export → import should succeed (tests the count is correct)
    auto buf = db.export_db_to_buffer();
    BinDb db2;
    ASSERT_TRUE(db2.import_db_from_buffer(buf));

    auto media = db2.list_media(a->id);
    ASSERT_EQ(media.size(), 1u);
    ASSERT_EQ(media[0].id, m2->id);

    auto rd = db2.read_media_data(m2->id);
    ASSERT_EQ(rd.size(), 200u);
}

// ===================================================================
// 20. BUG TEST: set_album_public + disk reload preserves media linkage
//     The album record is zeroed and re-appended after all media records.
//     On reload, media records may not find their album if it hasn't
//     been loaded yet.
// ===================================================================

TEST(bug_set_album_public_reload) {
    TmpDir tmp;
    std::string store_id, album_id, m1_id, m2_id;
    auto data1 = make_data(100, 0xA1);
    auto data2 = make_data(200, 0xB2);

    {
        BinDb db(tmp.path().string());
        auto s = db.create_store("s1");
        auto a = db.create_album(s.id, "photos");
        auto m1 = db.add_media(a->id, "a.jpg", "image/jpeg", data1);
        auto m2 = db.add_media(a->id, "b.png", "image/png", data2);
        ASSERT_TRUE(m1.has_value() && m2.has_value());
        store_id = s.id;
        album_id = a->id;
        m1_id = m1->id;
        m2_id = m2->id;

        // Set album public — this zeros old album record and appends new at end
        db.set_album_public(album_id, true);

        // Verify in-memory state is correct
        auto media = db.list_media(album_id);
        ASSERT_EQ(media.size(), 2u);
    }

    // Reload from disk — this is where the bug may appear:
    // The new album record is at the end of the file, AFTER media records.
    // Media records referencing the album can't find it during sequential parsing.
    {
        BinDb db(tmp.path().string());
        auto a = db.get_album(album_id);
        ASSERT_TRUE(a.has_value());
        ASSERT_TRUE(a->is_public);

        // CRITICAL: media must still be linked to the album
        auto media = db.list_media(album_id);
        ASSERT_EQ(media.size(), 2u);

        // Verify data integrity
        auto rd1 = db.read_media_data(m1_id);
        ASSERT_EQ(rd1.size(), 100u);
        ASSERT_TRUE(rd1 == data1);

        auto rd2 = db.read_media_data(m2_id);
        ASSERT_EQ(rd2.size(), 200u);
        ASSERT_TRUE(rd2 == data2);
    }
}

// ===================================================================
// 21. BUG TEST: import in memory_only mode preserves media data
// ===================================================================

TEST(bug_import_memory_only_preserves_data) {
    // Export from a memory-only DB, then import into another memory-only DB.
    // The import should preserve media data in pending_data.
    BinDb src;
    auto s = src.create_store("s1");
    auto a = src.create_album(s.id, "a1");
    auto data = make_data(512, 0x88);
    auto m = src.add_media(a->id, "test.jpg", "image/jpeg", data);
    ASSERT_TRUE(m.has_value());

    auto exported = src.export_db_to_buffer();

    BinDb dst;
    ASSERT_TRUE(dst.import_db_from_buffer(exported));

    // Media record should exist
    auto m2 = dst.get_media(m->id);
    ASSERT_TRUE(m2.has_value());
    ASSERT_EQ(m2->size_bytes, 512u);

    // Media data should be readable
    auto rd = dst.read_media_data(m->id);
    ASSERT_EQ(rd.size(), 512u);
    ASSERT_TRUE(rd == data);
}

// ===================================================================
// 22. Disk: zero_record + vacuum reclaims space
// ===================================================================

TEST(disk_zero_record_vacuum_size) {
    TmpDir tmp;
    BinDb db(tmp.path().string());
    auto s = db.create_store("s1");
    auto a = db.create_album(s.id, "a1");
    auto m1 = db.add_media(a->id, "big.bin", "application/octet-stream", make_data(10000));
    auto m2 = db.add_media(a->id, "small.bin", "application/octet-stream", make_data(100));
    ASSERT_TRUE(m1.has_value() && m2.has_value());

    auto sf = db.store_file(s.id);
    auto size_before = fs::file_size(sf);

    // Delete the big file
    db.delete_media(m1->id);
    // File size unchanged (just zeroed the record type)
    ASSERT_EQ(fs::file_size(sf), size_before);

    // Vacuum
    db.vacuum_store(s.id);
    auto size_after = fs::file_size(sf);
    // File should be smaller (big file's 10000 bytes reclaimed)
    ASSERT_TRUE(size_after < size_before);
    ASSERT_TRUE(size_after < size_before - 9000);  // at least 9000 bytes freed

    // Remaining data intact
    auto rd = db.read_media_data(m2->id);
    ASSERT_EQ(rd.size(), 100u);
}

// ===================================================================
// 23. Large data integrity: write and readback big blob
// ===================================================================

TEST(large_data_integrity) {
    TmpDir tmp;
    BinDb db(tmp.path().string());
    auto s = db.create_store("s1");
    auto a = db.create_album(s.id, "a1");

    // 2 MB blob
    size_t sz = 2 * 1024 * 1024;
    auto data = make_data(sz, 0x42);
    auto m = db.add_media(a->id, "large.bin", "application/octet-stream", data);
    ASSERT_TRUE(m.has_value());
    ASSERT_EQ(m->size_bytes, sz);

    auto rd = db.read_media_data(m->id);
    ASSERT_EQ(rd.size(), sz);
    ASSERT_TRUE(rd == data);

    // Range read across chunk boundary
    auto range = db.read_media_data_range(m->id, 1024 * 1024 - 50, 100);
    ASSERT_EQ(range.size(), 100u);
    for (size_t i = 0; i < 100; i++) {
        ASSERT_EQ(range[i], data[1024 * 1024 - 50 + i]);
    }
}

// ===================================================================
// 24. Incremental import with multiple stores
// ===================================================================

TEST(incremental_import_multi_store) {
    BinDb src;
    auto s1 = src.create_store("store_a");
    auto s2 = src.create_store("store_b");
    auto a1 = src.create_album(s1.id, "a1");
    auto a2 = src.create_album(s2.id, "a2");
    src.add_media(a1->id, "f1.jpg", "image/jpeg", make_data(100, 0x11));
    src.add_media(a2->id, "f2.jpg", "image/jpeg", make_data(200, 0x22));

    auto exported = src.export_db_to_buffer();

    BinDb dst;
    auto session = dst.begin_import();
    // Feed byte by byte (stress test)
    for (size_t i = 0; i < exported.size(); i++) {
        session->feed(reinterpret_cast<const char*>(&exported[i]), 1);
        if (session->done()) break;
    }
    ASSERT_TRUE(session->done());
    ASSERT_TRUE(session->ok());
    ASSERT_EQ(dst.list_stores().size(), 2u);
    ASSERT_EQ(dst.list_media(a1->id).size(), 1u);
    ASSERT_EQ(dst.list_media(a2->id).size(), 1u);
}

// ===================================================================
// 25. Empty database operations
// ===================================================================

TEST(empty_db_operations) {
    BinDb db;

    // No stores
    ASSERT_TRUE(db.list_stores().empty());
    ASSERT_TRUE(!db.get_store("no-id").has_value());
    ASSERT_TRUE(!db.delete_store("no-id"));
    ASSERT_TRUE(!db.rename_store("no-id", "x"));

    // No albums
    ASSERT_TRUE(!db.get_album("no-id").has_value());
    ASSERT_TRUE(!db.delete_album("no-id"));
    ASSERT_TRUE(db.list_albums("no-store").empty());

    // No media
    ASSERT_TRUE(!db.get_media("no-id").has_value());
    ASSERT_TRUE(!db.delete_media("no-id"));
    ASSERT_TRUE(db.read_media_data("no-id").empty());
    ASSERT_EQ(db.get_media_size("no-id"), 0u);

    // Export empty DB
    auto buf = db.export_db_to_buffer();
    ASSERT_TRUE(!buf.empty());  // Should have at least magic + store count

    // Import empty export into empty DB
    BinDb db2;
    ASSERT_TRUE(db2.import_db_from_buffer(buf));
    ASSERT_TRUE(db2.list_stores().empty());
}

// ===================================================================
// 26. media_metadata_only (cluster mode helper)
// ===================================================================

TEST(add_media_metadata) {
    BinDb db;
    auto s = db.create_store("s1");
    auto a = db.create_album(s.id, "a1");

    auto m = db.add_media_metadata(a->id, "remote.mp4", "video/mp4", 5000000);
    ASSERT_TRUE(m.has_value());
    ASSERT_EQ(m->size_bytes, 5000000u);
    ASSERT_EQ(m->media_kind, "video");
    ASSERT_TRUE(m->original_filename == "remote.mp4");

    // No data locally
    auto rd = db.read_media_data(m->id);
    ASSERT_TRUE(rd.empty());

    ASSERT_EQ(db.get_media_size(m->id), 5000000u);
}

// ===================================================================
// 27. Disk: multiple set_album_public calls + reload
// ===================================================================

TEST(multiple_set_public_reload) {
    TmpDir tmp;
    std::string sid, aid, mid;
    auto data = make_data(50, 0xDE);

    {
        BinDb db(tmp.path().string());
        auto s = db.create_store("s1");
        auto a = db.create_album(s.id, "a1");
        auto m = db.add_media(a->id, "f.jpg", "image/jpeg", data);
        sid = s.id; aid = a->id; mid = m->id;

        // Toggle public multiple times (each zeros old album, appends new)
        db.set_album_public(aid, true);
        db.set_album_public(aid, false);
        db.set_album_public(aid, true);
    }

    {
        BinDb db(tmp.path().string());
        auto a = db.get_album(aid);
        ASSERT_TRUE(a.has_value());
        ASSERT_TRUE(a->is_public);

        auto media = db.list_media(aid);
        ASSERT_EQ(media.size(), 1u);

        auto rd = db.read_media_data(mid);
        ASSERT_EQ(rd.size(), 50u);
        ASSERT_TRUE(rd == data);
    }
}

// ===================================================================
// 28. Idempotent import (re-import same data)
// ===================================================================

TEST(idempotent_import) {
    BinDb db;
    auto s = db.create_store("s1");
    auto a = db.create_album(s.id, "a1");
    auto data = make_data(300, 0x44);
    auto m = db.add_media(a->id, "f.jpg", "image/jpeg", data);

    auto exported = db.export_db_to_buffer();

    // Import same buffer again — should be idempotent (existing records skipped)
    ASSERT_TRUE(db.import_db_from_buffer(exported));

    // Should still have exactly 1 store, 1 album, 1 media
    ASSERT_EQ(db.list_stores().size(), 1u);
    auto albums = db.list_albums(s.id);
    ASSERT_EQ(albums.size(), 1u);
    auto media = db.list_media(a->id);
    ASSERT_EQ(media.size(), 1u);
}

// ===================================================================
// 29. Reload preserves index with auth domains and ACLs
// ===================================================================

TEST(disk_reload_index_with_domains_acls) {
    TmpDir tmp;
    std::string sid;

    {
        BinDb db(tmp.path().string());
        auto s = db.create_store("indexed");
        sid = s.id;

        AuthDomain d;
        d.label = "oidc";
        d.url = "https://oidc.test";
        d.client_name = "cn";
        d.client_secret = "cs";
        db.add_store_domain(sid, d);

        StoreACL acl;
        acl.group_id = "admin-group";
        acl.group_name = "admins";
        acl.can_read = true;
        acl.can_write = true;
        db.add_store_acl(sid, acl);
    }

    {
        BinDb db(tmp.path().string());
        auto s = db.get_store(sid);
        ASSERT_TRUE(s.has_value());
        ASSERT_EQ(s->name, "indexed");
        ASSERT_EQ(s->auth_domains.size(), 1u);
        ASSERT_EQ(s->auth_domains[0].label, "oidc");
        ASSERT_EQ(s->acls.size(), 1u);
        ASSERT_EQ(s->acls[0].group_name, "admins");
        ASSERT_TRUE(s->acls[0].can_read);
        ASSERT_TRUE(s->acls[0].can_write);
    }
}

// ===================================================================
// 30. Zero-length media
// ===================================================================

TEST(zero_length_media) {
    BinDb db;
    auto s = db.create_store("s1");
    auto a = db.create_album(s.id, "a1");
    auto m = db.add_media(a->id, "empty.txt", "text/plain", {});
    ASSERT_TRUE(m.has_value());
    ASSERT_EQ(m->size_bytes, 0u);
    ASSERT_TRUE(db.read_media_data(m->id).empty());
    ASSERT_EQ(db.get_media_size(m->id), 0u);

    // Export/import with zero-length media
    auto buf = db.export_db_to_buffer();
    BinDb db2;
    ASSERT_TRUE(db2.import_db_from_buffer(buf));
    ASSERT_EQ(db2.get_media_size(m->id), 0u);
}

// ===================================================================
// main
// ===================================================================

int main() {
    std::cout << "Running mediadb unit tests...\n\n";

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

    std::cout << "\n========================================\n";
    std::cout << "Tests: " << g_tests_run
              << "  Passed: " << g_tests_passed
              << "  Failed: " << g_tests_failed << "\n";
    std::cout << "========================================\n";

    return g_tests_failed > 0 ? 1 : 0;
}
