#include "MeshTypes.h"
#include "TestUtil.h"
#include <unity.h>

#if HAS_VARIABLE_HOPS

#include "FSCommon.h"
#include "gps/RTC.h"
#include "mesh/NodeDB.h"
#include "modules/HopScalingModule.h"
#include <cstdio>
#include <cstring>
#include <memory>

// Unity only shows TEST_MESSAGE output. printf goes to stdout which the runner swallows.
#define MSG_BUF_LEN 200
#define TEST_MSG_FMT(fmt, ...)                                                                                                   \
    do {                                                                                                                         \
        char _buf[MSG_BUF_LEN];                                                                                                  \
        snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__);                                                                          \
        TEST_MESSAGE(_buf);                                                                                                      \
    } while (0)

static constexpr NodeNum kLocalNode = 0x11111111;

// Shared mock clock — drives HopScalingModule::nowMs()
static uint32_t &mockTime = HopScalingModule::s_testNowMs;
static constexpr uint32_t ONE_HOUR_MS = 3600UL * 1000UL;

// ---------------------------------------------------------------------------
// MockNodeDB — not used for hop decisions any more, kept for completeness
// ---------------------------------------------------------------------------
class MockNodeDB : public NodeDB
{
  public:
    void clearTestNodes()
    {
        testNodes.clear();
        numMeshNodes = 0;
    }

    void addTestNode(NodeNum num, uint8_t hopsAway, bool hasHops, uint32_t ageSecs, bool viaMqtt = false)
    {
        meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero;
        node.num = num;
        node.has_hops_away = hasHops;
        node.hops_away = hopsAway;
        nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_VIA_MQTT_MASK, viaMqtt);
        node.last_heard = getTime() - ageSecs;
        testNodes.push_back(node);
        meshNodes = &testNodes;
        numMeshNodes = testNodes.size();
    }

    std::vector<meshtastic_NodeInfoLite> testNodes;
};

// ---------------------------------------------------------------------------
// Test shim — expose protected/private members for direct invocation
// ---------------------------------------------------------------------------
class HopScalingTestShim : public HopScalingModule
{
  public:
    using HopScalingModule::runOnce;
    using HopScalingModule::samplePacketForHistogram;

    using HopScalingModule::getLastRequiredHop;

    // Test-only helpers (require UNIT_TEST friend access)
    void rollHourTest() { rollHour(); }
    void setHistogramDenominator(uint8_t d) { setSamplingDenominator(d); }

    /// Directly set denominator state, bypassing any scale-up/down logic.
    /// Used by tests that need a specific pre-condition without triggering trim.
    void forceFilterDenomState(uint8_t samp, uint8_t filt, uint8_t holdRolls)
    {
        samplingDenominator = samp;
        filteringDenominator = filt;
        filteringDenomHoldRollsRemaining = holdRolls;
    }
    uint8_t getFilteringDenomHoldRollsRemaining() const { return filteringDenomHoldRollsRemaining; }

    /// Insert an entry with an explicit hash, bypassing the sampling filter.
    /// Used to fill the histogram to a known state without depending on hashNodeId distribution.
    void forceInsertEntry(uint16_t hash, uint8_t hops)
    {
        if (count < CAPACITY) {
            entries[count].nodeHash = hash;
            entries[count].hops_away = hops;
            entries[count].seenHoursAgo = 1u;
            count++;
        }
    }

    // Size introspection for test_memory_layout
    static constexpr size_t sizeofSelf() { return sizeof(HopScalingModule); }
};

static MockNodeDB *mockNodeDB = nullptr;

// Create deterministic IDs that produce a broad spread of 16-bit hashes.
// HopScalingModule admission uses passesFilter(hashNodeId(nodeId), denom), NOT a raw nodeId
// modulo check — do not assume (nodeId & (denom-1)) == 0 determines whether a node is admitted.
static uint32_t makeDistributedNodeId(uint32_t baseId, uint32_t ordinal, uint32_t salt = 0)
{
    return baseId + salt + (ordinal * 33u);
}

// ---------------------------------------------------------------------------
// Helpers — mesh topology builders
// ---------------------------------------------------------------------------

