// TRACES: AR-005, AR-028, AR-029, AR-030 | UT-130, UT-131, UT-132, UT-133, UT-134, UT-135, UT-136, UT-137, UT-138 | SR-002
//
// Unit tests for the geometric/numeric helpers in types.hpp and face_utils.hpp:
// cosine_similarity, the ArcFace 5-point alignment transform, and the two
// measured axes of the AR-028 quality vector — the alignment residual AR-030
// reads as visibility, and the normalised Laplacian variance AR-029 reads as
// sharpness. The aligner node is exercised here too, since it is the unit that
// fills the vector in. GPU-free, model-free.
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>

#include "face_utils.hpp"
#include "nodes/face_aligner_node.hpp"
#include "types.hpp"

#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <limits>

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

TEST_CASE("cosine_similarity of a unit vector with itself is 1", "[types]") {
    std::array<float, 512> raw{};
    raw[3] = 2.f; raw[7] = -1.f;
    Embedding e = l2_normalise(raw.data());
    CHECK_THAT(cosine_similarity(e, e), WithinAbs(1.0f, 1e-6f));
}

TEST_CASE("cosine_similarity of orthogonal vectors is 0", "[types]") {
    Embedding a{}, b{};
    a[0] = 1.f;
    b[1] = 1.f;
    CHECK_THAT(cosine_similarity(a, b), WithinAbs(0.0f, 1e-6f));
}

TEST_CASE("cosine_similarity of opposite vectors is -1", "[types]") {
    Embedding a{}, b{};
    a[5] = 1.f;
    b[5] = -1.f;
    CHECK_THAT(cosine_similarity(a, b), WithinAbs(-1.0f, 1e-6f));
}

TEST_CASE("align_face maps the reference landmarks onto the 112x112 canvas", "[face_utils]") {
    // Build a synthetic image where the five landmarks sit at known positions.
    // Feeding align_face the *reference* positions themselves should yield an
    // (near-)identity similarity transform, so the output is 112x112.
    cv::Mat img(200, 200, CV_8UC3, cv::Scalar(0, 0, 0));
    std::array<cv::Point2f, 5> lm;
    for (int i = 0; i < 5; ++i) {
        lm[i] = {kArcFaceRef[i][0], kArcFaceRef[i][1]};
        cv::circle(img, lm[i], 2, cv::Scalar(255, 255, 255), -1);
    }

    cv::Mat crop = align_face(img, lm);
    REQUIRE_FALSE(crop.empty());
    CHECK(crop.cols == 112);
    CHECK(crop.rows == 112);
}

TEST_CASE("align_face is translation-equivariant", "[face_utils]") {
    // Shifting all landmarks by a constant offset must still produce a valid
    // 112x112 crop (the similarity transform absorbs the translation).
    cv::Mat img(300, 300, CV_8UC3, cv::Scalar(30, 30, 30));
    std::array<cv::Point2f, 5> lm;
    const float dx = 100.f, dy = 80.f;
    for (int i = 0; i < 5; ++i)
        lm[i] = {kArcFaceRef[i][0] + dx, kArcFaceRef[i][1] + dy};

    cv::Mat crop = align_face(img, lm);
    REQUIRE_FALSE(crop.empty());
    CHECK(crop.cols == 112);
    CHECK(crop.rows == 112);
}

TEST_CASE("align_face returns empty on degenerate (collinear) landmarks", "[face_utils]") {
    // All five landmarks identical → the affine fit is degenerate.
    cv::Mat img(200, 200, CV_8UC3, cv::Scalar(0, 0, 0));
    std::array<cv::Point2f, 5> lm;
    for (auto& p : lm) p = {50.f, 50.f};

    cv::Mat crop = align_face(img, lm);
    CHECK(crop.empty());
}

// ── AR-030: the alignment residual as a visibility measure ────────────────────
// These assert the *properties* the measure is relied on for, not a magic value.
// Each would fail under a RANSAC fit, which buys a small residual by discarding
// the very landmarks that carry the signal.

