// TRACES: AR-007, AR-008 | SR-002
//
// Unit tests for FaceTrackerFunc (nodes/face_tracker_node.hpp): frame-to-frame
// track linking and, crucially, cross-cut re-association. Pure, GPU-free,
// model-free — drives the node's operator() with hand-built EmbeddedSceneFrames
// and inspects the emitted track_ids.
//
// The behaviour under test: there is one track pool keyed on `last_seen`
// (AR-008), so a face lost across a camera-angle change (Frame::is_cut) is an
// ordinary association candidate rather than a parked track needing a revival
// path — the raw-cosine `cut_revive_sim` that guarded that path is retired
// (AR-024). On a cut the association weight drops to embedding-only (AR-007),
// and IoU is deliberately driven to 0 across the cut (boxes moved) so only the
// embedding path can re-link — exactly the scenario a cut creates.
#include <catch2/catch_test_macros.hpp>

#include "config.hpp"
#include "nodes/face_tracker_node.hpp"
#include "types.hpp"
#include "track_registry.hpp"
#include "evidence_discount.hpp"

#include <algorithm>
#include <cmath>
#include <memory>

namespace {

// Unit-norm embedding in the plane of axes i,j at angle whose cosine to
// one_hot(i) is cos_t. cosine_similarity(at_sim(i,j,a), at_sim(i,j,b)) works out
// to cos(angle diff), letting a test dial the cross-cut similarity precisely.
Embedding at_sim(int i, int j, float cos_t) {
    Embedding e{};
    float s = std::sqrt(std::max(0.f, 1.f - cos_t * cos_t));
    e[i] = cos_t;
    e[j] = s;
    return e;
}

Embedding axis(int slot) {
    Embedding e{};
    e[slot] = 1.0f;
    return e;
}

DetectedFace face_at(float x, float y) {
    DetectedFace f;
    f.bbox = cv::Rect2f(x, y, 40.f, 40.f);
    f.confidence = 0.99f;
    return f;
}

// Build a single-face frame at position (x,y) with embedding emb. is_cut marks a
// camera-angle change on this frame.
EmbeddedSceneFrame frame(double t, float x, float y, const Embedding& emb,
                         bool is_cut = false) {
    EmbeddedSceneFrame ef;
    ef.source.timestamp_sec = t;
    ef.source.is_cut        = is_cut;
    ef.faces      = {face_at(x, y)};
    ef.crops      = {cv::Mat()};
    ef.embeddings = {emb};
    return ef;
}

// Build a tracker over a fresh registry. The registry IS the tracker's state
// now (AR-008), so a test constructs both together and can inspect either.
struct Rig {
    std::shared_ptr<TrackRegistry> reg;
    FaceTrackerFunc                ft;

    explicit Rig(double extinction = 30.0, float assoc_min_prob = 0.5f)
        : reg(std::make_shared<TrackRegistry>(
              [extinction] {
                  TrackRegistry::Config c;
                  c.track_extinction_sec = extinction;
                  return c;
              }(),
              EvidenceDiscounter([](float cos) { return std::max(0.f, cos); })))
        , ft([&] {
              Config c;
              c.track_assoc_min_prob = assoc_min_prob;
              return c;
          }(),
          reg,
          // Trivial calibration: cosine passed through as P(same). Real runs use
          // the fit belonging to the active embedder (AR-023/AR-024).
          [](float cos) { return std::max(0.f, cos); })
    {}

    int track_of(EmbeddedSceneFrame f) { return ft(std::move(f)).track_ids[0]; }
};

}  // namespace

// ── AR-008 — one pool, ordinary association ──────────────────────────────────
TEST_CASE("track id is stable across ordinary frames", "[face_tracker][AR-008]") {
    Rig r;
    Embedding e = axis(0);
    int id0 = r.track_of(frame(0.0, 10, 10, e));
    int id1 = r.track_of(frame(1.0, 11, 10, e));   // overlaps → same track
    CHECK(id0 >= 0);
    CHECK(id1 == id0);
}