// Helper: add N nodes at a given hop with ages spread across a time range.
static void addNodesAtHop(uint32_t baseId, uint8_t hop, uint32_t count, uint32_t ageSecs, uint32_t stride = 10)
{
    for (uint32_t i = 0; i < count; i++) {
        const uint32_t nodeId = makeDistributedNodeId(baseId, i, static_cast<uint32_t>(hop) << 8);
        mockNodeDB->addTestNode(nodeId, hop, true, ageSecs + i * stride);
    }
}

// Feed sampled traffic into the histogram.
// Advances mock clock by one hour per roll and calls rollHour() so each roll produces data.
static void injectSampleTraffic(HopScalingTestShim &shim, uint32_t baseId, const uint16_t hopDist[HOP_MAX + 1],
                                uint8_t numRolls = 16)
{
    shim.setHistogramDenominator(HopScalingModule::DENOM_MIN);

    for (uint8_t roll = 0; roll < numRolls; ++roll) {
        mockTime += ONE_HOUR_MS;

        uint16_t ordinal = 0;
        for (uint8_t hop = 0; hop <= HOP_MAX; ++hop) {
            for (uint16_t n = 0; n < hopDist[hop]; ++n) {
                const uint32_t nodeId = makeDistributedNodeId(baseId, ordinal);
                shim.samplePacketForHistogram(nodeId, hop);
                ++ordinal;
            }
        }
        shim.rollHourTest();
    }
}

static void assertCompactHistogramActive(HopScalingTestShim &shim)
{
    TEST_ASSERT_GREATER_THAN_UINT8(0, shim.getCompactHistogramEntryCount());
    TEST_ASSERT_TRUE(shim.getCompactHistogramAllSampleCount() > 0);
}

// ---------------------------------------------------------------------------
// Topology builders
// ---------------------------------------------------------------------------

// Scenario A: Dense local mesh — 110 nodes, heavy at hops 0–2.
static void buildDenseLocalMesh()
{
    mockNodeDB->clearTestNodes();
    addNodesAtHop(0x1000, 0, 25, 120);
    addNodesAtHop(0x2000, 1, 30, 300);
    addNodesAtHop(0x3000, 2, 15, 600);
    addNodesAtHop(0x4000, 3, 5, 1200);
    addNodesAtHop(0x5000, 4, 10, 1800);
    addNodesAtHop(0x6000, 5, 15, 2400);
    addNodesAtHop(0x7000, 6, 10, 3000);
}

// Scenario B: Spread sparse mesh — 76 nodes across hops 0–7.
static void buildSpreadSparseMesh()
{
    mockNodeDB->clearTestNodes();
    addNodesAtHop(0x1000, 0, 5, 120);
    addNodesAtHop(0x2000, 1, 8, 300);
    addNodesAtHop(0x3000, 2, 12, 600);
    addNodesAtHop(0x4000, 3, 15, 900);
    addNodesAtHop(0x5000, 4, 10, 1200);
    addNodesAtHop(0x6000, 5, 6, 1800);
    addNodesAtHop(0x7000, 6, 10, 3000);
    addNodesAtHop(0x8000, 7, 10, 3600);
}

// Scenario C: Deep linear chain — 22 thin nodes, never reaches 40.
static void buildDeepLinearChain()
{
    mockNodeDB->clearTestNodes();
    addNodesAtHop(0x1000, 0, 2, 120);
    addNodesAtHop(0x2000, 1, 3, 300);
    addNodesAtHop(0x3000, 2, 3, 600);
    addNodesAtHop(0x4000, 3, 4, 900);
    addNodesAtHop(0x5000, 4, 3, 1200);
    addNodesAtHop(0x6000, 5, 2, 1800);
    addNodesAtHop(0x7000, 6, 2, 2400);
    addNodesAtHop(0x8000, 7, 3, 3600);
}

// Scenario D: Router cluster — 71 nodes, 45 at hop 2.
static void buildRouterCluster()
{
    mockNodeDB->clearTestNodes();
    addNodesAtHop(0x1000, 0, 3, 120);
    addNodesAtHop(0x2000, 1, 5, 300);
    addNodesAtHop(0x3000, 2, 45, 600);
    addNodesAtHop(0x4000, 3, 8, 1200);
    addNodesAtHop(0x5000, 4, 3, 1200);
    addNodesAtHop(0x6000, 5, 2, 1800);
    addNodesAtHop(0x7000, 6, 2, 2400);
    addNodesAtHop(0x8000, 7, 3, 3600);
}

