// Unit tests for the JRay v1 audio signature (src/audio_signature.*).
//
/// TRACES: UT-101, UT-102, UT-103, UT-104 | IR-004, IR-005, IR-007, IR-008
//
// The headline test is the golden vector: a deterministic tone fixture checked
// into tests/fixtures/audio/ together with the signature it must produce. That
// fixture is the artefact shared with the jRay plugin repo, and it is what
// makes "both producers agree bit-for-bit" a checked claim rather than an
// assertion (IR-005).
//
// GPU-free, model-free, no network. Pure CPU DSP plus an FFmpeg decode of a
// 585 KB file — which is precisely why this is the right cross-repo check: it
// runs anywhere, including the N100 CI host.
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>

#include "audio_signature.hpp"

#include <nlohmann/json.hpp>

#include <cmath>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <stdexcept>
#include <string>
#include <vector>

using namespace sae::audio;
namespace fs = std::filesystem;

namespace {

const std::string kFixtureDir = SAE_TEST_FIXTURES_DIR "/audio";
const std::string kGoldenPath = kFixtureDir + "/jray_audio_v1_golden.json";
const std::string kMediaPath  = kFixtureDir + "/jray_audio_v1_tone.flac";

const nlohmann::json& golden() {
    static const nlohmann::json j = [] {
        std::ifstream in(kGoldenPath);
        if (!in.good())
            throw std::runtime_error("golden fixture not found: " + kGoldenPath);
        nlohmann::json parsed;
        in >> parsed;
        return parsed;
    }();
    return j;
}

std::uint64_t hex64(const std::string& s) {
    return std::stoull(s, nullptr, 16);
}

// The fixture is 120 s of audio: decoding and signing it is the expensive part
// of this file, so both results are computed once and shared. Every test below
// still asserts against the on-disk golden values, not against each other.
const std::optional<std::vector<float>>& fixture_window() {
    static const std::optional<std::vector<float>> w = decode_centre_window(kMediaPath);
    return w;
}

const std::optional<std::string>& fixture_signature() {
    static const std::optional<std::string> s = compute_signature(kMediaPath);
    return s;
}

// ── Minimal WAV writer, so the short-media and resample cases need no fixture ─
// 16-bit PCM, interleaved.
struct TempWav {
    fs::path path;
    explicit TempWav(const std::string& name)
        : path(fs::temp_directory_path() / ("sae_audio_test_" + name + ".wav")) {}
    ~TempWav() { std::error_code ec; fs::remove(path, ec); }

    void write(const std::vector<std::int16_t>& samples, int rate, int channels) const {
        const std::uint32_t bytes = static_cast<std::uint32_t>(samples.size() * 2);
        const std::uint32_t byte_rate = static_cast<std::uint32_t>(rate * channels * 2);
        std::ofstream out(path, std::ios::binary);
        auto u32 = [&](std::uint32_t v) { out.write(reinterpret_cast<const char*>(&v), 4); };
        auto u16 = [&](std::uint16_t v) { out.write(reinterpret_cast<const char*>(&v), 2); };
        out.write("RIFF", 4); u32(36 + bytes); out.write("WAVE", 4);
        out.write("fmt ", 4); u32(16); u16(1); u16(static_cast<std::uint16_t>(channels));
        u32(static_cast<std::uint32_t>(rate)); u32(byte_rate);
        u16(static_cast<std::uint16_t>(channels * 2)); u16(16);
        out.write("data", 4); u32(bytes);
        out.write(reinterpret_cast<const char*>(samples.data()), bytes);
    }
};

// A plain 1 kHz tone, mono, at the signature's own rate.
std::vector<std::int16_t> tone(double seconds, int rate = kSampleRate) {
    const std::size_t n = static_cast<std::size_t>(std::llround(seconds * rate));
    std::vector<std::int16_t> s(n);
    for (std::size_t i = 0; i < n; ++i)
        s[i] = static_cast<std::int16_t>(std::llround(
            20000.0 * std::sin(2.0 * 3.14159265358979323846 * 1000.0 * double(i) / rate)));
    return s;
}

std::vector<std::uint8_t> base64_decode(const std::string& in) {
    auto val = [](char c) -> int {
        if (c >= 'A' && c <= 'Z') return c - 'A';
        if (c >= 'a' && c <= 'z') return c - 'a' + 26;
        if (c >= '0' && c <= '9') return c - '0' + 52;
        if (c == '+') return 62;
        if (c == '/') return 63;
        return -1;
    };
    std::vector<std::uint8_t> out;
    std::uint32_t acc = 0;
    int bits = 0;
    for (char c : in) {
        const int v = val(c);
        if (v < 0) continue;                       // '=' padding
        acc = (acc << 6) | static_cast<std::uint32_t>(v);
        bits += 6;
        if (bits >= 8) {
            bits -= 8;
            out.push_back(static_cast<std::uint8_t>((acc >> bits) & 0xFF));
        }
    }
    return out;
}

}  // namespace

