// ============================================================================
// test/test_hidden_tx_scanner/test_hidden_tx_scanner.cpp
//
// Unity tests for src/hidden_tx_scanner.{h,cpp} -- whitehat continuous-
// carrier / hidden-camera scanner. Directly maps to the standing charter's
// request to build out more whitehat/diagnostic capability. This module
// has ZERO hardware dependencies (no Arduino, no Serial, no radio calls --
// pure per-bin streak tracking over an already-captured spectrum snapshot),
// so unlike most of this sweep it needed no #ifdef ARDUINO restructuring at
// all; it compiles natively as-is.
//
// The module's own header comment documents a REAL bug found via actual
// hardware testing in a busy indoor environment (13-16 nearby BLE/WiFi
// devices): the first version flagged all 128 bins as suspects because
// EWMA-smoothed occupancy LEVEL alone can't distinguish "genuine continuous
// carrier" from "a busy-but-packetized real channel that stays elevated
// after smoothing." The shipped fix tracks variance (min/max range) over
// the sustained window, not just level -- these tests directly verify that
// fix (test_busy_fluctuating_channel_not_flagged) plus the general streak/
// threshold/reset/saturation logic around it.
//
// Run with:
//   pio test -e native -f test_hidden_tx_scanner
// ============================================================================

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

#include "../../src/hidden_tx_scanner.cpp"

void setUp(void) {
    hidden_tx_scanner_init();
}

void tearDown(void) {}

// ── helpers ───────────────────────────────────────────────────────────────

static std::vector<float> flat_bins(size_t n, float value) {
    return std::vector<float>(n, value);
}

// ── init / reset ──────────────────────────────────────────────────────────

void test_init_starts_with_no_suspects(void) {
    HiddenTxStats stats;
    hidden_tx_scanner_get_stats(&stats);
    TEST_ASSERT_EQUAL_UINT16(0, stats.active_suspects);
    TEST_ASSERT_EQUAL_UINT32(0, stats.longest_sustained_seconds);
}

void test_init_clears_previously_flagged_bins(void) {
    auto bins = flat_bins(128, 0.90f);  // constant, above threshold
    for (int t = 0; t < HTX_SUSTAINED_TICKS_FLAG; t++) {
        hidden_tx_scanner_observe(bins.data(), bins.size());
    }
    HiddenTxStats before;
    hidden_tx_scanner_get_stats(&before);
    TEST_ASSERT_TRUE(before.active_suspects > 0);

    hidden_tx_scanner_init();

    HiddenTxStats after;
    hidden_tx_scanner_get_stats(&after);
    TEST_ASSERT_EQUAL_UINT16(0, after.active_suspects);
    HiddenTxFinding findings[HTX_MAX_FINDINGS];
    TEST_ASSERT_EQUAL_UINT32(0, (uint32_t)hidden_tx_scanner_list_findings(findings, HTX_MAX_FINDINGS));
}

// ── threshold / sustained-duration gating ────────────────────────────────

void test_below_threshold_never_flagged(void) {
    auto bins = flat_bins(128, 0.30f);  // well under HTX_OCCUPANCY_THRESHOLD
    for (int t = 0; t < 100; t++) hidden_tx_scanner_observe(bins.data(), bins.size());
    HiddenTxStats stats;
    hidden_tx_scanner_get_stats(&stats);
    TEST_ASSERT_EQUAL_UINT16(0, stats.active_suspects);
}

void test_above_threshold_but_not_yet_sustained_not_flagged(void) {
    auto bins = flat_bins(128, 0.90f);
    for (int t = 0; t < HTX_SUSTAINED_TICKS_FLAG - 1; t++) {
        hidden_tx_scanner_observe(bins.data(), bins.size());
    }
    HiddenTxStats stats;
    hidden_tx_scanner_get_stats(&stats);
    TEST_ASSERT_EQUAL_UINT16(0, stats.active_suspects);
}

void test_sustained_stable_carrier_gets_flagged(void) {
    // Genuine continuous-carrier signature: elevated AND nearly constant.
    auto bins = flat_bins(128, 0.90f);
    for (int t = 0; t < HTX_SUSTAINED_TICKS_FLAG; t++) {
        hidden_tx_scanner_observe(bins.data(), bins.size());
    }
    HiddenTxStats stats;
    hidden_tx_scanner_get_stats(&stats);
    TEST_ASSERT_EQUAL_UINT16(128, stats.active_suspects);
    TEST_ASSERT_EQUAL_UINT32(HTX_SUSTAINED_TICKS_FLAG, stats.longest_sustained_seconds);
}

// ── the real bug fix: variance, not just level, distinguishes a genuine
// carrier from a busy-but-packetized real channel ─────────────────────────

