// ============================================================================
// test/test_ffps/test_ffps.cpp
//
// Unity tests for src/ffps.{h,cpp} — Federated False-Positive Suppression,
// the multi-sensor corroboration gate that decides whether a candidate
// detection gets promoted (published as a real alert) or suppressed. This
// is the single most safety-relevant piece of logic in the FFPS/detection
// pipeline: a bug here either drops a real threat or lets an uncorroborated
// false positive through. It had zero test coverage and unconditionally
// `#include <Arduino.h>`'d, so it could not be compiled natively. Applied
// the same #ifdef ARDUINO / native-stub pattern used elsewhere in this
// sweep (network_id.cpp, threat_correlator.cpp, blue_force.cpp).
//
// Writing these tests surfaced a real bug, fixed in ffps.cpp: corroboration
// counting had no dedup by sensor_id. A single peer's CORROBORATE packet
// getting duplicated (multi-hop relay rebroadcast, a retried TX -- both
// routine on a LoRa mesh) incremented the count once per delivery, not
// once per genuinely independent corroborating sensor. Harmless today
// (FFPS_MIN_CORROBORATORS=1 -- one real corroboration already satisfies
// K=1), but would silently defeat the whole point of "federated"
// suppression the moment K is ever raised, since one peer's retransmitted
// packet could satisfy K=2+ without a second independent sensor ever
// agreeing. See test_duplicate_corroboration_from_same_sensor_not_double_
// counted below. Also removed a redundant, provably-unreachable "promote
// at window expiry" branch in ffps_tick() (the earlier early-promotion
// check already returns for any count >= threshold, so by construction
// the window-expiry branch can only ever be a suppression).
//
// Serialize/deserialize stubs below faithfully replicate mesh_publish.cpp's
// real wire format (read directly from its source -- big-endian fixed
// layout, DETECTION_PACKET_SIZE=33 bytes) rather than a trivial echo, so
// these tests exercise ffps.cpp's actual packet-parsing logic realistically.
// mesh_publish.cpp itself is not included here (it unconditionally pulls in
// Arduino.h + SPI.h + the real SX1262 radio driver -- out of scope for this
// pass, same reasoning already applied elsewhere in this codebase's tests).
//
// Run with:
//   pio test -e native -f test_ffps
// ============================================================================

#include <unity.h>
#include <cstring>

#define STUB_MILLIS_PROVIDED
static uint32_t g_stub_millis = 1000;
static inline uint32_t millis() { return g_stub_millis; }

// ffps.cpp does `#include "mesh_publish.h"`, which unconditionally pulls in
// RadioLib.h (the real SX1262 driver) -- not native-includable, and out of
// scope to fix here (same reasoning as elsewhere in this sweep: the radio
// driver itself is a much larger, separate concern). Pre-define its include
// guard so the preprocessor skips its body entirely, then declare exactly
// the four function signatures ffps.cpp actually calls, matching
// config.h's DetectionEvent -- config.h itself IS native-safe and gets
// included normally via ffps.h.
#define MESH_PUBLISH_H
#include "../../src/config.h"
bool mesh_publish_raw(const uint8_t* data, size_t len);
void mesh_start_receive();
size_t mesh_serialize_event(const DetectionEvent& evt, uint8_t* buf, size_t buf_len);
bool mesh_deserialize_event(const uint8_t* buf, size_t len, DetectionEvent& evt);

// ---------------------------------------------------------------------------
// mesh_publish.h stubs -- serialize/deserialize faithfully mirror the real
// wire format in mesh_publish.cpp (big-endian fixed layout). publish_raw
// and start_receive are simple test-observable stubs.
// ---------------------------------------------------------------------------
static bool    g_publish_called = false;
static uint8_t g_publish_buf[64];
static size_t  g_publish_len = 0;
static int     g_publish_call_count = 0;
static bool    g_publish_fail = false;

bool mesh_publish_raw(const uint8_t* data, size_t len) {
    g_publish_called = true;
    g_publish_call_count++;
    size_t copy = len < sizeof(g_publish_buf) ? len : sizeof(g_publish_buf);
    memcpy(g_publish_buf, data, copy);
    g_publish_len = len;
    return !g_publish_fail;
}

static int g_start_receive_calls = 0;
void mesh_start_receive() { g_start_receive_calls++; }