// ── UT-101 — the golden vector ──────────────────────────────────────────────

/// TRACES: UT-101 | IR-004, IR-005, IR-008
TEST_CASE("signature of the golden fixture matches the recorded value exactly",
          "[audio_signature][golden]") {
    REQUIRE(fs::exists(kMediaPath));
    const std::optional<std::string>& sig = fixture_signature();
    REQUIRE(sig.has_value());
    CHECK(*sig == golden()["signature"].get<std::string>());
}

/// TRACES: UT-101 | IR-005
TEST_CASE("decoded centre window matches the recorded PCM checksum",
          "[audio_signature][golden]") {
    // Checked separately from the signature so a codec-level difference is
    // distinguishable from a DSP-level one: if this passes and the signature
    // test fails, the DSP diverged; if this fails, the decode did.
    const std::optional<std::vector<float>>& mono = fixture_window();
    REQUIRE(mono.has_value());
    CHECK(mono->size() == golden()["decoded_window"]["samples"].get<std::size_t>());
    CHECK(fnv1a64(mono->data(), mono->size() * sizeof(float)) ==
          hex64(golden()["decoded_window"]["f32le_fnv1a64"].get<std::string>()));
}

/// TRACES: UT-101 | IR-004
TEST_CASE("log-spaced band table matches the recorded one", "[audio_signature][golden]") {
    // The band->FFT-bin table is the part of the construction most likely to
    // drift between two implementations, so it is pinned independently of the
    // signature it produces.
    const auto& tbl = band_fft_bins();
    const auto& want = golden()["band_fft_bins"];
    REQUIRE(want.size() == tbl.size());
    for (std::size_t b = 0; b < tbl.size(); ++b) {
        CHECK(tbl[b].first == want[b][0].get<int>());
        CHECK(tbl[b].second == want[b][1].get<int>());
        CHECK(tbl[b].second > tbl[b].first);        // no empty band
        if (b) CHECK(tbl[b].first == tbl[b - 1].second);  // contiguous, no overlap
    }
}

/// TRACES: UT-101 | IR-004, IR-008
TEST_CASE("signature is well-formed: v1 prefix, 1288 frames, structural bytes",
          "[audio_signature][golden]") {
    const std::optional<std::string>& sig = fixture_signature();
    REQUIRE(sig.has_value());

    // IR-008 — the signature carries its own version, separate from
    // schema_version, so a future DSP change is detectable rather than silently
    // producing non-matching signatures.
    REQUIRE(sig->rfind(kVersionPrefix, 0) == 0);

    const std::vector<std::uint8_t> bytes = base64_decode(sig->substr(3));
    CHECK(bytes.size() == kExpectedFrames);
    CHECK(bytes.size() == golden()["frame_count"].get<std::size_t>());

    // The server validates this structure on upload (server SPEC §3): each byte
    // is a 5-bit band index plus a 2-bit energy class, so bit 7 is always clear
    // and arbitrary bytes are invalid. That is what keeps the field from being
    // a payload channel.
    bool bands_seen[kNumBands] = {};
    bool classes_seen[4] = {};
    for (std::uint8_t b : bytes) {
        REQUIRE((b & 0x80) == 0);
        bands_seen[(b >> 2) & 0x1F] = true;
        classes_seen[b & 0x03] = true;
    }
    // The fixture is built to exercise the whole output alphabet — if it ever
    // stops doing so, the golden vector has become a weaker check than it looks.
    for (bool seen : bands_seen) CHECK(seen);
    for (bool seen : classes_seen) CHECK(seen);
}

