// Unit tests for gallery (de)serialisation: HDF5 round-trip fidelity (the only
// format save_gallery writes), legacy JSON read back-compat (optional field
// defaults, the legacy "jellyfin_person_id" fallback), and the GR-004 embedder
// stamp. GPU-free, model-free — the stamp tests exercise the comparison logic
// with synthetic stamps and never load an ONNX, so they run on CI's Intel N100.
#include <catch2/catch_test_macros.hpp>

#include "gallery/embedder_stamp.hpp"
#include "gallery/gallery_store.hpp"
#include "types.hpp"

#include <cstdio>
#include <fstream>
#include <stdexcept>
#include <string>

#include <nlohmann/json.hpp>

namespace {

struct TempFile {
    std::string path;
    explicit TempFile(const std::string& name)
        : path(std::string(std::tmpnam(nullptr)) + name) {}
    ~TempFile() { std::remove(path.c_str()); }
};

Embedding make_embedding(float base) {
    Embedding e{};
    for (int i = 0; i < 512; ++i) e[i] = base + i * 1e-4f;
    return e;
}

// A stamp built by hand — no ONNX is read, so these tests never need a model.
EmbedderStamp stamp(const std::string& name, const std::string& sha, int32_t dim = 512) {
    EmbedderStamp s;
    s.model_name = name;
    s.model_sha256 = sha;
    s.embed_dim = dim;
    return s;
}

const std::string kShaA(64, 'a');
const std::string kShaB(64, 'b');

bool mentions(const std::string& haystack, const std::string& needle) {
    return haystack.find(needle) != std::string::npos;
}

}  // namespace

TEST_CASE("gallery save/load round-trips actors and embeddings", "[gallery]") {
    ActorGallery g;
    ActorGallery::Actor a;
    a.imdb_id = "nm0000093";
    a.tmdb_id = "287";
    a.jellyfin_id = "guid-abc";
    a.name = "Brad Pitt";
    a.source_images = {"img1.jpg", "img2.jpg"};
    a.embeddings = {make_embedding(0.1f), make_embedding(-0.2f)};
    g.actors.push_back(a);

    ActorGallery::Actor b;
    b.name = "Edward Norton";
    b.embeddings = {make_embedding(0.5f)};
    g.actors.push_back(b);

    // save_gallery always writes HDF5 (see gallery_store.cpp); use a .h5 path
    // directly rather than relying on the "wrong extension gets rewritten"
    // fallback, which is a compatibility shim, not the intended usage.
    TempFile tf("gallery.h5");
    save_gallery(tf.path, g);
    ActorGallery loaded = load_gallery(tf.path);

    REQUIRE(loaded.actors.size() == 2);

    const auto& la = loaded.actors[0];
    CHECK(la.imdb_id == "nm0000093");
    CHECK(la.tmdb_id == "287");
    CHECK(la.jellyfin_id == "guid-abc");
    CHECK(la.name == "Brad Pitt");
    CHECK(la.source_images == std::vector<std::string>{"img1.jpg", "img2.jpg"});
    REQUIRE(la.embeddings.size() == 2);
    for (int i = 0; i < 512; ++i) {
        CHECK(la.embeddings[0][i] == a.embeddings[0][i]);
        CHECK(la.embeddings[1][i] == a.embeddings[1][i]);
    }

    CHECK(loaded.actors[1].name == "Edward Norton");
    CHECK(loaded.actors[1].embeddings.size() == 1);
}

TEST_CASE("gallery save/load round-trips calibration", "[gallery]") {
    ActorGallery g;
    ActorGallery::Actor a;
    a.name = "Solo Actor";
    a.embeddings = {make_embedding(0.1f)};
    g.actors.push_back(a);
    g.calib_a = 12.5f;
    g.calib_b = -3.25f;
    g.calib_valid = true;
    g.calib_hash = 0xDEADBEEFULL;

    TempFile tf("gallery_calib.h5");
    save_gallery(tf.path, g);
    ActorGallery loaded = load_gallery(tf.path);

    CHECK(loaded.calib_a == g.calib_a);
    CHECK(loaded.calib_b == g.calib_b);
    CHECK(loaded.calib_valid == g.calib_valid);
    CHECK(loaded.calib_hash == g.calib_hash);
}

TEST_CASE("gallery with no calibration loads with calib_hash 0", "[gallery]") {
    ActorGallery g;
    ActorGallery::Actor a;
    a.name = "No Calibration";
    a.embeddings = {make_embedding(0.f)};
    g.actors.push_back(a);
    // calib_hash left at its default (0) — save_gallery skips writing the
    // /calibration group entirely when there's nothing to persist yet.

    TempFile tf("gallery_nocalib.h5");
    save_gallery(tf.path, g);
    ActorGallery loaded = load_gallery(tf.path);

    CHECK(loaded.calib_hash == 0);
    CHECK_FALSE(loaded.calib_valid);
}