void test_busy_fluctuating_channel_not_flagged(void) {
    // Same elevated LEVEL as the stable-carrier test above, but wobbling
    // by +-0.10 around 0.90 -- exceeds HTX_MAX_RANGE_FOR_CARRIER (0.05),
    // exactly the real indoor-environment scenario the module's header
    // comment documents as the original false-positive bug.
    for (int t = 0; t < HTX_SUSTAINED_TICKS_FLAG + 5; t++) {
        float v = (t % 2 == 0) ? 0.85f : 0.95f;
        auto bins = flat_bins(128, v);
        hidden_tx_scanner_observe(bins.data(), bins.size());
    }
    HiddenTxStats stats;
    hidden_tx_scanner_get_stats(&stats);
    TEST_ASSERT_EQUAL_UINT16(0, stats.active_suspects);
}

void test_fluctuation_within_tolerance_still_flagged(void) {
    // Wobble that stays inside HTX_MAX_RANGE_FOR_CARRIER (0.05) should
    // still read as "nearly constant" and get flagged -- proves the
    // variance check isn't accidentally requiring bit-exact stability.
    for (int t = 0; t < HTX_SUSTAINED_TICKS_FLAG + 5; t++) {
        float v = (t % 2 == 0) ? 0.90f : 0.93f;  // range 0.03 < 0.05
        auto bins = flat_bins(128, v);
        hidden_tx_scanner_observe(bins.data(), bins.size());
    }
    HiddenTxStats stats;
    hidden_tx_scanner_get_stats(&stats);
    TEST_ASSERT_EQUAL_UINT16(128, stats.active_suspects);
}

// ── streak reset behavior ────────────────────────────────────────────────

void test_dip_below_threshold_resets_streak(void) {
    auto hot  = flat_bins(128, 0.90f);
    auto cold = flat_bins(128, 0.10f);

    for (int t = 0; t < HTX_SUSTAINED_TICKS_FLAG - 1; t++) {
        hidden_tx_scanner_observe(hot.data(), hot.size());
    }
    // One tick below threshold breaks the streak.
    hidden_tx_scanner_observe(cold.data(), cold.size());
    // Even if it goes right back to hot, the streak restarts from zero.
    hidden_tx_scanner_observe(hot.data(), hot.size());

    HiddenTxStats stats;
    hidden_tx_scanner_get_stats(&stats);
    TEST_ASSERT_EQUAL_UINT16(0, stats.active_suspects);  // needs 45 MORE ticks now
}

void test_range_tracking_restarts_on_new_streak(void) {
    // First streak wobbles a lot (would fail the variance check if it
    // "leaked" into a second streak); after a reset dip, a second, truly
    // stable streak should be judged purely on its own range.
    for (int t = 0; t < HTX_SUSTAINED_TICKS_FLAG; t++) {
        float v = (t % 2 == 0) ? 0.70f : 0.99f;  // wide range
        auto bins = flat_bins(128, v);
        hidden_tx_scanner_observe(bins.data(), bins.size());
    }
    auto cold = flat_bins(128, 0.10f);
    hidden_tx_scanner_observe(cold.data(), cold.size());  // reset

    auto stable = flat_bins(128, 0.90f);
    for (int t = 0; t < HTX_SUSTAINED_TICKS_FLAG; t++) {
        hidden_tx_scanner_observe(stable.data(), stable.size());
    }

    HiddenTxStats stats;
    hidden_tx_scanner_get_stats(&stats);
    TEST_ASSERT_EQUAL_UINT16(128, stats.active_suspects);
}

// ── longest_sustained_seconds is a persistent high-water mark ───────────

void test_longest_sustained_persists_after_streak_ends(void) {
    auto hot  = flat_bins(128, 0.90f);
    auto cold = flat_bins(128, 0.10f);

    for (int t = 0; t < HTX_SUSTAINED_TICKS_FLAG + 10; t++) {
        hidden_tx_scanner_observe(hot.data(), hot.size());
    }
    HiddenTxStats mid;
    hidden_tx_scanner_get_stats(&mid);
    TEST_ASSERT_EQUAL_UINT32(HTX_SUSTAINED_TICKS_FLAG + 10, mid.longest_sustained_seconds);

    hidden_tx_scanner_observe(cold.data(), cold.size());  // streak ends

    HiddenTxStats after;
    hidden_tx_scanner_get_stats(&after);
    TEST_ASSERT_EQUAL_UINT16(0, after.active_suspects);
    // High-water mark is NOT reset just because the streak ended.
    TEST_ASSERT_EQUAL_UINT32(HTX_SUSTAINED_TICKS_FLAG + 10, after.longest_sustained_seconds);
}

// ── findings list ─────────────────────────────────────────────────────────

void test_list_findings_maps_bin_to_frequency(void) {
    auto bins = flat_bins(128, 0.90f);
    for (int t = 0; t < HTX_SUSTAINED_TICKS_FLAG; t++) {
        hidden_tx_scanner_observe(bins.data(), bins.size());
    }
    HiddenTxFinding findings[HTX_MAX_FINDINGS];
    size_t n = hidden_tx_scanner_list_findings(findings, HTX_MAX_FINDINGS);
    TEST_ASSERT_EQUAL_UINT32(HTX_MAX_FINDINGS, (uint32_t)n);  // capped, 128 flagged > 8 max
    // Bin 0 maps to 2400 MHz exactly (per the header's documented mapping).
    TEST_ASSERT_FLOAT_WITHIN(0.01f, 2400.0f, findings[0].freq_mhz);
}