// ── UT-102 — IR-007, media shorter than the window ──────────────────────────

/// TRACES: UT-102 | IR-007
TEST_CASE("media shorter than 120 s emits no signature", "[audio_signature][short]") {
    // The window runtime/2 ± 60 s underflows, so there is no signature and no
    // sync offset downstream. Both producers must apply the identical rule or
    // they diverge on exactly the short items most likely to be misidentified.
    SECTION("30 s") {
        TempWav w("short30");
        w.write(tone(30.0), kSampleRate, 1);
        CHECK_FALSE(compute_signature(w.path.string()).has_value());
        CHECK_FALSE(decode_centre_window(w.path.string()).has_value());
    }
    SECTION("just under the boundary") {
        TempWav w("short11999");
        w.write(tone(119.99), kSampleRate, 1);
        CHECK_FALSE(compute_signature(w.path.string()).has_value());
    }
}

/// TRACES: UT-102 | IR-007
TEST_CASE("media of exactly 120 s emits a full-length signature",
          "[audio_signature][short]") {
    TempWav w("exact120");
    w.write(tone(120.0), kSampleRate, 1);
    const std::optional<std::string> sig = compute_signature(w.path.string());
    REQUIRE(sig.has_value());
    CHECK(base64_decode(sig->substr(3)).size() == kExpectedFrames);
}

/// TRACES: UT-102 | IR-007
TEST_CASE("unreadable media degrades to no signature rather than failing",
          "[audio_signature][short]") {
    // UR-9 is an enhancement and must never be able to break a fetch.
    CHECK_FALSE(compute_signature("/nonexistent/definitely-not-here.mkv").has_value());
}

/// TRACES: UT-102 | IR-004
TEST_CASE("the window is taken from the centre, not the head",
          "[audio_signature][centre]") {
    // Sampling from the centre is the whole reason the construction avoids the
    // head and tail (logos, cold opens, credits), so it needs its own check:
    // wrap the fixture's own 120 s in 90 s of silence either side and the
    // signature of the 300 s file must be the golden value, byte for byte.
    // Nothing else pins the seek offset — a head-anchored window would pass
    // every other test in this file.
    const std::optional<std::vector<float>>& mono = fixture_window();
    REQUIRE(mono.has_value());

    const std::size_t pad = 90 * kSampleRate;
    std::vector<std::int16_t> padded(pad * 2 + mono->size(), 0);
    for (std::size_t i = 0; i < mono->size(); ++i)
        padded[pad + i] = static_cast<std::int16_t>(std::llround(double((*mono)[i]) * 32768.0));

    TempWav w("centred300");
    w.write(padded, kSampleRate, 1);

    const std::optional<std::string> sig = compute_signature(w.path.string());
    REQUIRE(sig.has_value());
    CHECK(*sig == golden()["signature"].get<std::string>());
}

// ── UT-103 — downmix and resample ───────────────────────────────────────────

/// TRACES: UT-103 | IR-004
TEST_CASE("stereo, non-native sample rate yields the same peak-bin sequence",
          "[audio_signature][resample]") {
    // The golden fixture is already mono at 11025 Hz so the golden vector does
    // not depend on the resampler's version. This case exercises the path that
    // real media takes — downmix plus resample — by rebuilding the fixture's own
    // audio as 22050 Hz stereo and checking the peak bins survive it.
    const std::optional<std::vector<float>>& mono = fixture_window();
    REQUIRE(mono.has_value());

    std::vector<std::int16_t> stereo;
    stereo.reserve(mono->size() * 4);
    for (float f : *mono) {
        const auto s = static_cast<std::int16_t>(std::llround(double(f) * 32768.0));
        stereo.push_back(s); stereo.push_back(s);   // sample 1, L/R
        stereo.push_back(s); stereo.push_back(s);   // sample 2 (zero-order hold)
    }
    TempWav w("stereo22050");
    w.write(stereo, 2 * kSampleRate, 2);

    const std::optional<std::string> sig = compute_signature(w.path.string());
    REQUIRE(sig.has_value());

    const std::vector<std::uint8_t> got  = base64_decode(sig->substr(3));
    const std::vector<std::uint8_t> want =
        base64_decode(golden()["signature"].get<std::string>().substr(3));
    REQUIRE(got.size() == want.size());

    std::size_t agree = 0;
    for (std::size_t i = 0; i < got.size(); ++i)
        agree += ((got[i] >> 2) == (want[i] >> 2)) ? 1 : 0;
    // The server treats ≥ 0.85 as the `audio` match tier; this path scores 1.0
    // in practice, and the margin is left for libswresample version drift.
    CHECK(double(agree) / double(got.size()) >= 0.85);
}