// Scenario E: Megamesh — 199 nodes (DB near capacity).
static void buildMegamesh()
{
    mockNodeDB->clearTestNodes();
    addNodesAtHop(0x01000, 0, 30, 120);
    addNodesAtHop(0x02000, 1, 40, 300);
    addNodesAtHop(0x03000, 2, 35, 600);
    addNodesAtHop(0x04000, 3, 30, 900);
    addNodesAtHop(0x05000, 4, 20, 1200);
    addNodesAtHop(0x06000, 5, 15, 1800);
    addNodesAtHop(0x07000, 6, 14, 2400);
    addNodesAtHop(0x08000, 7, 15, 3600);
}

// ---------------------------------------------------------------------------
// Tests — Topology-driven hop reduction scenarios
// ---------------------------------------------------------------------------

void test_dense_local_telemetry()
{
    TEST_MESSAGE("=== Dense local mesh: telemetry broadcast ===");
    TEST_MESSAGE("Topology: 110 nodes with 25/30/15 nodes at hops 0/1/2 and a thinner tail to hop 6.");
    TEST_MESSAGE("Expectation: cumulative reaches 55 nodes by hop 1, result stays tightly constrained.");

    auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
    hopScalingModule = shim.get();
    buildDenseLocalMesh();
    const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0};
    injectSampleTraffic(*shim, 0x91000000, distA);
    shim->runOnce();

    TEST_MSG_FMT("Dense local: hop=%u", shim->getLastRequiredHop());
    TEST_ASSERT_TRUE(shim->getLastRequiredHop() <= 3);
    TEST_ASSERT_TRUE(shim->getLastRequiredHop() >= 1);
    assertCompactHistogramActive(*shim);

    hopScalingModule = nullptr;
}

void test_spread_sparse_position()
{
    TEST_MESSAGE("=== Spread sparse mesh: position broadcast ===");
    TEST_MESSAGE("Topology: 76 nodes spread across all hops, reaching 40 nodes only when hop 3 is included.");
    TEST_MESSAGE("Expectation: hop settles in the 3-5 range.");

    auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
    hopScalingModule = shim.get();
    buildSpreadSparseMesh();
    const uint16_t distB[HOP_MAX + 1] = {5, 8, 12, 15, 10, 6, 10, 10};
    injectSampleTraffic(*shim, 0x92000000, distB);
    shim->runOnce();

    TEST_MSG_FMT("Spread sparse: hop=%u", shim->getLastRequiredHop());
    TEST_ASSERT_TRUE(shim->getLastRequiredHop() >= 3);
    TEST_ASSERT_TRUE(shim->getLastRequiredHop() <= 5);
    assertCompactHistogramActive(*shim);

    hopScalingModule = nullptr;
}

void test_deep_chain_position()
{
    TEST_MESSAGE("=== Deep linear chain: position broadcast ===");
    TEST_MESSAGE("Topology: 22 nodes spread thinly across hops 0-7, never reaching the 40-node floor.");
    TEST_MESSAGE("Expectation: module must keep HOP_MAX.");

    auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
    hopScalingModule = shim.get();
    buildDeepLinearChain();
    const uint16_t distC[HOP_MAX + 1] = {2, 3, 3, 4, 3, 2, 2, 3};
    injectSampleTraffic(*shim, 0x93000000, distC);
    shim->runOnce();

    TEST_MSG_FMT("Deep chain: hop=%u", shim->getLastRequiredHop());
    TEST_ASSERT_EQUAL_UINT8(HOP_MAX, shim->getLastRequiredHop());
    assertCompactHistogramActive(*shim);

    hopScalingModule = nullptr;
}

void test_router_cluster_telemetry()
{
    TEST_MESSAGE("=== Router cluster: telemetry broadcast ===");
    TEST_MESSAGE("Topology: 71 nodes with a concentrated 45-node cluster at hop 2.");
    TEST_MESSAGE("Expectation: result stays in the 2-4 range.");

    auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
    hopScalingModule = shim.get();
    buildRouterCluster();
    const uint16_t distD[HOP_MAX + 1] = {3, 5, 45, 8, 3, 2, 2, 3};
    injectSampleTraffic(*shim, 0x94000000, distD);
    shim->runOnce();

    TEST_MSG_FMT("Router cluster: hop=%u", shim->getLastRequiredHop());
    TEST_ASSERT_TRUE(shim->getLastRequiredHop() >= 2);
    TEST_ASSERT_TRUE(shim->getLastRequiredHop() <= 4);
    assertCompactHistogramActive(*shim);

    hopScalingModule = nullptr;
}