size_t mesh_serialize_event(const DetectionEvent& evt, uint8_t* buf, size_t buf_len) {
    if (buf_len < DETECTION_PACKET_SIZE) return 0;
    size_t pos = 0;
    buf[pos++] = evt.msg_type;
    buf[pos++] = (evt.sensor_id >> 24) & 0xFF;
    buf[pos++] = (evt.sensor_id >> 16) & 0xFF;
    buf[pos++] = (evt.sensor_id >> 8)  & 0xFF;
    buf[pos++] = (evt.sensor_id)       & 0xFF;
    buf[pos++] = (evt.timestamp_utc >> 24) & 0xFF;
    buf[pos++] = (evt.timestamp_utc >> 16) & 0xFF;
    buf[pos++] = (evt.timestamp_utc >> 8)  & 0xFF;
    buf[pos++] = (evt.timestamp_utc)       & 0xFF;
    buf[pos++] = evt.class_label;
    buf[pos++] = evt.confidence;
    buf[pos++] = (evt.rssi >> 8) & 0xFF;
    buf[pos++] = (evt.rssi)      & 0xFF;
    buf[pos++] = (evt.noise_floor >> 8) & 0xFF;
    buf[pos++] = (evt.noise_floor)      & 0xFF;
    memcpy(&buf[pos], evt.signature_hash, 8);
    pos += 8;
    buf[pos++] = (evt.lat_fixed >> 24) & 0xFF;
    buf[pos++] = (evt.lat_fixed >> 16) & 0xFF;
    buf[pos++] = (evt.lat_fixed >> 8)  & 0xFF;
    buf[pos++] = (evt.lat_fixed)       & 0xFF;
    buf[pos++] = (evt.lon_fixed >> 24) & 0xFF;
    buf[pos++] = (evt.lon_fixed >> 16) & 0xFF;
    buf[pos++] = (evt.lon_fixed >> 8)  & 0xFF;
    buf[pos++] = (evt.lon_fixed)       & 0xFF;
    buf[pos++] = evt.battery_pct;
    buf[pos++] = evt.flags;
    return pos;
}

bool mesh_deserialize_event(const uint8_t* buf, size_t len, DetectionEvent& evt) {
    if (len < DETECTION_PACKET_SIZE) return false;
    size_t pos = 0;
    evt.msg_type = buf[pos++];
    evt.sensor_id = ((uint32_t)buf[pos] << 24) | ((uint32_t)buf[pos+1] << 16) |
                    ((uint32_t)buf[pos+2] << 8) | buf[pos+3];
    pos += 4;
    evt.timestamp_utc = ((uint32_t)buf[pos] << 24) | ((uint32_t)buf[pos+1] << 16) |
                        ((uint32_t)buf[pos+2] << 8) | buf[pos+3];
    pos += 4;
    evt.class_label = buf[pos++];
    evt.confidence  = buf[pos++];
    evt.rssi = (int16_t)(((uint16_t)buf[pos] << 8) | buf[pos+1]);
    pos += 2;
    evt.noise_floor = (int16_t)(((uint16_t)buf[pos] << 8) | buf[pos+1]);
    pos += 2;
    memcpy(evt.signature_hash, &buf[pos], 8);
    pos += 8;
    evt.lat_fixed = ((int32_t)buf[pos] << 24) | ((int32_t)buf[pos+1] << 16) |
                    ((int32_t)buf[pos+2] << 8) | buf[pos+3];
    pos += 4;
    evt.lon_fixed = ((int32_t)buf[pos] << 24) | ((int32_t)buf[pos+1] << 16) |
                    ((int32_t)buf[pos+2] << 8) | buf[pos+3];
    pos += 4;
    evt.battery_pct = buf[pos++];
    evt.flags       = buf[pos++];
    return true;
}

#include "../../src/ffps.cpp"

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
static DetectionEvent make_candidate(uint32_t sensor_id, uint8_t class_label,
                                      uint8_t confidence, const uint8_t sig_hash[8]) {
    DetectionEvent e{};
    e.sensor_id = sensor_id;
    e.class_label = class_label;
    e.confidence = confidence;
    e.timestamp_utc = 12345;
    if (sig_hash) memcpy(e.signature_hash, sig_hash, 8);
    return e;
}