namespace {

std::array<cv::Point2f, 5> canonical() {
    std::array<cv::Point2f, 5> lm;
    for (int i = 0; i < 5; ++i) lm[i] = {kArcFaceRef[i][0], kArcFaceRef[i][1]};
    return lm;
}

// Rotate by `deg` in-plane, scale uniformly, translate — i.e. exactly the 4 DoF
// the similarity transform models.
std::array<cv::Point2f, 5> similarity(const std::array<cv::Point2f, 5>& in,
                                      float deg, float s, float tx, float ty) {
    const float r = deg * 3.14159265358979f / 180.f;
    const float c = std::cos(r), sn = std::sin(r);
    std::array<cv::Point2f, 5> out;
    for (int i = 0; i < 5; ++i)
        out[i] = {s * (c * in[i].x - sn * in[i].y) + tx,
                  s * (sn * in[i].x + c * in[i].y) + ty};
    return out;
}

// Squash x about the centroid by `k`: the anisotropic deformation an out-of-plane
// yaw produces, and the one a similarity provably cannot absorb.
std::array<cv::Point2f, 5> foreshorten(const std::array<cv::Point2f, 5>& in, float k) {
    float cx = 0.f;
    for (const auto& p : in) cx += p.x;
    cx /= 5.f;
    std::array<cv::Point2f, 5> out = in;
    for (auto& p : out) p.x = cx + (p.x - cx) * k;
    return out;
}

}  // namespace

TEST_CASE("residual is zero for a face in canonical pose", "[face_utils][AR-030]") {
    const Alignment a = estimate_alignment(canonical());
    REQUIRE(a.ok);
    CHECK_THAT(a.residual, WithinAbs(0.0f, 1e-3f));
}

TEST_CASE("residual ignores in-plane roll, scale and translation", "[face_utils][AR-030]") {
    // The structural claim behind AR-030: the fit absorbs all four similarity
    // DoF exactly, so what remains is only the deformation a similarity cannot
    // explain. A rolled head must not read as a turned one.
    for (float deg : {-40.f, -12.f, 0.f, 17.f, 65.f}) {
        const Alignment a = estimate_alignment(similarity(canonical(), deg, 3.5f, 220.f, -40.f));
        REQUIRE(a.ok);
        CHECK_THAT(a.residual, WithinAbs(0.0f, 1e-3f));
    }
}

TEST_CASE("residual rises monotonically with foreshortening", "[face_utils][AR-030]") {
    float prev = -1.f;
    for (float k : {1.0f, 0.9f, 0.75f, 0.5f, 0.3f}) {
        const Alignment a = estimate_alignment(foreshorten(canonical(), k));
        REQUIRE(a.ok);
        CHECK(a.residual > prev);
        prev = a.residual;
    }
}

TEST_CASE("residual is independent of face size", "[face_utils][AR-030]") {
    // The measure must not silently re-express face size — that is AR-002's job,
    // and double-counting it would make a small frontal face look occluded.
    // Same deformation, two very different face sizes, one answer.
    const auto small = similarity(foreshorten(canonical(), 0.7f), 20.f, 1.0f,  0.f,  0.f);
    const auto large = similarity(foreshorten(canonical(), 0.7f), 20.f, 12.0f, 500.f, 300.f);

    const Alignment a = estimate_alignment(small);
    const Alignment b = estimate_alignment(large);
    REQUIRE(a.ok);
    REQUIRE(b.ok);
    CHECK_THAT(b.residual, WithinAbs(a.residual, 1e-2f));
}

TEST_CASE("the fit never mirrors the face", "[face_utils][AR-030]") {
    // SVD will happily return an orientation-reversing solution; a similarity
    // transform may rotate but never reflect. Without the determinant guard a
    // mirrored landmark set fits "perfectly" as a reflection.
    const auto mirrored = foreshorten(canonical(), -1.f);
    const Alignment a = estimate_alignment(mirrored);
    REQUIRE(a.ok);

    const double det = a.M.at<double>(0,0) * a.M.at<double>(1,1)
                     - a.M.at<double>(0,1) * a.M.at<double>(1,0);
    CHECK(det > 0.0);
    CHECK(a.residual > 1.0f);   // and the mirroring shows up as misfit
}