void test_megamesh_eviction_scaling()
{
    TEST_MESSAGE("=== Megamesh with eviction scaling ===");
    TEST_MESSAGE("Topology: NodeDB at capacity (199 nodes), ~2000-node mesh with sustained eviction pressure.");
    TEST_MESSAGE("Expectation: sustained evictions tracked in rolling average, hop stays well below HOP_MAX.");

    auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
    hopScalingModule = shim.get();
    buildMegamesh();

    const uint16_t distE[HOP_MAX + 1] = {301, 402, 352, 301, 201, 151, 141, 151};
    injectSampleTraffic(*shim, 0x9B000000, distE);

    shim->runOnce();
    uint8_t hopBefore = shim->getLastRequiredHop();
    TEST_MSG_FMT("Megamesh initial: hop=%u", hopBefore);

    for (int hour = 0; hour < 3; hour++) {
        mockTime += ONE_HOUR_MS;
        {
            const uint16_t megaDist[HOP_MAX + 1] = {301, 402, 352, 301, 201, 151, 141, 151};
            uint16_t ordinal = 0;
            for (uint8_t hop = 0; hop <= HOP_MAX; ++hop) {
                for (uint16_t n = 0; n < megaDist[hop]; ++n) {
                    const uint32_t nodeId = makeDistributedNodeId(0x9C000000u, ordinal, static_cast<uint32_t>(hour) * 0x10000u);
                    shim->samplePacketForHistogram(nodeId, hop);
                    ++ordinal;
                }
            }
        }

        for (int run = 0; run < 7; run++)
            shim->runOnce();

        TEST_MSG_FMT("Megamesh hour %d: hop=%u", hour + 1, shim->getLastRequiredHop());
    }

    TEST_MESSAGE("Assertion: hop stays well below HOP_MAX on a large-distribution mesh.");
    TEST_ASSERT_TRUE(shim->getLastRequiredHop() <= 3);
    assertCompactHistogramActive(*shim);

    hopScalingModule = nullptr;
}

void test_sparse_to_dense_transition()
{
    TEST_MESSAGE("=== Sparse-to-dense transition ===");
    TEST_MESSAGE("Topology change: start with a 22-node deep chain, then inject 50 new neighbors at hops 0-1.");
    TEST_MESSAGE("Expectation: hop drops sharply once the local neighborhood becomes dense.");

    auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
    hopScalingModule = shim.get();

    buildDeepLinearChain();
    const uint16_t distC2[HOP_MAX + 1] = {2, 3, 3, 4, 3, 2, 2, 3};
    injectSampleTraffic(*shim, 0x95000000, distC2);
    shim->runOnce();
    uint8_t hopSparse = shim->getLastRequiredHop();
    TEST_MSG_FMT("Phase 1 sparse: hop=%u (expect %u)", hopSparse, HOP_MAX);
    TEST_ASSERT_EQUAL_UINT8(HOP_MAX, hopSparse);

    addNodesAtHop(0xA000, 0, 25, 120);
    addNodesAtHop(0xB000, 1, 25, 300);

    for (uint32_t i = 0; i < 25; ++i)
        shim->samplePacketForHistogram(makeDistributedNodeId(0xA000, i, static_cast<uint32_t>(0) << 8), 0);
    for (uint32_t i = 0; i < 25; ++i)
        shim->samplePacketForHistogram(makeDistributedNodeId(0xB000, i, static_cast<uint32_t>(1) << 8), 1);

    for (int run = 0; run < HopScalingModule::RUNS_PER_HOUR; run++)
        shim->runOnce();

    uint8_t hopDense = shim->getLastRequiredHop();
    TEST_MSG_FMT("Phase 2 dense: hop=%u (expect <= 3)", hopDense);

    TEST_ASSERT_TRUE(hopDense < hopSparse);
    TEST_ASSERT_TRUE(hopDense <= 3);
    assertCompactHistogramActive(*shim);

    hopScalingModule = nullptr;
}

