// TRACES: AR-023 | SR-002
//
// Unit tests for gallery calibration: the sigmoid math, the pairwise fit on
// separable data, and the in-memory hash-keyed cache (hit / stale / cold).
// All pure, GPU-free, model-free.
//
// The three `[report]` cases at the bottom carry their own GR-003 tags: they
// verify the build report, which is fitted from the same distributions but is a
// separate requirement.
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>

#include "gallery/gallery_calibration.hpp"
#include "face_utils.hpp"   // l2_normalise
#include "types.hpp"

#include <array>
#include <random>
#include <string>
#include <vector>

using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;

namespace {

// n embeddings for `n_actors` actors, each a tight cluster around a random
// per-actor centre — cleanly separable, so calibration should converge.
void make_separable_gallery(int n_actors, int per_actor,
                            std::vector<Embedding>& emb,
                            std::vector<int>& actor) {
    std::mt19937 rng(1234);
    std::normal_distribution<float> centre(0.f, 1.f);
    std::normal_distribution<float> jitter(0.f, 0.01f);
    for (int a = 0; a < n_actors; ++a) {
        std::array<float, 512> c{};
        for (float& v : c) v = centre(rng);
        for (int k = 0; k < per_actor; ++k) {
            std::array<float, 512> raw{};
            for (int d = 0; d < 512; ++d) raw[d] = c[d] + jitter(rng);
            emb.push_back(l2_normalise(raw.data()));
            actor.push_back(a);
        }
    }
}

}  // namespace

TEST_CASE("GalleryCalibration probability is monotonic and bounded", "[calibration]") {
    GalleryCalibration cal{10.f, -5.f, true};
    float lo = cal.probability(-1.f);
    float mid = cal.probability(0.5f);   // boundary is at sim = -b/a = 0.5
    float hi = cal.probability(1.f);
    CHECK(lo >= 0.f);
    CHECK(hi <= 1.f);
    CHECK(lo < mid);
    CHECK(mid < hi);
    CHECK_THAT(mid, WithinAbs(0.5f, 1e-5f));  // σ(0) = 0.5 at the boundary
}

TEST_CASE("boundary_at inverts probability", "[calibration]") {
    GalleryCalibration cal{8.f, -3.f, true};
    for (float p : {0.1f, 0.5f, 0.9f}) {
        float sim = cal.boundary_at(p);
        CHECK_THAT(cal.probability(sim), WithinAbs(p, 1e-5f));
    }
}

TEST_CASE("prior odds shift the decision boundary", "[calibration]") {
    GalleryCalibration cal{10.f, -5.f, true};
    // A positive prior (match more likely) should raise P at a fixed similarity.
    float base = cal.probability(0.5f);
    float raised = cal.probability(0.5f, /*log_prior_odds=*/2.f);
    CHECK(raised > base);
    // and correspondingly lower the similarity needed to reach P=0.5.
    CHECK(cal.boundary_at(0.5f, 2.f) < cal.boundary_at(0.5f));
}

TEST_CASE("calibrate_gallery fits separable data", "[calibration]") {
    std::vector<Embedding> emb;
    std::vector<int> actor;
    make_separable_gallery(/*n_actors=*/6, /*per_actor=*/8, emb, actor);

    GalleryCalibration cal = calibrate_gallery(emb, actor);
    REQUIRE(cal.valid);
    CHECK(cal.a > 0.f);                       // higher sim → higher P
    // Same-actor pairs sit near sim≈1, cross-actor near 0 → boundary between.
    float boundary = cal.boundary_at(0.5f);
    CHECK(boundary > 0.f);
    CHECK(boundary < 1.f);
}

TEST_CASE("calibrate_gallery returns invalid on too few pairs", "[calibration]") {
    // One actor, one embedding: no negative pairs, no positive pairs.
    std::vector<Embedding> emb(1);
    emb[0] = l2_normalise(std::array<float, 512>{1.f}.data());
    std::vector<int> actor{0};
    GalleryCalibration cal = calibrate_gallery(emb, actor);
    CHECK_FALSE(cal.valid);
}