// Builds a raw wire-format CORROBORATE packet from a peer, matching the
// active candidate's signature hash, ready to feed into ffps_handle_packet.
static size_t build_corroborate_wire(uint8_t* out, uint32_t peer_sensor_id,
                                      const uint8_t sig_hash[8], uint8_t class_label = 1) {
    DetectionEvent reply{};
    reply.msg_type = MSG_TYPE_CORROBORATE;
    reply.sensor_id = peer_sensor_id;
    reply.class_label = class_label;
    memcpy(reply.signature_hash, sig_hash, 8);
    return mesh_serialize_event(reply, out, DETECTION_PACKET_SIZE);
}

static const uint8_t kSigHash[8] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08};

void setUp(void) {
    g_stub_millis = 1000;
    g_publish_called = false;
    g_publish_call_count = 0;
    g_publish_len = 0;
    g_publish_fail = false;
    g_start_receive_calls = 0;
    memset(g_publish_buf, 0, sizeof(g_publish_buf));
    ffps_init(0xAAAAAAAAu);
    ffps_reset();
}
void tearDown(void) {}

// ---------------------------------------------------------------------------
// Direct-decode classes bypass corroboration entirely
// ---------------------------------------------------------------------------
void test_direct_decode_class_promotes_immediately(void) {
    DetectionEvent c = make_candidate(0xAAAAAAAAu, CLASS_RID_BLE_DECODED, 200, kSigHash);
    bool ok = ffps_submit_candidate(c);
    TEST_ASSERT_TRUE(ok);
    TEST_ASSERT_EQUAL(FFPS_PROMOTED, ffps_tick());
    TEST_ASSERT_EQUAL_UINT8(FLAG_PROMOTED, ffps_get_promoted_event().flags & FLAG_PROMOTED);
    // Direct-decode promotion must not broadcast a CANDIDATE -- it never
    // needed corroboration in the first place.
    TEST_ASSERT_FALSE(g_publish_called);
}

// ---------------------------------------------------------------------------
// Normal (inference) candidates go through the corroboration window
// ---------------------------------------------------------------------------
void test_normal_candidate_broadcasts_and_waits(void) {
    DetectionEvent c = make_candidate(0xAAAAAAAAu, CLASS_OCUSYNC, 200, kSigHash);
    bool ok = ffps_submit_candidate(c);
    TEST_ASSERT_TRUE(ok);
    TEST_ASSERT_TRUE(g_publish_called);
    TEST_ASSERT_EQUAL_UINT8(MSG_TYPE_CANDIDATE, g_publish_buf[0]);
    TEST_ASSERT_EQUAL(FFPS_WAITING_CORROBORATION, ffps_tick());
}

void test_second_candidate_while_waiting_is_rejected(void) {
    DetectionEvent c1 = make_candidate(0xAAAAAAAAu, CLASS_OCUSYNC, 200, kSigHash);
    ffps_submit_candidate(c1);
    DetectionEvent c2 = make_candidate(0xAAAAAAAAu, CLASS_FPV_FHSS, 180, kSigHash);
    TEST_ASSERT_FALSE(ffps_submit_candidate(c2));
}

// ---------------------------------------------------------------------------
// Corroboration -> promotion
// ---------------------------------------------------------------------------
void test_one_corroborator_promotes_at_min_k_1(void) {
    DetectionEvent c = make_candidate(0xAAAAAAAAu, CLASS_OCUSYNC, 200, kSigHash);
    ffps_submit_candidate(c);

    uint8_t wire[DETECTION_PACKET_SIZE];
    size_t len = build_corroborate_wire(wire, /*peer=*/0xBBBBBBBBu, kSigHash);
    ffps_handle_packet(wire, len, -60.0f);

    TEST_ASSERT_EQUAL_UINT8(1, ffps_get_corroboration_count());
    TEST_ASSERT_EQUAL(FFPS_PROMOTED, ffps_tick());
    TEST_ASSERT_TRUE(ffps_get_promoted_event().flags & FLAG_PROMOTED);
}