void test_state_persistence()
{
    TEST_MESSAGE("=== State persistence across restart ===");
    TEST_MESSAGE("Expectation: histogram entries survive instance teardown and reload.");

    {
        auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
        hopScalingModule = shim.get();

        const uint16_t dist[HOP_MAX + 1] = {5, 8, 12, 10, 5, 3, 2, 1};
        injectSampleTraffic(*shim, 0x9D000000, dist, 2);

        TEST_MSG_FMT("Phase 1: entries=%u hop=%u", shim->getEntryCount(), shim->getLastRequiredHop());
        TEST_ASSERT_GREATER_THAN_UINT8(0, shim->getEntryCount());
        hopScalingModule = nullptr;
    }

    {
        auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
        hopScalingModule = shim.get();
        shim->runOnce();

        TEST_MSG_FMT("Phase 2 restored: entries=%u hop=%u", shim->getEntryCount(), shim->getLastRequiredHop());
        TEST_ASSERT_GREATER_THAN_UINT8(0, shim->getEntryCount());
        hopScalingModule = nullptr;
    }
}

void test_hourly_roll()
{
    TEST_MESSAGE("=== Hourly roll cycle ===");
    TEST_MESSAGE("Expectation: histogram accumulates data and provides valid hop recommendation after multiple rolls.");

    auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
    hopScalingModule = shim.get();
    buildSpreadSparseMesh();
    shim->setHistogramDenominator(HopScalingModule::DENOM_MIN);

    for (uint32_t i = 1; i <= 30; i++) {
        const uint32_t nodeId = makeDistributedNodeId(0x97000000, i, 0xAAu);
        shim->samplePacketForHistogram(nodeId, static_cast<uint8_t>(i % (HOP_MAX + 1)));
    }

    for (int run = 0; run < 13; run++) {
        int32_t interval = shim->runOnce();
        TEST_ASSERT_GREATER_THAN(0, interval);
    }

    TEST_MSG_FMT("Hourly roll: hop=%u entries=%u", shim->getLastRequiredHop(), shim->getEntryCount());
    assertCompactHistogramActive(*shim);

    hopScalingModule = nullptr;
}

void test_intermediate_status()
{
    TEST_MESSAGE("=== Intermediate status (no recomputation) ===");
    TEST_MESSAGE("Expectation: runs between hourly updates leave hop unchanged.");

    auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
    hopScalingModule = shim.get();
    buildRouterCluster();
    const uint16_t distD[HOP_MAX + 1] = {3, 5, 45, 8, 3, 2, 2, 3};
    injectSampleTraffic(*shim, 0x98000000, distD);

    shim->runOnce();
    uint8_t hopAfterInitial = shim->getLastRequiredHop();
    TEST_MSG_FMT("Initial: hop=%u", hopAfterInitial);

    for (int run = 0; run < 3; run++) {
        shim->runOnce();
        TEST_ASSERT_EQUAL_UINT8(hopAfterInitial, shim->getLastRequiredHop());
    }

    TEST_MSG_FMT("After 3 intermediate runs: hop=%u (unchanged)", shim->getLastRequiredHop());
    hopScalingModule = nullptr;
}

void test_startup_blank_state()
{
    TEST_MESSAGE("=== Startup with blank state ===");
    TEST_MESSAGE("Expectation: fresh instance starts with zeroed rolling averages and a valid hop result.");

#ifdef FSCom
    FSCom.remove("/prefs/hopScalingState.bin");
#endif

    auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
    hopScalingModule = shim.get();
    buildDeepLinearChain();

    int32_t interval = shim->runOnce();

    TEST_ASSERT_GREATER_THAN(0, interval);
    TEST_ASSERT_TRUE(shim->getLastRequiredHop() <= HOP_MAX);

    TEST_MSG_FMT("Startup blank: hop=%u", shim->getLastRequiredHop());

    hopScalingModule = nullptr;
}

// ---------------------------------------------------------------------------
// Tests — Denominator state machine
// ---------------------------------------------------------------------------