TEST_CASE("load_gallery defaults optional string fields to empty", "[gallery]") {
    // Minimal actor: only the required name + embeddings.
    nlohmann::json j;
    j["actors"] = nlohmann::json::array();
    nlohmann::json ja;
    ja["name"] = "Minimal";
    ja["embeddings"] = nlohmann::json::array();
    ja["embeddings"].push_back(std::vector<float>(512, 0.25f));
    j["actors"].push_back(ja);

    TempFile tf("gallery_min.json");
    { std::ofstream out(tf.path); out << j.dump(); }

    ActorGallery g = load_gallery(tf.path);
    REQUIRE(g.actors.size() == 1);
    CHECK(g.actors[0].name == "Minimal");
    CHECK(g.actors[0].imdb_id.empty());
    CHECK(g.actors[0].tmdb_id.empty());
    CHECK(g.actors[0].jellyfin_id.empty());
    CHECK(g.actors[0].source_images.empty());
    REQUIRE(g.actors[0].embeddings.size() == 1);
    CHECK(g.actors[0].embeddings[0][0] == 0.25f);
}

TEST_CASE("load_gallery reads the legacy jellyfin_person_id key", "[gallery]") {
    nlohmann::json j;
    j["actors"] = nlohmann::json::array();
    nlohmann::json ja;
    ja["name"] = "Legacy";
    ja["jellyfin_person_id"] = "old-guid";  // pre-rename key
    ja["embeddings"] = nlohmann::json::array();
    ja["embeddings"].push_back(std::vector<float>(512, 0.f));
    j["actors"].push_back(ja);

    TempFile tf("gallery_legacy.json");
    { std::ofstream out(tf.path); out << j.dump(); }

    ActorGallery g = load_gallery(tf.path);
    REQUIRE(g.actors.size() == 1);
    CHECK(g.actors[0].jellyfin_id == "old-guid");
}

TEST_CASE("load_gallery throws on a missing file", "[gallery]") {
    CHECK_THROWS(load_gallery("/nonexistent/path/gallery.json"));
}

// ── GR-004: gallery ↔ embedder binding ───────────────────────────────────────
// Verification plan row GR-004/T1: "Mismatched embedder → hard startup error;
// error names both sides." The comparison is a pure function over two stamps, so
// none of this needs a GPU, an ONNX, or even a file.

/// TRACES: GR-004 | SR-001
TEST_CASE("gallery save/load round-trips the embedder stamp", "[gallery][GR-004]") {
    ActorGallery g;
    ActorGallery::Actor a;
    a.name = "Stamped Actor";
    a.embeddings = {make_embedding(0.3f)};
    g.actors.push_back(a);
    g.embedder = stamp("LVFace-B_Glint360K.onnx", kShaA);

    TempFile tf("gallery_stamped.h5");
    save_gallery(tf.path, g);
    ActorGallery loaded = load_gallery(tf.path);

    CHECK(loaded.embedder.model_name == "LVFace-B_Glint360K.onnx");
    CHECK(loaded.embedder.model_sha256 == kShaA);
    CHECK(loaded.embedder.embed_dim == 512);
    CHECK_FALSE(loaded.embedder.empty());
}

/// TRACES: GR-004 | SR-001
TEST_CASE("a gallery written without a stamp loads as unstamped", "[gallery][GR-004]") {
    // The back-compat case: pre-GR-004 files have no /embedder group at all. The
    // absence must survive the round trip as an absence — a stamp naming no model
    // would read as "checked and fine" to every consumer.
    ActorGallery g;
    ActorGallery::Actor a;
    a.name = "Legacy Actor";
    a.embeddings = {make_embedding(0.f)};
    g.actors.push_back(a);

    TempFile tf("gallery_unstamped.h5");
    save_gallery(tf.path, g);
    ActorGallery loaded = load_gallery(tf.path);

    CHECK(loaded.embedder.empty());
}