TEST_CASE("hash_gallery_embeddings is sensitive to changes", "[calibration]") {
    std::vector<Embedding> emb;
    std::vector<int> actor;
    make_separable_gallery(3, 4, emb, actor);

    uint64_t h0 = hash_gallery_embeddings(emb, actor);
    CHECK(hash_gallery_embeddings(emb, actor) == h0);  // stable

    auto emb2 = emb;
    emb2[0][0] += 1e-3f;
    CHECK(hash_gallery_embeddings(emb2, actor) != h0);  // embedding change

    auto actor2 = actor;
    actor2.back() = 99;
    CHECK(hash_gallery_embeddings(emb, actor2) != h0);  // assignment change
}

TEST_CASE("calibrate_gallery_cached reuses matching in-memory calibration", "[calibration]") {
    std::vector<Embedding> emb;
    std::vector<int> actor;
    make_separable_gallery(5, 6, emb, actor);

    bool recomputed = false;
    GalleryCalibration first = calibrate_gallery_cached(
        emb, actor, /*cached_a=*/0.f, /*cached_b=*/0.f, /*cached_valid=*/false,
        /*cached_hash=*/0, /*curve_base_path=*/"", recomputed);
    REQUIRE(first.valid);
    CHECK(recomputed);  // no prior cache (hash=0) → always recomputes

    uint64_t hash = hash_gallery_embeddings(emb, actor);

    // Second call, passing back the just-fitted params + matching hash, must
    // NOT recompute and must return the identical fitted params.
    recomputed = false;
    GalleryCalibration second = calibrate_gallery_cached(
        emb, actor, first.a, first.b, first.valid, hash, "", recomputed);
    CHECK_FALSE(recomputed);
    CHECK_THAT(second.a, WithinRel(first.a, 1e-6f));
    CHECK_THAT(second.b, WithinRel(first.b, 1e-6f));
    CHECK(second.valid == first.valid);
}

TEST_CASE("calibrate_gallery_cached recomputes when the gallery changes", "[calibration]") {
    std::vector<Embedding> emb;
    std::vector<int> actor;
    make_separable_gallery(5, 6, emb, actor);

    bool recomputed = false;
    GalleryCalibration first = calibrate_gallery_cached(
        emb, actor, 0.f, 0.f, false, 0, "", recomputed);
    uint64_t stale_hash = hash_gallery_embeddings(emb, actor);

    // Mutate the gallery → hash no longer matches → must recompute.
    emb.push_back(emb.front());
    actor.push_back(actor.front());
    recomputed = false;
    GalleryCalibration second = calibrate_gallery_cached(
        emb, actor, first.a, first.b, first.valid, stale_hash, "", recomputed);
    CHECK(recomputed);
    CHECK(hash_gallery_embeddings(emb, actor) != stale_hash);
}

TEST_CASE("calibrate_gallery_cached treats hash=0 as always-recompute", "[calibration]") {
    std::vector<Embedding> emb;
    std::vector<int> actor;
    make_separable_gallery(4, 5, emb, actor);

    // hash=0 is the "no cached calibration" sentinel (ActorGallery::calib_hash
    // default) — must fit fresh rather than treat 0 as a real cached hash.
    bool recomputed = false;
    GalleryCalibration cal = calibrate_gallery_cached(
        emb, actor, 0.f, 0.f, false, /*cached_hash=*/0, "", recomputed);
    CHECK(recomputed);
    CHECK(cal.valid);
}

// ── GR-003 — the build report ────────────────────────────────────────────────
#include "gallery/gallery_report.hpp"

namespace {
// A unit vector on one axis. Distinct axes are orthogonal, which is unrealistic
// as a same-actor cluster but irrelevant here: these tests count actors, they do
// not assess fit quality.
Embedding unit_axis(int slot) {
    Embedding e{};
    e[slot % 512] = 1.0f;
    return e;
}
}  // namespace