void test_denominator_rises_on_overflow()
{
    TEST_MESSAGE("=== samplingDenominator doubles when histogram overflows ===");
    TEST_MESSAGE("Fill to > FILL_HIGH_PCT with forceInsertEntry, then trigger via samplePacketForHistogram.");
    TEST_MESSAGE("Expectation: samp/filt both double to 2, hold set to FILTER_DENOM_HOLD_ROLLS.");

    auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
    hopScalingModule = shim.get();

    // Insert 103 entries with hashes 1..103 (all distinct, no sampling-filter skew).
    // 103 / 128 = 80.4% fill, which meets FILL_HIGH_PCT=80.
    // Odd hashes (1,3,...,103) will be evicted when denom doubles to 2; even ones survive.
    static constexpr uint8_t FILL_COUNT = 103u;
    for (uint8_t i = 1; i <= FILL_COUNT; i++)
        shim->forceInsertEntry(i, 2u);

    TEST_ASSERT_EQUAL_UINT8(HopScalingModule::DENOM_MIN, shim->getSamplingDenominator());
    TEST_ASSERT_EQUAL_UINT8(HopScalingModule::DENOM_MIN, shim->getFilteringDenominator());
    TEST_ASSERT_EQUAL_UINT8(0u, shim->getFilteringDenomHoldRollsRemaining());
    TEST_ASSERT_EQUAL_UINT8(FILL_COUNT, shim->getEntryCount());

    // A new node passes the denom=1 admission gate; fill ≥ 80% triggers trimIfNeeded → doubling.
    shim->samplePacketForHistogram(0xB0000000u, 1u);

    TEST_MSG_FMT("After scale-up: samp=1/%u filt=1/%u holdRolls=%u entries=%u", shim->getSamplingDenominator(),
                 shim->getFilteringDenominator(), shim->getFilteringDenomHoldRollsRemaining(), shim->getEntryCount());

    TEST_ASSERT_EQUAL_UINT8(2u, shim->getSamplingDenominator());
    TEST_ASSERT_EQUAL_UINT8(2u, shim->getFilteringDenominator());
    TEST_ASSERT_EQUAL_UINT8(HopScalingModule::FILTER_DENOM_HOLD_ROLLS, shim->getFilteringDenomHoldRollsRemaining());
    // After evicting entries with (hash & 1) != 0, roughly half the entries remain.
    TEST_ASSERT_LESS_THAN_UINT8(FILL_COUNT, shim->getEntryCount());

    hopScalingModule = nullptr;
}

void test_filtering_denom_hold_counts_down()
{
    TEST_MESSAGE("=== filteringDenominator held while hold counter > 0 ===");
    TEST_MESSAGE("Force filt=4 samp=1 hold=3; verify no step for 2 rolls, then step fires on roll 3.");

    auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
    hopScalingModule = shim.get();

    // samp=DENOM_MIN so scale-down in step 4 can't go lower; hold=3 for a short, fast test.
    shim->forceFilterDenomState(HopScalingModule::DENOM_MIN, 4u, 3u);

    shim->rollHourTest(); // hold 3→2, no step
    TEST_ASSERT_EQUAL_UINT8(4u, shim->getFilteringDenominator());
    TEST_ASSERT_EQUAL_UINT8(2u, shim->getFilteringDenomHoldRollsRemaining());

    shim->rollHourTest(); // hold 2→1, no step
    TEST_ASSERT_EQUAL_UINT8(4u, shim->getFilteringDenominator());
    TEST_ASSERT_EQUAL_UINT8(1u, shim->getFilteringDenomHoldRollsRemaining());

    // Roll 3: hold 1→0, step fires — filteringDenominator halves to max(2, samp=1) = 2.
    shim->rollHourTest();
    TEST_MSG_FMT("After hold expires: filt=1/%u samp=1/%u holdRolls=%u", shim->getFilteringDenominator(),
                 shim->getSamplingDenominator(), shim->getFilteringDenomHoldRollsRemaining());
    TEST_ASSERT_EQUAL_UINT8(2u, shim->getFilteringDenominator());
    TEST_ASSERT_EQUAL_UINT8(0u, shim->getFilteringDenomHoldRollsRemaining());

    hopScalingModule = nullptr;
}