TEST_CASE("degenerate landmarks report not-ok rather than a residual", "[face_utils][AR-030]") {
    std::array<cv::Point2f, 5> lm;
    for (auto& p : lm) p = {50.f, 50.f};

    const Alignment a = estimate_alignment(lm);
    CHECK_FALSE(a.ok);
    CHECK(a.M.empty());
}

// ── AR-029: normalised Laplacian variance as a sharpness measure ──────────────
// As with AR-030 above, these assert the *properties* the measure is relied on
// for rather than magic values: no threshold is set here or anywhere else, so a
// number that drifted with the OpenCV version would still be usable — a number
// that stopped falling with blur, or started tracking exposure, would not.

namespace {

// Pink noise: broadband, but with a 1/f spectrum, so most of the energy sits at
// low frequency the way it does in a photograph. An LCG rather than cv::randu so
// the ladder is identical on every machine and every OpenCV build.
//
// **The 1/f part is load-bearing, not decoration.** On a flat-spectrum texture
// (raw white noise) the Gaussian ladder still falls, but the motion-blur ladder
// *rises* — 80.1 → 90.0 across the same kernel lengths used below. That is not a
// bug in the measure, it is what a normalised measure must do on such an input:
// a horizontal smear takes energy out of the numerator and the denominator
// together, and what survives is vertical detail that really is just as fine.
// Real crops have the low-frequency mass that keeps the denominator steady while
// the numerator falls. See the second hazard note on crop_sharpness().
cv::Mat pink(int size, uint32_t seed = 12345u) {
    cv::Mat white(size, size, CV_32F);
    uint32_t s = seed;
    for (int y = 0; y < size; ++y)
        for (int x = 0; x < size; ++x) {
            s = s * 1664525u + 1013904223u;
            white.at<float>(y, x) = float((s >> 16) & 0xFFFF) / 65535.f - 0.5f;
        }

    // Octaves weighted 1/f. The sigma=0 band keeps genuine per-pixel detail in,
    // so the top of the blur ladder is a sharp image rather than an already-soft
    // one.
    cv::Mat acc = cv::Mat::zeros(size, size, CV_32F);
    const double sigma[]  = {0.0, 1.0, 2.0, 4.0, 8.0};
    const double weight[] = {1.0, 2.0, 4.0, 8.0, 16.0};
    for (int k = 0; k < 5; ++k) {
        cv::Mat band;
        if (sigma[k] <= 0.0) band = white.clone();
        else cv::GaussianBlur(white, band, {0, 0}, sigma[k], sigma[k], cv::BORDER_REPLICATE);
        acc += band * weight[k];
    }

    // Into [40, 215]: 8-bit like a real crop, with headroom at both ends so the
    // contrast test can halve it without clipping.
    double lo = 0, hi = 0;
    cv::minMaxLoc(acc, &lo, &hi);
    const double scale = 175.0 / (hi - lo);
    cv::Mat out;
    acc.convertTo(out, CV_8U, scale, 40.0 - lo * scale);
    return out;
}

// One master pattern, resampled. So "the same face at 40 px and at 400 px" is
// literally the same image at two resolutions, and a test about source
// resolution is not accidentally a test about two different textures.
// INTER_AREA because area-averaging is what a sensor does when it images the
// same subject onto fewer pixels.
cv::Mat texture(int size) {
    static const cv::Mat master = pink(448);
    if (size == master.cols) return master;
    cv::Mat out;
    cv::resize(master, out, {size, size}, 0, 0, cv::INTER_AREA);
    return out;
}

cv::Mat gaussian(const cv::Mat& in, double sigma) {
    if (sigma <= 0.0) return in.clone();
    cv::Mat out;
    cv::GaussianBlur(in, out, {0, 0}, sigma, sigma, cv::BORDER_REPLICATE);
    return out;
}

// Horizontal box smear — motion blur, which is anisotropic and so attenuates
// only one axis of the spectrum. A measure tuned to the isotropic case can
// miss it.
cv::Mat motion(const cv::Mat& in, int len) {
    if (len <= 1) return in.clone();
    const cv::Mat k(1, len, CV_32F, cv::Scalar(1.0 / len));
    cv::Mat out;
    cv::filter2D(in, out, -1, k, {-1, -1}, 0, cv::BORDER_REPLICATE);
    return out;
}

// The pipeline reaches 112×112 through warpAffine's INTER_LINEAR; resize with
// the same interpolation so a test about source resolution is not really a test
// about which resampler was used.
cv::Mat to_crop(const cv::Mat& in) {
    cv::Mat out;
    cv::resize(in, out, {112, 112}, 0, 0, cv::INTER_LINEAR);
    return out;
}

}  // namespace