/// TRACES: GR-004 | SR-001
TEST_CASE("legacy JSON galleries carry an optional embedder stamp", "[gallery][GR-004]") {
    nlohmann::json j;
    j["embedder"] = {{"model_name", "arcface_w600k_r50.onnx"},
                     {"model_sha256", kShaB},
                     {"embed_dim", 512}};
    j["actors"] = nlohmann::json::array();
    nlohmann::json ja;
    ja["name"] = "JSON Actor";
    ja["embeddings"] = nlohmann::json::array();
    ja["embeddings"].push_back(std::vector<float>(512, 0.1f));
    j["actors"].push_back(ja);

    TempFile tf("gallery_json_stamp.json");
    { std::ofstream out(tf.path); out << j.dump(); }

    ActorGallery g = load_gallery(tf.path);
    CHECK(g.embedder.model_name == "arcface_w600k_r50.onnx");
    CHECK(g.embedder.model_sha256 == kShaB);
}

/// TRACES: GR-004 | SR-001
TEST_CASE("matching embedder stamps pass", "[gallery][GR-004]") {
    auto chk = compare_embedder_stamps(stamp("model.onnx", kShaA),
                                       stamp("model.onnx", kShaA));
    CHECK(chk.verdict == StampVerdict::match);
    CHECK_FALSE(chk.fatal(false));
    CHECK_FALSE(chk.fatal(true));   // a proven match is never fatal, even in strict mode
    CHECK_NOTHROW(enforce_embedder_stamp(stamp("model.onnx", kShaA),
                                         stamp("model.onnx", kShaA),
                                         "g.h5", "model.onnx", true));
}

/// TRACES: GR-004 | SR-001
TEST_CASE("the hash decides, not the filename", "[gallery][GR-004]") {
    // Same bytes under a different filename is the SAME model — a renamed or
    // relocated file must not be treated as a different one.
    auto same = compare_embedder_stamps(stamp("lvface.onnx", kShaA),
                                        stamp("LVFace-B_Glint360K.onnx", kShaA));
    CHECK(same.verdict == StampVerdict::match);

    // Different bytes under the SAME filename is a DIFFERENT model — this is the
    // in-place re-export a name-only stamp would miss entirely, and the reason the
    // stamp carries a hash at all.
    auto differ = compare_embedder_stamps(stamp("model.onnx", kShaA),
                                          stamp("model.onnx", kShaB));
    CHECK(differ.verdict == StampVerdict::mismatch);
}

/// TRACES: GR-004 | SR-001
TEST_CASE("mismatched embedder is fatal and names both sides", "[gallery][GR-004]") {
    const auto built  = stamp("LVFace-B_Glint360K.onnx", kShaA);
    const auto loaded = stamp("arcface_w600k_r50.onnx", kShaB);

    auto chk = compare_embedder_stamps(built, loaded, "cast.h5", "models/r50.onnx");
    REQUIRE(chk.verdict == StampVerdict::mismatch);
    CHECK(chk.fatal(false));   // no bypass: a mismatch is fatal in every mode
    CHECK(chk.fatal(true));

    // Both sides must be identifiable from the message alone.
    CHECK(mentions(chk.message, "LVFace-B_Glint360K.onnx"));
    CHECK(mentions(chk.message, "arcface_w600k_r50.onnx"));
    CHECK(mentions(chk.message, kShaA));
    CHECK(mentions(chk.message, kShaB));
    CHECK(mentions(chk.message, "cast.h5"));
    CHECK(mentions(chk.message, "models/r50.onnx"));

    // ...and it must reach the caller as an error, not a log line.
    CHECK_THROWS_AS(enforce_embedder_stamp(built, loaded, "cast.h5",
                                           "models/r50.onnx", false),
                    std::runtime_error);
    try {
        enforce_embedder_stamp(built, loaded, "cast.h5", "models/r50.onnx", false);
        FAIL("mismatch must throw");
    } catch (const std::runtime_error& e) {
        const std::string what = e.what();
        CHECK(mentions(what, "LVFace-B_Glint360K.onnx"));
        CHECK(mentions(what, "arcface_w600k_r50.onnx"));
    }
}

/// TRACES: GR-004 | SR-001
TEST_CASE("differing embedding width is a mismatch", "[gallery][GR-004]") {
    auto chk = compare_embedder_stamps(stamp("a.onnx", kShaA, 512),
                                       stamp("a.onnx", kShaA, 256));
    CHECK(chk.verdict == StampVerdict::mismatch);
    CHECK(mentions(chk.message, "512"));
    CHECK(mentions(chk.message, "256"));
}