void test_corroboration_from_self_is_ignored(void) {
    DetectionEvent c = make_candidate(0xAAAAAAAAu, CLASS_OCUSYNC, 200, kSigHash);
    ffps_submit_candidate(c);

    uint8_t wire[DETECTION_PACKET_SIZE];
    // Same sensor_id as ffps_init's own id -- must not self-corroborate.
    size_t len = build_corroborate_wire(wire, /*peer=*/0xAAAAAAAAu, kSigHash);
    ffps_handle_packet(wire, len, -60.0f);

    TEST_ASSERT_EQUAL_UINT8(0, ffps_get_corroboration_count());
}

void test_corroboration_with_mismatched_hash_is_ignored(void) {
    DetectionEvent c = make_candidate(0xAAAAAAAAu, CLASS_OCUSYNC, 200, kSigHash);
    ffps_submit_candidate(c);

    uint8_t wrong_hash[8] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
    uint8_t wire[DETECTION_PACKET_SIZE];
    size_t len = build_corroborate_wire(wire, 0xBBBBBBBBu, wrong_hash);
    ffps_handle_packet(wire, len, -60.0f);

    TEST_ASSERT_EQUAL_UINT8(0, ffps_get_corroboration_count());
}

// Regression test for the real bug this pass fixed: a duplicated
// CORROBORATE from the SAME peer sensor must not inflate the count.
void test_duplicate_corroboration_from_same_sensor_not_double_counted(void) {
    DetectionEvent c = make_candidate(0xAAAAAAAAu, CLASS_OCUSYNC, 200, kSigHash);
    ffps_submit_candidate(c);

    uint8_t wire[DETECTION_PACKET_SIZE];
    size_t len = build_corroborate_wire(wire, /*peer=*/0xBBBBBBBBu, kSigHash);
    // Same peer's packet delivered twice (mesh relay rebroadcast / retry).
    ffps_handle_packet(wire, len, -60.0f);
    ffps_handle_packet(wire, len, -61.0f);

    TEST_ASSERT_EQUAL_UINT8_MESSAGE(1, ffps_get_corroboration_count(),
        "a duplicate CORROBORATE from the same sensor_id must not be counted twice");
}

void test_two_distinct_corroborators_both_counted(void) {
    DetectionEvent c = make_candidate(0xAAAAAAAAu, CLASS_OCUSYNC, 200, kSigHash);
    ffps_submit_candidate(c);

    uint8_t wire1[DETECTION_PACKET_SIZE], wire2[DETECTION_PACKET_SIZE];
    size_t len1 = build_corroborate_wire(wire1, 0xBBBBBBBBu, kSigHash);
    size_t len2 = build_corroborate_wire(wire2, 0xCCCCCCCCu, kSigHash);
    ffps_handle_packet(wire1, len1, -60.0f);
    ffps_handle_packet(wire2, len2, -60.0f);

    TEST_ASSERT_EQUAL_UINT8_MESSAGE(2, ffps_get_corroboration_count(),
        "two genuinely distinct peer sensors must both be counted");
}

// ---------------------------------------------------------------------------
// Window expiry -> suppression
// ---------------------------------------------------------------------------
void test_no_corroboration_suppresses_at_window_expiry(void) {
    DetectionEvent c = make_candidate(0xAAAAAAAAu, CLASS_OCUSYNC, 200, kSigHash);
    g_stub_millis = 1000;
    ffps_submit_candidate(c);

    g_stub_millis = 1000 + FFPS_CORROBORATION_WINDOW_MS + 1;
    TEST_ASSERT_EQUAL(FFPS_SUPPRESSED, ffps_tick());
    TEST_ASSERT_TRUE(ffps_get_promoted_event().flags & FLAG_SUPPRESSED);
}

void test_still_waiting_before_window_expiry(void) {
    DetectionEvent c = make_candidate(0xAAAAAAAAu, CLASS_OCUSYNC, 200, kSigHash);
    g_stub_millis = 1000;
    ffps_submit_candidate(c);

    g_stub_millis = 1000 + FFPS_CORROBORATION_WINDOW_MS - 1;
    TEST_ASSERT_EQUAL(FFPS_WAITING_CORROBORATION, ffps_tick());
}