TEST_CASE("sharpness falls monotonically along a Gaussian blur ladder", "[face_utils][AR-029][UT-130]") {
    const cv::Mat src = texture(112);
    float prev = std::numeric_limits<float>::infinity();
    for (double sigma : {0.0, 0.6, 1.0, 1.6, 2.5, 4.0}) {
        const float s = crop_sharpness(gaussian(src, sigma));
        CHECK(s < prev);
        CHECK(s > 0.f);
        prev = s;
    }
}

TEST_CASE("sharpness falls monotonically under motion blur too", "[face_utils][AR-029][UT-131]") {
    // Motion blur is the failure mode that leaves the bounding box looking
    // perfectly healthy, so it is the one the measure exists for.
    const cv::Mat src = texture(112);
    float prev = std::numeric_limits<float>::infinity();
    for (int len : {1, 3, 5, 9, 15}) {
        const float s = crop_sharpness(motion(src, len));
        CHECK(s < prev);
        CHECK(s > 0.f);
        prev = s;
    }
}

TEST_CASE("contrast does not leak into sharpness", "[face_utils][AR-029][UT-132]") {
    // The normalisation that makes the axis mean the same thing in a dim scene
    // and a bright one. Without it VR-012 would locate a different knee per
    // film — a magic number wearing a measurement's clothes (AR-024).
    const cv::Mat src = texture(112);

    cv::Mat dim;
    src.convertTo(dim, CV_8U, 0.5, 64.0);   // half contrast, re-centred, no clipping

    const float a = crop_sharpness(src);
    const float b = crop_sharpness(dim);
    REQUIRE(a > 0.f);
    CHECK_THAT(b, WithinRel(a, 0.03f));
}

TEST_CASE("the contrast invariance is exact, and 8-bit sampling is what bends it",
          "[face_utils][AR-029]") {
    // Worth separating because the two have different consequences. The
    // algebra is exact — scaling I by α scales the Laplacian by α, so both
    // variances scale by α² and cancel — which is why halving a float crop
    // changes nothing at all.
    //
    // What deviates is the 8-bit *round trip*: halving the contrast of a stored
    // crop throws away a bit of dynamic range, and the quantisation floor it
    // leaves behind is broadband, so it lands almost entirely in the numerator.
    // The effect scales with how little signal is left to compete with it —
    // measured on this texture, a half-contrast copy reads 0.9% high when sharp,
    // 24% high at sigma 1.2 and 148% high at sigma 2.5.
    //
    // So: a dim *and* soft crop reads sharper than it is, and that is the corner
    // of the axis VR-012 has to put a knee in. Asserted here rather than left as
    // a comment, because "the measure is contrast-invariant" is the kind of claim
    // that gets repeated without its precondition.
    cv::Mat src;
    gaussian(texture(112), 1.2).convertTo(src, CV_32F);
    const cv::Mat half = src * 0.5 + 64.0;

    const float a = crop_sharpness(src);
    const float b = crop_sharpness(half);
    REQUIRE(a > 0.f);
    CHECK_THAT(b, WithinRel(a, 1e-5f));
}

TEST_CASE("a small sharp face outscores a large soft one", "[face_utils][AR-029][UT-134]") {
    // The register's named edge case: "size must not leak into this axis". What
    // that means operationally is that the measure is not a monotone function of
    // source face size — it reports the detail present in the embedder's input,
    // so the ordering can and must invert when the large face is the blurred one.
    //
    // Small sharp: 40 px of real detail, upsampled 2.8x → finest scale ~2.8 crop px.
    // Large soft:  400 px blurred at sigma 20, downsampled 3.57x → ~5.6 crop px.
    const float small_sharp = crop_sharpness(to_crop(texture(40)));
    const float large_soft  = crop_sharpness(to_crop(gaussian(texture(400), 20.0)));

    CHECK(small_sharp > large_soft);
}