// ── UT-104 — the pure DSP surface ───────────────────────────────────────────

/// TRACES: UT-104 | IR-004
TEST_CASE("pack_frames uses whole frames only", "[audio_signature][dsp]") {
    CHECK(pack_frames(std::vector<float>(kFrameSize - 1, 0.f)).empty());
    CHECK(pack_frames(std::vector<float>(kFrameSize, 0.f)).size() == 1);
    CHECK(pack_frames(std::vector<float>(kFrameSize + kHopSize - 1, 0.f)).size() == 1);
    CHECK(pack_frames(std::vector<float>(kFrameSize + kHopSize, 0.f)).size() == 2);
    // The full 120 s window is 1288 frames — asserted as a constant rather than
    // by running the DSP over 1.3 M zeros, which is the same claim for free.
    CHECK(kWindowSamples == 1323000u);
    CHECK(kExpectedFrames == 1288u);
    CHECK_FALSE(signature_from_mono(std::vector<float>(kFrameSize - 1, 0.f)).has_value());
}

/// TRACES: UT-104 | IR-004
TEST_CASE("a pure tone lands in the band that contains it", "[audio_signature][dsp]") {
    // 1000 Hz sits in log-band floor(32 * log10(1000/300)) = 16.
    const int expect = static_cast<int>(std::floor(
        kNumBands * std::log10(1000.0 / kBandLoHz) / std::log10(kBandHiHz / kBandLoHz)));
    std::vector<float> mono(kWindowSamples / 100);
    for (std::size_t i = 0; i < mono.size(); ++i)
        mono[i] = static_cast<float>(0.5 * std::sin(
            2.0 * 3.14159265358979323846 * 1000.0 * double(i) / kSampleRate));
    const std::vector<std::uint8_t> packed = pack_frames(mono);
    REQUIRE_FALSE(packed.empty());
    for (std::uint8_t b : packed) CHECK(((b >> 2) & 0x1F) == expect);
}

/// TRACES: UT-104 | IR-004
TEST_CASE("signature is invariant to overall gain", "[audio_signature][dsp]") {
    // Loudness normalisation between two releases of the same cut must not
    // change the signature — that is why the energy class is relative.
    std::vector<float> a(kWindowSamples / 50);
    for (std::size_t i = 0; i < a.size(); ++i) {
        const double t = double(i) / kSampleRate;
        a[i] = static_cast<float>(0.4 * std::sin(2.0 * 3.14159265358979323846 * 640.0 * t) +
                                  0.2 * std::sin(2.0 * 3.14159265358979323846 * 1900.0 * t) *
                                        std::sin(2.0 * 3.14159265358979323846 * 0.7 * t));
    }
    std::vector<float> b(a.size());
    for (std::size_t i = 0; i < a.size(); ++i) b[i] = a[i] * 0.25f;
    CHECK(pack_frames(a) == pack_frames(b));
}

/// TRACES: UT-104 | IR-004
TEST_CASE("base64 encoder matches the standard alphabet and padding",
          "[audio_signature][dsp]") {
    auto enc = [](const std::string& s) {
        return base64_encode(reinterpret_cast<const std::uint8_t*>(s.data()), s.size());
    };
    CHECK(enc("") == "");
    CHECK(enc("f") == "Zg==");
    CHECK(enc("fo") == "Zm8=");
    CHECK(enc("foo") == "Zm9v");
    CHECK(enc("foob") == "Zm9vYg==");
    CHECK(enc("fooba") == "Zm9vYmE=");
    CHECK(enc("foobar") == "Zm9vYmFy");
    const std::uint8_t all[] = {0xFB, 0xFF, 0xBF};      // exercises '+' and '/'
    CHECK(base64_encode(all, 3) == "+/+/");
}