// ---------------------------------------------------------------------------
// Peer relay: hearing another sensor's CANDIDATE
// ---------------------------------------------------------------------------
void test_hearing_peer_candidate_sends_corroboration(void) {
    // We are IDLE (no candidate of our own) -- a peer's CANDIDATE for a
    // non-noise class should trigger us to reply with CORROBORATE.
    DetectionEvent peer_candidate{};
    peer_candidate.msg_type = MSG_TYPE_CANDIDATE;
    peer_candidate.sensor_id = 0xBBBBBBBBu;   // different from our own 0xAAAAAAAA
    peer_candidate.class_label = CLASS_OCUSYNC;
    peer_candidate.confidence = 180;
    memcpy(peer_candidate.signature_hash, kSigHash, 8);

    uint8_t wire[DETECTION_PACKET_SIZE];
    size_t len = mesh_serialize_event(peer_candidate, wire, sizeof(wire));

    ffps_handle_packet(wire, len, -55.0f);

    TEST_ASSERT_TRUE_MESSAGE(g_publish_called, "must reply with a corroboration for a peer's non-noise candidate");
    TEST_ASSERT_EQUAL_UINT8(MSG_TYPE_CORROBORATE, g_publish_buf[0]);
}

void test_hearing_own_candidate_echo_is_ignored(void) {
    DetectionEvent echo{};
    echo.msg_type = MSG_TYPE_CANDIDATE;
    echo.sensor_id = 0xAAAAAAAAu;   // our own id
    echo.class_label = CLASS_OCUSYNC;
    memcpy(echo.signature_hash, kSigHash, 8);

    uint8_t wire[DETECTION_PACKET_SIZE];
    size_t len = mesh_serialize_event(echo, wire, sizeof(wire));
    ffps_handle_packet(wire, len, -55.0f);

    TEST_ASSERT_FALSE(g_publish_called);
}

void test_hearing_peer_noise_candidate_does_not_corroborate(void) {
    DetectionEvent peer_candidate{};
    peer_candidate.msg_type = MSG_TYPE_CANDIDATE;
    peer_candidate.sensor_id = 0xBBBBBBBBu;
    peer_candidate.class_label = CLASS_NOISE;
    memcpy(peer_candidate.signature_hash, kSigHash, 8);

    uint8_t wire[DETECTION_PACKET_SIZE];
    size_t len = mesh_serialize_event(peer_candidate, wire, sizeof(wire));
    ffps_handle_packet(wire, len, -55.0f);

    TEST_ASSERT_FALSE(g_publish_called);
}

// ---------------------------------------------------------------------------
// ffps_reset
// ---------------------------------------------------------------------------
void test_reset_returns_to_idle_and_clears_count(void) {
    DetectionEvent c = make_candidate(0xAAAAAAAAu, CLASS_OCUSYNC, 200, kSigHash);
    ffps_submit_candidate(c);
    uint8_t wire[DETECTION_PACKET_SIZE];
    size_t len = build_corroborate_wire(wire, 0xBBBBBBBBu, kSigHash);
    ffps_handle_packet(wire, len, -60.0f);
    TEST_ASSERT_EQUAL_UINT8(1, ffps_get_corroboration_count());

    ffps_reset();
    TEST_ASSERT_EQUAL_UINT8(0, ffps_get_corroboration_count());
    TEST_ASSERT_EQUAL(FFPS_IDLE, ffps_tick());
}

int main(int, char**) {
    UNITY_BEGIN();

    RUN_TEST(test_direct_decode_class_promotes_immediately);

    RUN_TEST(test_normal_candidate_broadcasts_and_waits);
    RUN_TEST(test_second_candidate_while_waiting_is_rejected);

    RUN_TEST(test_one_corroborator_promotes_at_min_k_1);
    RUN_TEST(test_corroboration_from_self_is_ignored);
    RUN_TEST(test_corroboration_with_mismatched_hash_is_ignored);
    RUN_TEST(test_duplicate_corroboration_from_same_sensor_not_double_counted);
    RUN_TEST(test_two_distinct_corroborators_both_counted);

    RUN_TEST(test_no_corroboration_suppresses_at_window_expiry);
    RUN_TEST(test_still_waiting_before_window_expiry);

    RUN_TEST(test_hearing_peer_candidate_sends_corroboration);
    RUN_TEST(test_hearing_own_candidate_echo_is_ignored);
    RUN_TEST(test_hearing_peer_noise_candidate_does_not_corroborate);

    RUN_TEST(test_reset_returns_to_idle_and_clears_count);

    return UNITY_END();
}