// TRACES: GR-003 | SR-001
TEST_CASE("report surfaces actors that can never be recognised", "[report][GR-003]") {
    // An actor with no usable image is a silent recall ceiling: the pipeline
    // will never name them, and nothing in the gallery says why. This is the
    // single most useful number in the report.
    ActorGallery g;
    for (int a = 0; a < 3; ++a) {
        ActorGallery::Actor act;
        act.name = "actor" + std::to_string(a);
        if (a != 1)                       // actor1 gets nothing
            for (int i = 0; i < 6; ++i) act.embeddings.push_back(unit_axis(a * 10 + i));
        g.actors.push_back(std::move(act));
    }

    std::vector<Embedding> flat;
    std::vector<int>       flat_actor;
    for (int a = 0; a < static_cast<int>(g.actors.size()); ++a)
        for (const auto& e : g.actors[a].embeddings) { flat.push_back(e); flat_actor.push_back(a); }

    GalleryCalibrationStats stats;
    GalleryCalibration cal = calibrate_gallery(flat, flat_actor, &stats);
    GalleryReport r = build_gallery_report(g, cal, stats);

    // An actor present in the gallery with no embeddings is counted as
    // in-gallery but contributes nothing; the zero-usable list is populated
    // from the build audit, which a stored gallery cannot supply.
    CHECK(r.actors_in_gallery == 3);
    CHECK(r.actors[1].references == 0);
}

// TRACES: GR-003 | SR-001
TEST_CASE("report surfaces actors too thin to calibrate on", "[report][GR-003]") {
    // Below the positive-pair threshold an actor contributes nothing to the
    // intra-class side of the fit. They are not broken, so nothing complains —
    // they just quietly weaken every threshold downstream.
    ActorGallery g;
    for (int a = 0; a < 2; ++a) {
        ActorGallery::Actor act;
        act.name = "actor" + std::to_string(a);
        const int n = (a == 0) ? 6 : 2;   // actor1 is under-referenced
        for (int i = 0; i < n; ++i) act.embeddings.push_back(unit_axis(a * 10 + i));
        g.actors.push_back(std::move(act));
    }

    std::vector<Embedding> flat;
    std::vector<int>       flat_actor;
    for (int a = 0; a < static_cast<int>(g.actors.size()); ++a)
        for (const auto& e : g.actors[a].embeddings) { flat.push_back(e); flat_actor.push_back(a); }

    GalleryCalibrationStats stats;
    GalleryCalibration cal = calibrate_gallery(flat, flat_actor, &stats);
    GalleryReport r = build_gallery_report(g, cal, stats);

    CHECK(r.actors_below_positive_threshold >= 1);
}

// TRACES: GR-003 | SR-001
TEST_CASE("report round-trips", "[report][GR-003]") {
    ActorGallery g;
    ActorGallery::Actor act;
    act.name = "solo";
    for (int i = 0; i < 6; ++i) act.embeddings.push_back(unit_axis(i));
    g.actors.push_back(std::move(act));

    std::vector<Embedding> flat;
    std::vector<int>       flat_actor;
    for (const auto& e : g.actors[0].embeddings) { flat.push_back(e); flat_actor.push_back(0); }

    GalleryCalibrationStats stats;
    GalleryCalibration cal = calibrate_gallery(flat, flat_actor, &stats);
    GalleryReport r = build_gallery_report(g, cal, stats);

    const std::string path = "/tmp/gr003_roundtrip.report.json";
    save_gallery_report(path, r);
    GalleryReport back = load_gallery_report(path);

    CHECK(back.actors_in_gallery == r.actors_in_gallery);
    CHECK(back.actors_below_positive_threshold == r.actors_below_positive_threshold);
    CHECK(back.calib_a == r.calib_a);
    std::remove(path.c_str());
}