void test_filtering_denom_steps_down_gradually()
{
    TEST_MESSAGE("=== filteringDenominator descends one halving per rollHour() after hold expires ===");
    TEST_MESSAGE("Force filt=8 samp=1 hold=1; expect 8→4→2→1 over 3 rolls, then stable.");

    auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
    hopScalingModule = shim.get();

    shim->forceFilterDenomState(HopScalingModule::DENOM_MIN, 8u, 1u);

    shim->rollHourTest(); // hold 1→0, step: 8/2=4 > 1, filt=4
    TEST_ASSERT_EQUAL_UINT8(4u, shim->getFilteringDenominator());

    shim->rollHourTest(); // hold=0 (no decrement), step: 4/2=2 > 1, filt=2
    TEST_ASSERT_EQUAL_UINT8(2u, shim->getFilteringDenominator());

    shim->rollHourTest(); // step: 2/2=1, not > samp=1, filt=samp=1 — converged
    TEST_ASSERT_EQUAL_UINT8(1u, shim->getFilteringDenominator());

    shim->rollHourTest(); // filt==samp, outer if is false — no further change
    TEST_ASSERT_EQUAL_UINT8(1u, shim->getFilteringDenominator());
    TEST_ASSERT_EQUAL_UINT8(HopScalingModule::DENOM_MIN, shim->getSamplingDenominator());

    hopScalingModule = nullptr;
}

void test_full_at_denom_max_drops_entry()
{
    TEST_MESSAGE("=== Full histogram at DENOM_MAX drops new entries ===");
    TEST_MESSAGE("Fill CAPACITY entries, force samp=DENOM_MAX, sample admissible node.");
    TEST_MESSAGE("Expectation: entry count stays at CAPACITY (LOG_WARN fires; visible in test output).");

    auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
    hopScalingModule = shim.get();
    shim->setHashSeed(0); // deterministic hash for admissible-ID search

    shim->forceFilterDenomState(HopScalingModule::DENOM_MAX, HopScalingModule::DENOM_MAX, 0u);

    // Fill with odd hashes 1,3,5,...,(2*CAPACITY-1). None are multiples of 128, so none
    // collide with the admissible node's hash (which must be a multiple of 128).
    for (uint16_t i = 0; i < HopScalingModule::CAPACITY; i++)
        shim->forceInsertEntry(static_cast<uint16_t>(2u * i + 1u), 1u);

    TEST_ASSERT_EQUAL_UINT8(HopScalingModule::CAPACITY, shim->getEntryCount());

    // Find a node ID whose hash passes DENOM_MAX, i.e. (hash & 127) == 0.
    uint32_t admissibleId = 0;
    for (uint32_t id = 1u; id < 0x10000u; id++) {
        if ((shim->hashNodeIdPublic(id) & (HopScalingModule::DENOM_MAX - 1u)) == 0u) {
            admissibleId = id;
            break;
        }
    }
    TEST_ASSERT_NOT_EQUAL(0u, admissibleId); // sanity: the hash space is dense enough to find one quickly

    shim->samplePacketForHistogram(admissibleId, 3u);

    TEST_MSG_FMT("After drop attempt: entries=%u CAPACITY=%u admissibleId=0x%08x hash=0x%04x", shim->getEntryCount(),
                 static_cast<unsigned>(HopScalingModule::CAPACITY), admissibleId,
                 static_cast<unsigned>(shim->hashNodeIdPublic(admissibleId)));
    TEST_ASSERT_EQUAL_UINT8(HopScalingModule::CAPACITY, shim->getEntryCount());

    hopScalingModule = nullptr;
}