/// TRACES: GR-004 | SR-001
TEST_CASE("an unstamped gallery warns by default and fails under strict",
          "[gallery][GR-004]") {
    // Decision recorded in src/gallery/embedder_stamp.hpp: unstamped is UNKNOWN,
    // not known-bad, and every pre-GR-004 gallery is unstamped. Hard-failing them
    // all would make the check something people disable rather than trust; so it
    // warns loudly, names the risk, and is promotable to fatal for measurement runs.
    EmbedderStamp none;
    auto chk = compare_embedder_stamps(none, stamp("model.onnx", kShaA), "old.h5");
    REQUIRE(chk.verdict == StampVerdict::unstamped);
    CHECK_FALSE(chk.fatal(false));
    CHECK(chk.fatal(true));

    CHECK(mentions(chk.message, "old.h5"));
    CHECK(mentions(chk.message, "model.onnx"));   // the loaded side is still named
    CHECK(mentions(chk.message, "UNKNOWN"));      // ...and the gallery side is honest

    CHECK_NOTHROW(enforce_embedder_stamp(none, stamp("model.onnx", kShaA),
                                         "old.h5", "model.onnx", false));
    CHECK_THROWS_AS(enforce_embedder_stamp(none, stamp("model.onnx", kShaA),
                                           "old.h5", "model.onnx", true),
                    std::runtime_error);
}

/// TRACES: GR-004 | SR-001
TEST_CASE("an unidentifiable embedder against a stamped gallery is not silent",
          "[gallery][GR-004]") {
    // e.g. a replay whose dump predates GR-004: we know what built the gallery but
    // not what produced the vectors being fed in. Unverifiable, so it must not
    // report success.
    auto chk = compare_embedder_stamps(stamp("model.onnx", kShaA), EmbedderStamp{},
                                       "g.h5", "old dump.h5");
    CHECK(chk.verdict == StampVerdict::unknown_embedder);
    CHECK_FALSE(chk.fatal(false));
    CHECK(chk.fatal(true));
    CHECK(mentions(chk.message, "model.onnx"));
    CHECK(mentions(chk.message, "old dump.h5"));
}

/// TRACES: GR-004 | SR-001
TEST_CASE("name-only agreement is a weak match, not a clean pass", "[gallery][GR-004]") {
    // A TRT deployment can run from a prebuilt .engine with the .onnx absent, so
    // no hash is computable. Names agreeing is evidence, not proof.
    auto weak = compare_embedder_stamps(stamp("model.onnx", kShaA),
                                        stamp("model.onnx", ""));
    CHECK(weak.verdict == StampVerdict::weak_match);
    CHECK_FALSE(weak.fatal(false));
    CHECK(weak.fatal(true));

    // Names disagreeing with no hash available is still a mismatch — the weaker
    // evidence is enough to convict, just not to acquit.
    auto bad = compare_embedder_stamps(stamp("lvface.onnx", ""),
                                       stamp("arcface.onnx", ""));
    CHECK(bad.verdict == StampVerdict::mismatch);
    CHECK(mentions(bad.message, "lvface.onnx"));
    CHECK(mentions(bad.message, "arcface.onnx"));
}

/// TRACES: GR-004 | SR-001
TEST_CASE("sha256 matches the published vectors", "[gallery][GR-004]") {
    // Pins the in-tree FIPS 180-4 implementation against the standard vectors.
    // This is what guarantees the C++ stamp and the Python (hashlib) stamp in
    // scripts/sae_gallery.py agree on the same model file — without it the two
    // halves of GR-004 could silently diverge and every check would be a mismatch.
    CHECK(sha256_hex("") ==
          "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
    CHECK(sha256_hex("abc") ==
          "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
    CHECK(sha256_hex("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq") ==
          "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1");
    // Multi-block input, exercising the length-padding path past 64 bytes.
    CHECK(sha256_hex(std::string(1000, 'a')) ==
          "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3");
}

/// TRACES: GR-004 | SR-001
TEST_CASE("make_embedder_stamp hashes a real file and degrades gracefully",
          "[gallery][GR-004]") {
    // Stands in for an ONNX: the stamp does not care what the bytes mean.
    TempFile tf("fake_model.onnx");
    { std::ofstream out(tf.path, std::ios::binary); out << "abc"; }

    EmbedderStamp s = make_embedder_stamp(tf.path);
    CHECK(s.model_sha256 ==
          "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
    CHECK_FALSE(s.model_name.empty());
    CHECK(s.model_name.find('/') == std::string::npos);   // basename, not full path

    // A model path that does not exist still yields a comparable name-only stamp
    // rather than an empty one, which is what keeps engine-only deployments usable.
    EmbedderStamp missing = make_embedder_stamp("/nonexistent/models/foo.onnx");
    CHECK(missing.model_name == "foo.onnx");
    CHECK(missing.model_sha256.empty());
    CHECK_FALSE(missing.empty());

    CHECK(make_embedder_stamp("").empty());
}