TEST_CASE("a face lost across a cut and re-associated is the SAME track",
          "[face_tracker][AR-008]") {
    // Previously this was a distinct "revival" path guarded by a raw-cosine
    // constant. There is no such path now: a dormant track is an ordinary
    // association candidate, and continuity falls out of the embedding match.
    Rig r;

    Embedding pre  = at_sim(0, 1, 0.99f);
    int id_pre = r.track_of(frame(0.0, 10, 10, pre));
    REQUIRE(id_pre >= 0);

    // Box jumps so IoU is zero — only the embedding can link it.
    Embedding post = at_sim(0, 1, 0.98f);
    CHECK(r.track_of(frame(1.0, 300, 300, post, /*is_cut=*/true)) == id_pre);
}

TEST_CASE("a cut starts a fresh track when identity does not match",
          "[face_tracker][AR-008]") {
    Rig r;
    int id_pre = r.track_of(frame(0.0, 10, 10, axis(0)));
    REQUIRE(id_pre >= 0);

    // Orthogonal embedding and disjoint box: nothing links them.
    int id_post = r.track_of(frame(1.0, 300, 300, axis(5), /*is_cut=*/true));
    CHECK(id_post != id_pre);
    CHECK(id_post >= 0);
}

// ── AR-007 — a cut makes association ignore position ─────────────────────────
TEST_CASE("on a cut, identity follows the embedding rather than the box",
          "[face_tracker][AR-007]") {
    // Two people swap screen positions across a cut while keeping their faces.
    // If IoU still carried weight the ids would follow the boxes and swap; with
    // alpha driven to embedding-only on a cut, they must follow the faces.
    Rig r;

    Embedding a = at_sim(0, 1, 0.99f);
    Embedding b = at_sim(2, 3, 0.99f);

    EmbeddedSceneFrame f0;
    f0.source.timestamp_sec = 0.0;
    f0.faces      = {face_at(10, 10), face_at(300, 300)};
    f0.crops      = {cv::Mat(), cv::Mat()};
    f0.embeddings = {a, b};
    auto out0 = r.ft(std::move(f0));
    const int id_a = out0.track_ids[0];
    const int id_b = out0.track_ids[1];
    REQUIRE(id_a >= 0);
    REQUIRE(id_b >= 0);
    REQUIRE(id_a != id_b);

    // Same two people, positions exchanged, on a cut frame.
    EmbeddedSceneFrame f1;
    f1.source.timestamp_sec = 1.0;
    f1.source.is_cut        = true;
    f1.faces      = {face_at(300, 300), face_at(10, 10)};
    f1.crops      = {cv::Mat(), cv::Mat()};
    f1.embeddings = {a, b};
    auto out1 = r.ft(std::move(f1));

    CHECK(out1.track_ids[0] == id_a);   // A kept its id despite moving to B's box
    CHECK(out1.track_ids[1] == id_b);
}

// ── AR-013 — extinction replaces the parked-pool frame counter ───────────────
TEST_CASE("a track past the extinction window is gone, not revived",
          "[face_tracker][AR-013]") {
    // The old design aged a parked pool in frames, which silently changed
    // meaning with sample_fps. Extinction is in seconds and lives in the
    // registry, so the tracker no longer counts anything.
    Rig r(/*extinction=*/2.0);

    Embedding person = at_sim(0, 1, 0.99f);
    int id_pre = r.track_of(frame(0.0, 10, 10, person));
    REQUIRE(id_pre >= 0);

    // Unrelated faces elsewhere while the clock runs well past extinction.
    r.track_of(frame(1.0,  300, 300, axis(7), /*is_cut=*/true));
    r.track_of(frame(10.0, 300, 300, axis(7)));

    CHECK(r.track_of(frame(11.0, 10, 10, person)) != id_pre);
}

TEST_CASE("a track within the extinction window is still a candidate",
          "[face_tracker][AR-013]") {
    Rig r(/*extinction=*/30.0);

    Embedding person = at_sim(0, 1, 0.99f);
    int id_pre = r.track_of(frame(0.0, 10, 10, person));
    r.track_of(frame(1.0, 300, 300, axis(7), /*is_cut=*/true));

    // Back inside the window: the same person continues the same track, so the
    // gap is absorbed into one window rather than splitting it.
    CHECK(r.track_of(frame(3.0, 10, 10, person)) == id_pre);
}

TEST_CASE("eof is forwarded", "[face_tracker]") {
    Rig r;
    EmbeddedSceneFrame eof;
    eof.source.eof = true;
    CHECK(r.ft(std::move(eof)).source.eof);
}