void test_scenario_summary_output()
{
    TEST_MESSAGE("=== Scenario summary ===");
    TEST_MESSAGE("Scenario       | Nodes  | Distribution                 | Hop    | Why");
    TEST_MESSAGE("A: Dense local | 110    | 25/30/15/5/10/15/10 h0-6     | 1-2    | 55 nodes at h1 >> 40");
    TEST_MESSAGE("B: Spread      | 76     | 5/8/12/15/10/6/10/10 h0-7    | 3-4    | Need h3 to reach 40");
    TEST_MESSAGE("C: Deep chain  | 22     | 2/3/3/4/3/2/2/3 h0-7         | 7      | Never reaches 40");
    TEST_MESSAGE("D: Router      | 71     | 3/5/45/8/3/2/2/3 h0-7        | 2-3    | 45-node hop-2 cluster");
    TEST_MESSAGE("E: Megamesh    | 199    | 30/40/35/30/20/15/14/15 h0-7 | 0-1    | Dense low-hop histogram");
    TEST_MESSAGE("F: Transition  | 22->72 | Chain -> dense local         | 7-><=3 | Adapts to new neighbors");
    TEST_MESSAGE("G: Persistence | --     | --                           | --     | Eviction avg survives reboot");
    TEST_MESSAGE("");
    TEST_MESSAGE("=== Denominator state machine summary ===");
    TEST_MESSAGE("Test                              | Pre-condition                  | Expectation");
    TEST_MESSAGE("H: Rises on overflow              | 103 entries forced, denom=1    | samp/filt→2, holdRolls=13");
    TEST_MESSAGE(
        "I: Hold counts down               | filt=4 samp=1 hold=3           | no step for 2 rolls, step on roll 3: filt→2");
    TEST_MESSAGE("J: Steps down gradually           | filt=8 samp=1 hold=1           | 8→4→2→1 over 3 rolls, stable on 4th");
    TEST_MESSAGE("K: Full at DENOM_MAX drops entry  | 128 entries, samp=filt=128     | count stays 128, LOG_WARN emitted");
}

static void test_memory_layout()
{
    TEST_MSG_FMT("%-35s  %6s  %s", "Type", "bytes", "Notes");
    TEST_MSG_FMT("%-35s  %6zu  %s", "Record", sizeof(Record), "nodeHash:16 + hops:3 + seen:13 (32-bit packed)");
    TEST_MSG_FMT("%-35s  %6zu  %s", "HopScalingModule::PerHopCounts", sizeof(HopScalingModule::PerHopCounts),
                 "perHop[8](16) + total(2)");
    TEST_MSG_FMT("%-35s  %6zu  %s", "HopScalingModule (instance)", HopScalingTestShim::sizeofSelf(),
                 "entries[128](512) + denom state + cached results + OSThread overhead");

    TEST_PASS();
}

// ---------------------------------------------------------------------------
// Unity setup / teardown / main
// ---------------------------------------------------------------------------

void setUp(void)
{
    if (!mockNodeDB)
        mockNodeDB = new MockNodeDB();
    mockNodeDB->clearTestNodes();

    config = meshtastic_LocalConfig_init_zero;
    moduleConfig = meshtastic_LocalModuleConfig_init_zero;
    myNodeInfo.my_node_num = kLocalNode;
    nodeDB = mockNodeDB;

#ifdef FSCom
    FSCom.remove("/prefs/hopScalingState.bin");
#endif

    // Reset mock clock to a known base (1 hour in so subtraction never underflows)
    mockTime = ONE_HOUR_MS;
}

void tearDown(void)
{
    hopScalingModule = nullptr;
}

void setup()
{
    initializeTestEnvironment();
    nodeDB = mockNodeDB;

    UNITY_BEGIN();

    printf("\n=== Topology-driven hop reduction ===\n");
    RUN_TEST(test_dense_local_telemetry);
    RUN_TEST(test_spread_sparse_position);
    RUN_TEST(test_deep_chain_position);
    RUN_TEST(test_router_cluster_telemetry);
    RUN_TEST(test_megamesh_eviction_scaling);
    RUN_TEST(test_sparse_to_dense_transition);

    printf("\n=== Lifecycle ===\n");
    RUN_TEST(test_state_persistence);
    RUN_TEST(test_hourly_roll);
    RUN_TEST(test_intermediate_status);
    RUN_TEST(test_startup_blank_state);

    printf("\n=== Denominator state machine ===\n");
    RUN_TEST(test_denominator_rises_on_overflow);
    RUN_TEST(test_filtering_denom_hold_counts_down);
    RUN_TEST(test_filtering_denom_steps_down_gradually);
    RUN_TEST(test_full_at_denom_max_drops_entry);

    printf("\n=== Summary ===\n");
    RUN_TEST(test_memory_layout);
    RUN_TEST(test_scenario_summary_output);

    exit(UNITY_END());
}

void loop() {}

#else // !HAS_VARIABLE_HOPS

void setUp(void) {}
void tearDown(void) {}

void setup()
{
    initializeTestEnvironment();
    UNITY_BEGIN();
    exit(UNITY_END());
}

void loop() {}

#endif