void test_list_findings_respects_caller_cap(void) {
    auto bins = flat_bins(128, 0.90f);
    for (int t = 0; t < HTX_SUSTAINED_TICKS_FLAG; t++) {
        hidden_tx_scanner_observe(bins.data(), bins.size());
    }
    HiddenTxFinding findings[3];
    size_t n = hidden_tx_scanner_list_findings(findings, 3);
    TEST_ASSERT_EQUAL_UINT32(3, (uint32_t)n);
}

void test_list_findings_null_out_returns_zero(void) {
    TEST_ASSERT_EQUAL_UINT32(0, (uint32_t)hidden_tx_scanner_list_findings(nullptr, 8));
}

void test_list_findings_reports_current_occupancy(void) {
    auto bins = flat_bins(128, 0.91f);
    for (int t = 0; t < HTX_SUSTAINED_TICKS_FLAG; t++) {
        hidden_tx_scanner_observe(bins.data(), bins.size());
    }
    HiddenTxFinding findings[1];
    hidden_tx_scanner_list_findings(findings, 1);
    TEST_ASSERT_FLOAT_WITHIN(1e-5f, 0.91f, findings[0].occupancy);
}

// ── input safety ──────────────────────────────────────────────────────────

void test_null_bins_pointer_is_noop(void) {
    hidden_tx_scanner_observe(nullptr, 128);
    HiddenTxStats stats;
    hidden_tx_scanner_get_stats(&stats);
    TEST_ASSERT_EQUAL_UINT16(0, stats.active_suspects);
}

void test_oversized_n_bins_is_clamped_not_overrun(void) {
    // n_bins > HTX_MAX_BINS (128) must not read/write past the fixed
    // internal arrays -- ASan/UBSan would catch a real overrun here.
    std::vector<float> big(500, 0.90f);
    for (int t = 0; t < HTX_SUSTAINED_TICKS_FLAG; t++) {
        hidden_tx_scanner_observe(big.data(), big.size());
    }
    HiddenTxStats stats;
    hidden_tx_scanner_get_stats(&stats);
    TEST_ASSERT_EQUAL_UINT16(128, stats.active_suspects);  // clamped to HTX_MAX_BINS
}

void test_fewer_bins_than_max_only_updates_those(void) {
    std::vector<float> few(10, 0.90f);
    for (int t = 0; t < HTX_SUSTAINED_TICKS_FLAG; t++) {
        hidden_tx_scanner_observe(few.data(), few.size());
    }
    HiddenTxStats stats;
    hidden_tx_scanner_get_stats(&stats);
    TEST_ASSERT_EQUAL_UINT16(10, stats.active_suspects);  // only bins 0-9 ever saw data
}

// ── counter saturation ────────────────────────────────────────────────────

void test_consecutive_ticks_saturate_without_wrapping(void) {
    // s_consecutive_ticks is uint16_t; a real deployment could sustain a
    // streak past 65535 ticks (~18 hours at ~1Hz). Must clamp, not wrap
    // back to a small value that would spuriously un-flag the bin.
    auto bins = flat_bins(128, 0.90f);
    for (int t = 0; t < 70000; t++) {
        hidden_tx_scanner_observe(bins.data(), bins.size());
    }
    TEST_ASSERT_EQUAL_UINT16(0xFFFF, s_consecutive_ticks[0]);
    HiddenTxStats stats;
    hidden_tx_scanner_get_stats(&stats);
    TEST_ASSERT_EQUAL_UINT16(128, stats.active_suspects);  // still flagged, not un-flagged
}

// ── main ──────────────────────────────────────────────────────────────────

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

    RUN_TEST(test_init_starts_with_no_suspects);
    RUN_TEST(test_init_clears_previously_flagged_bins);

    RUN_TEST(test_below_threshold_never_flagged);
    RUN_TEST(test_above_threshold_but_not_yet_sustained_not_flagged);
    RUN_TEST(test_sustained_stable_carrier_gets_flagged);

    RUN_TEST(test_busy_fluctuating_channel_not_flagged);
    RUN_TEST(test_fluctuation_within_tolerance_still_flagged);

    RUN_TEST(test_dip_below_threshold_resets_streak);
    RUN_TEST(test_range_tracking_restarts_on_new_streak);

    RUN_TEST(test_longest_sustained_persists_after_streak_ends);

    RUN_TEST(test_list_findings_maps_bin_to_frequency);
    RUN_TEST(test_list_findings_respects_caller_cap);
    RUN_TEST(test_list_findings_null_out_returns_zero);
    RUN_TEST(test_list_findings_reports_current_occupancy);

    RUN_TEST(test_null_bins_pointer_is_noop);
    RUN_TEST(test_oversized_n_bins_is_clamped_not_overrun);
    RUN_TEST(test_fewer_bins_than_max_only_updates_those);

    RUN_TEST(test_consecutive_ticks_saturate_without_wrapping);

    return UNITY_END();
}