TEST_CASE("a flat crop scores zero rather than dividing by zero", "[face_utils][AR-029][UT-135]") {
    const cv::Mat flat(112, 112, CV_8UC3, cv::Scalar(90, 90, 90));
    const float s = crop_sharpness(flat);
    CHECK(std::isfinite(s));
    CHECK_THAT(s, WithinAbs(0.0f, 1e-6f));
}

TEST_CASE("an empty crop is unscored, not zero", "[face_utils][AR-029][UT-136]") {
    // -1 says "nothing measured this"; 0 says "measured, and there was no
    // detail". Collapsing them would put unscored faces at the bottom of the
    // quality axis, where VR-012 would read them as the blurriest in the film.
    CHECK(crop_sharpness(cv::Mat()) < 0.f);
}

// ── AR-028: the aligner fills the vector, and loses nothing quietly ───────────

namespace {

// A face at `centre` in an image with enough texture for sharpness to be a real
// number rather than the flat-crop zero.
std::array<cv::Point2f, 5> face_at(cv::Point2f centre, float scale) {
    std::array<cv::Point2f, 5> lm;
    for (int i = 0; i < 5; ++i)
        lm[i] = {centre.x + (kArcFaceRef[i][0] - 56.f) * scale,
                 centre.y + (kArcFaceRef[i][1] - 56.f) * scale};
    return lm;
}

cv::Mat textured_frame(int w, int h) {
    cv::Mat gray = texture(std::max(w, h));
    cv::Mat bgr;
    cv::cvtColor(gray(cv::Rect(0, 0, w, h)), bgr, cv::COLOR_GRAY2BGR);
    return bgr;
}

}  // namespace

TEST_CASE("every face the aligner admits carries a full quality vector", "[face_utils][AR-028][UT-137]") {
    SceneFrame sf;
    sf.source.image = textured_frame(400, 300);
    for (auto c : {cv::Point2f{120.f, 100.f}, cv::Point2f{280.f, 190.f}}) {
        DetectedFace f;
        f.landmarks = face_at(c, 1.2f);
        f.bbox = cv::Rect2f(c.x - 60.f, c.y - 60.f, 120.f, 120.f);
        f.confidence = 0.9f;
        sf.faces.push_back(f);
    }

    FaceAlignerFunc aligner;
    const AlignedSceneFrame out = aligner(std::move(sf));

    REQUIRE(out.faces.size() == 2);
    for (const auto& f : out.faces) {
        // Not "is it good quality" — that is VR-012's to decide. Only that the
        // sentinel is gone, so no embedding reaches the matcher unscored.
        CHECK(f.sharpness          >= 0.f);
        CHECK(f.alignment_residual >= 0.f);
    }
    CHECK(aligner.scored() == 2);
    CHECK(aligner.degenerate() == 0);
}

TEST_CASE("a degenerate detection is counted, not silently vanished", "[face_utils][AR-028][UT-138]") {
    // It cannot be scored — there is no crop and no fit to score — so it is
    // dropped. The requirement is that the drop leaves a trace: without the
    // tally, a detector emitting unusable landmark sets produces a dump that
    // looks exactly like footage with fewer faces in it.
    SceneFrame sf;
    sf.source.image = textured_frame(400, 300);

    DetectedFace good;
    good.landmarks = face_at({150.f, 140.f}, 1.2f);
    good.confidence = 0.9f;
    sf.faces.push_back(good);

    DetectedFace degenerate;
    for (auto& p : degenerate.landmarks) p = {200.f, 200.f};
    degenerate.confidence = 0.9f;
    sf.faces.push_back(degenerate);

    FaceAlignerFunc aligner;
    const AlignedSceneFrame out = aligner(std::move(sf));

    CHECK(out.faces.size() == 1);
    CHECK(out.crops.size() == 1);
    CHECK(aligner.scored() == 1);
    CHECK(aligner.degenerate() == 1);
}
