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

#include "configuration.h"
#include "mesh/CryptoEngine.h"
#include "mesh/MeshService.h"
#include "mesh/NodeDB.h"
#include "mesh/RadioInterface.h"
#include "mesh/Router.h"
#include "modules/NeighborInfoModule.h"
#include "modules/RoutingModule.h"
#include "support/MockMeshService.h"
#include <memory>
#include <vector>

namespace
{
constexpr NodeNum LOCAL_NODE = 0x11111111;
constexpr NodeNum REMOTE_NODE = 0x22222222;

// Minimal concrete subclass for testing the base class helper
class TestModule : public MeshModule
{
  public:
    TestModule() : MeshModule("TestModule") {}
    virtual bool wantPacket(const meshtastic_MeshPacket *p) override { return true; }
    using MeshModule::currentRequest;
    using MeshModule::isMultiHopBroadcastRequest;
};

class MockNodeDB : public NodeDB
{
};

class MockRadioInterface : public RadioInterface
{
  public:
    ErrorCode send(meshtastic_MeshPacket *p) override
    {
        packetPool.release(p);
        return ERRNO_OK;
    }

    uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) override
    {
        (void)totalPacketLen;
        (void)received;
        return 0;
    }
};

class MockRouter : public Router
{
  public:
    ~MockRouter()
    {
        delete cryptLock;
        cryptLock = nullptr;
    }

    ErrorCode send(meshtastic_MeshPacket *p) override
    {
        sentPackets.push_back(*p);
        packetPool.release(p);
        return ERRNO_OK;
    }

    std::vector<meshtastic_MeshPacket> sentPackets;
};

struct AckNak {
    meshtastic_Routing_Error error;
    NodeNum to;
    PacketId requestId;
    ChannelIndex channel;
};

class MockRoutingModule : public RoutingModule
{
  public:
    void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0,
                    bool ackWantsAck = false) override
    {
        (void)hopLimit;
        (void)ackWantsAck;
        ackNaks.push_back({err, to, idFrom, chIndex});
    }

    std::vector<AckNak> ackNaks;

  protected:
    bool wantPacket(const meshtastic_MeshPacket *p) override
    {
        (void)p;
        return false;
    }
};

class SyntheticReplyModule : public MeshModule
{
  public:
    SyntheticReplyModule(const char *name, meshtastic_PortNum modulePort, meshtastic_PortNum replyPort,
                         bool acceptsEveryPort = false)
        : MeshModule(name, modulePort), replyPort(replyPort), acceptsEveryPort(acceptsEveryPort)
    {
        isPromiscuous = acceptsEveryPort;
    }

    uint32_t allocReplyCalls = 0;

  protected:
    bool wantPacket(const meshtastic_MeshPacket *p) override { return acceptsEveryPort || p->decoded.portnum == ourPortNum; }

    meshtastic_MeshPacket *allocReply() override
    {
        allocReplyCalls++;
        meshtastic_MeshPacket *reply = router->allocForSending();
        reply->decoded.portnum = replyPort;
        return reply;
    }

  private:
    meshtastic_PortNum replyPort;
    bool acceptsEveryPort;
};

class ObservingIgnoreModule : public MeshModule
{
  public:
    ObservingIgnoreModule() : MeshModule("storeforward-shaped", meshtastic_PortNum_STORE_FORWARD_APP) {}

    uint32_t allocReplyCalls = 0;

  protected:
    bool wantPacket(const meshtastic_MeshPacket *p) override { return p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP; }

    ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override
    {
        (void)mp;
        ignoreRequest = true;
        return ProcessMessage::CONTINUE;
    }

    meshtastic_MeshPacket *allocReply() override
    {
        allocReplyCalls++;
        return nullptr;
    }
};

class ReplyIgnoreModule : public MeshModule
{
  public:
    ReplyIgnoreModule() : MeshModule("reply-ignore", meshtastic_PortNum_NEIGHBORINFO_APP) {}

    uint32_t allocReplyCalls = 0;

  protected:
    bool wantPacket(const meshtastic_MeshPacket *p) override { return p->decoded.portnum == ourPortNum; }

    meshtastic_MeshPacket *allocReply() override
    {
        allocReplyCalls++;
        ignoreRequest = true;
        return nullptr;
    }
};

// Sends, from inside callModules(), a self-addressed reply and a local broadcast - the fan-out an
// AdminModule config save performs. Both go out through service->sendToMesh() while a
// handleReceived() frame is live, so both must be deferred rather than handled re-entrantly.
class NestedFanoutModule : public MeshModule
{
  public:
    NestedFanoutModule() : MeshModule("nested-fanout", meshtastic_PortNum_PRIVATE_APP) {}
    uint32_t handleCalls = 0;

  protected:
    bool wantPacket(const meshtastic_MeshPacket *p) override { return p->decoded.portnum == ourPortNum; }

    ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override
    {
        (void)mp;
        handleCalls++;

        // (a) a reply addressed to ourselves - exercises the isToUs() deferral branch
        meshtastic_MeshPacket *reply = router->allocForSending();
        reply->to = LOCAL_NODE;
        reply->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
        reply->decoded.request_id = 0xBEEF;
        service->sendToMesh(reply); // default src == RX_SRC_LOCAL

        // (b) a local broadcast - exercises the broadcast branch (loopback deferred, TX immediate)
        meshtastic_MeshPacket *bcast = router->allocForSending();
        bcast->to = NODENUM_BROADCAST;
        bcast->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
        service->sendToMesh(bcast);

        return ProcessMessage::STOP;
    }
};

// loopbackOk so it also runs for drained RX_SRC_LOCAL packets; each generation sends the next, so
// the drain must keep processing a chain that grows while it is draining.
class ChainSendModule : public MeshModule
{
  public:
    ChainSendModule() : MeshModule("chain-send", meshtastic_PortNum_PRIVATE_APP) { loopbackOk = true; }
    uint32_t handleCalls = 0;
    uint32_t maxGeneration = 0;

  protected:
    bool wantPacket(const meshtastic_MeshPacket *p) override { return p->decoded.portnum == ourPortNum; }

    ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override
    {
        handleCalls++;
        uint32_t generation = mp.decoded.request_id;
        if (generation > maxGeneration)
            maxGeneration = generation;

        if (generation < 3) {
            meshtastic_MeshPacket *next = router->allocForSending();
            next->to = LOCAL_NODE;
            next->decoded.portnum = ourPortNum;
            next->decoded.request_id = generation + 1;
            service->sendToMesh(next); // default src == RX_SRC_LOCAL
        }
        return ProcessMessage::STOP;
    }
};

// Sends a burst of self-addressed replies from one dispatch, to overflow the deferred queue.
class BurstSendModule : public MeshModule
{
  public:
    explicit BurstSendModule(uint32_t count) : MeshModule("burst-send", meshtastic_PortNum_PRIVATE_APP), count(count) {}

  protected:
    bool wantPacket(const meshtastic_MeshPacket *p) override { return p->decoded.portnum == ourPortNum; }

    ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override
    {
        (void)mp;
        for (uint32_t i = 0; i < count; i++) {
            meshtastic_MeshPacket *reply = router->allocForSending();
            reply->to = LOCAL_NODE;
            reply->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
            reply->decoded.request_id = 0x100 + i;
            service->sendToMesh(reply); // default src == RX_SRC_LOCAL
        }
        return ProcessMessage::STOP;
    }

  private:
    uint32_t count;
};

static TestModule *testModule;
static meshtastic_MeshPacket testPacket;
static MockNodeDB *mockNodeDB;
static MockMeshService *mockService;
static MockRouter *mockRouter;
static MockRoutingModule *mockRoutingModule;
static NeighborInfoModule *realNeighborInfoModule;
static RoutingModule *realRoutingModule;
static std::vector<MeshModule *> dispatchModules;

template <typename T> static T *registerDispatchModule(T *module)
{
    dispatchModules.push_back(module);
    return module;
}

// Swap the mocked RoutingModule for a real one. tearDown() owns the cleanup because a failed
// assertion longjmps out of the test, which would otherwise leave it registered in MeshModule::modules.
static void installRealRoutingModule()
{
    realRoutingModule = new RoutingModule();
    routingModule = realRoutingModule;
}

static meshtastic_MeshPacket makeRequest(meshtastic_PortNum port)
{
    meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero;
    packet.from = REMOTE_NODE;
    packet.to = LOCAL_NODE;
    packet.id = 0x12345678;
    packet.channel = 0;
    packet.hop_start = 3;
    packet.hop_limit = 3;
    packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
    packet.decoded.portnum = port;
    packet.decoded.want_response = true;
    return packet;
}

static void dispatch(meshtastic_PortNum port)
{
    meshtastic_MeshPacket request = makeRequest(port);
    MeshModule::callModules(request);
}

// Dispatch a want_response==false trigger addressed to us through the real router, so a module's
// handler runs inside a live handleReceived() frame (handleDepth == 1) and any packet it sends is
// deferred. requestId is carried in decoded.request_id for modules that key on a generation.
static void dispatchTrigger(meshtastic_PortNum port, uint32_t requestId = 0)
{
    meshtastic_MeshPacket trigger = meshtastic_MeshPacket_init_zero;
    trigger.from = LOCAL_NODE;
    trigger.to = LOCAL_NODE;
    trigger.id = 0x7A190000 + requestId;
    trigger.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
    trigger.decoded.portnum = port;
    trigger.decoded.request_id = requestId;
    service->sendToMesh(packetPool.allocCopy(trigger), RX_SRC_USER);
}

} // namespace

void setUp(void)
{
    config = meshtastic_LocalConfig_init_zero;
    moduleConfig = meshtastic_LocalModuleConfig_init_zero;
    channelFile = meshtastic_ChannelFile_init_zero;
    owner = meshtastic_User_init_zero;
    myNodeInfo.my_node_num = LOCAL_NODE;

    mockNodeDB = new MockNodeDB();
    nodeDB = mockNodeDB;
    myNodeInfo.my_node_num = LOCAL_NODE;

    mockService = new MockMeshService();
    service = mockService;

    channels.initDefaults();
    channels.onConfigChanged();

    mockRouter = new MockRouter();
    mockRouter->addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
    router = mockRouter;

    mockRoutingModule = new MockRoutingModule();
    routingModule = mockRoutingModule;
    realRoutingModule = nullptr;

    testModule = new TestModule();
    memset(&testPacket, 0, sizeof(testPacket));
    TestModule::currentRequest = &testPacket;
}

void tearDown(void)
{
    TestModule::currentRequest = NULL;

    for (auto it = dispatchModules.rbegin(); it != dispatchModules.rend(); ++it)
        delete *it;
    dispatchModules.clear();

    delete realNeighborInfoModule;
    realNeighborInfoModule = nullptr;

    delete testModule;
    testModule = nullptr;

    delete realRoutingModule;
    realRoutingModule = nullptr;

    delete mockRoutingModule;
    mockRoutingModule = nullptr;
    routingModule = nullptr;

    while (auto *status = mockService->getQueueStatusForPhone())
        mockService->releaseQueueStatusToPool(status);
    while (auto *toPhone = mockService->getForPhone())
        mockService->releaseToPool(toPhone);
    delete mockService;
    mockService = nullptr;
    service = nullptr;

    delete mockRouter;
    mockRouter = nullptr;
    router = nullptr;

    delete mockNodeDB;
    mockNodeDB = nullptr;
    nodeDB = nullptr;
}

// Zero-hop broadcast (hop_limit == hop_start): should be allowed
static void test_zeroHopBroadcast_isAllowed()
{
    testPacket.to = NODENUM_BROADCAST;
    testPacket.hop_start = 3;
    testPacket.hop_limit = 3; // Not yet relayed

    TEST_ASSERT_FALSE(testModule->isMultiHopBroadcastRequest());
}

// Multi-hop broadcast (hop_limit < hop_start): should be blocked
static void test_multiHopBroadcast_isBlocked()
{
    testPacket.to = NODENUM_BROADCAST;
    testPacket.hop_start = 7;
    testPacket.hop_limit = 4; // Already relayed 3 hops

    TEST_ASSERT_TRUE(testModule->isMultiHopBroadcastRequest());
}

// Direct message (not broadcast): should always be allowed regardless of hops
static void test_directMessage_isAllowed()
{
    testPacket.to = 0x12345678; // Specific node
    testPacket.hop_start = 7;
    testPacket.hop_limit = 4;

    TEST_ASSERT_FALSE(testModule->isMultiHopBroadcastRequest());
}

// Broadcast with hop_limit == 0 (fully relayed): should be blocked
static void test_fullyRelayedBroadcast_isBlocked()
{
    testPacket.to = NODENUM_BROADCAST;
    testPacket.hop_start = 3;
    testPacket.hop_limit = 0;

    TEST_ASSERT_TRUE(testModule->isMultiHopBroadcastRequest());
}

// No current request: should not crash, should return false
static void test_noCurrentRequest_isAllowed()
{
    TestModule::currentRequest = NULL;

    TEST_ASSERT_FALSE(testModule->isMultiHopBroadcastRequest());
}

// Broadcast with hop_start == 0 (legacy or local): should be allowed
static void test_legacyPacket_zeroHopStart_isAllowed()
{
    testPacket.to = NODENUM_BROADCAST;
    testPacket.hop_start = 0;
    testPacket.hop_limit = 0;

    // hop_limit == hop_start, so not multi-hop
    TEST_ASSERT_FALSE(testModule->isMultiHopBroadcastRequest());
}

// Single hop relayed broadcast (hop_limit = hop_start - 1): should be blocked
static void test_singleHopRelayedBroadcast_isBlocked()
{
    testPacket.to = NODENUM_BROADCAST;
    testPacket.hop_start = 3;
    testPacket.hop_limit = 2;

    TEST_ASSERT_TRUE(testModule->isMultiHopBroadcastRequest());
}

static void test_replyPortMatches_ownPort()
{
    meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_TELEMETRY_APP);

    TEST_ASSERT_TRUE(MeshModule::replyPortMatches(meshtastic_PortNum_TELEMETRY_APP, request));
}

static void test_replyPortMatches_neighborInfoVsTelemetry()
{
    meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_TELEMETRY_APP);

    TEST_ASSERT_FALSE(MeshModule::replyPortMatches(meshtastic_PortNum_NEIGHBORINFO_APP, request));
}

static void test_replyPortMatches_positionRequest()
{
    meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_POSITION_APP);

    TEST_ASSERT_TRUE(MeshModule::replyPortMatches(meshtastic_PortNum_POSITION_APP, request));
}

static void test_replyPortMatches_unknownModulePort()
{
    meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_TELEMETRY_APP);

    TEST_ASSERT_FALSE(MeshModule::replyPortMatches(meshtastic_PortNum_UNKNOWN_APP, request));
}

static void test_replyPortMatches_unknownRequestPort()
{
    meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_UNKNOWN_APP);

    TEST_ASSERT_FALSE(MeshModule::replyPortMatches(meshtastic_PortNum_TELEMETRY_APP, request));
}

static void test_dispatch_foreignPortOffenderCannotShadowOwner()
{
    auto *offender = registerDispatchModule(new SyntheticReplyModule("neighbor-shaped", meshtastic_PortNum_NEIGHBORINFO_APP,
                                                                     meshtastic_PortNum_NEIGHBORINFO_APP, true));
    auto *owner = registerDispatchModule(
        new SyntheticReplyModule("telemetry-owner", meshtastic_PortNum_TELEMETRY_APP, meshtastic_PortNum_TELEMETRY_APP));

    dispatch(meshtastic_PortNum_TELEMETRY_APP);

    TEST_ASSERT_EQUAL_UINT32(0, offender->allocReplyCalls);
    TEST_ASSERT_EQUAL_UINT32(1, owner->allocReplyCalls);
    TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
    TEST_ASSERT_EQUAL(meshtastic_PortNum_TELEMETRY_APP, mockRouter->sentPackets[0].decoded.portnum);
    TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
}

static void test_dispatch_ownerPortStillReplies()
{
    auto *offender = registerDispatchModule(new SyntheticReplyModule("neighbor-shaped", meshtastic_PortNum_NEIGHBORINFO_APP,
                                                                     meshtastic_PortNum_NEIGHBORINFO_APP, true));
    auto *owner = registerDispatchModule(
        new SyntheticReplyModule("telemetry-owner", meshtastic_PortNum_TELEMETRY_APP, meshtastic_PortNum_TELEMETRY_APP));

    dispatch(meshtastic_PortNum_NEIGHBORINFO_APP);

    TEST_ASSERT_EQUAL_UINT32(1, offender->allocReplyCalls);
    TEST_ASSERT_EQUAL_UINT32(0, owner->allocReplyCalls);
    TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
    TEST_ASSERT_EQUAL(meshtastic_PortNum_NEIGHBORINFO_APP, mockRouter->sentPackets[0].decoded.portnum);
    TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
}

static void test_dispatch_crossPortReplyUsesRequestOwner()
{
    auto *position = registerDispatchModule(
        new SyntheticReplyModule("position-owner", meshtastic_PortNum_POSITION_APP, meshtastic_PortNum_ATAK_PLUGIN_V2));

    dispatch(meshtastic_PortNum_POSITION_APP);

    TEST_ASSERT_EQUAL_UINT32(1, position->allocReplyCalls);
    TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
    TEST_ASSERT_EQUAL(meshtastic_PortNum_ATAK_PLUGIN_V2, mockRouter->sentPackets[0].decoded.portnum);
    TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
}

static void test_dispatch_foreignPortObserverCanSuppressNak()
{
    auto *observer = registerDispatchModule(new ObservingIgnoreModule());

    dispatch(meshtastic_PortNum_TEXT_MESSAGE_APP);

    TEST_ASSERT_EQUAL_UINT32(0, observer->allocReplyCalls);
    TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size());
    TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
}

static void test_dispatch_noResponderSendsNak()
{
    dispatch(meshtastic_PortNum_TELEMETRY_APP);

    TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size());
    TEST_ASSERT_EQUAL_UINT32(1, mockRoutingModule->ackNaks.size());
    TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NO_RESPONSE, mockRoutingModule->ackNaks[0].error);
    TEST_ASSERT_EQUAL_HEX32(REMOTE_NODE, mockRoutingModule->ackNaks[0].to);
}

static void test_dispatch_ignoreRequestIsClearedPerPacket()
{
    auto *ignoring = registerDispatchModule(new ReplyIgnoreModule());

    dispatch(meshtastic_PortNum_NEIGHBORINFO_APP);

    TEST_ASSERT_EQUAL_UINT32(1, ignoring->allocReplyCalls);
    TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size());
    TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());

    dispatch(meshtastic_PortNum_TELEMETRY_APP);

    TEST_ASSERT_EQUAL_UINT32(1, ignoring->allocReplyCalls);
    TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size());
    TEST_ASSERT_EQUAL_UINT32(1, mockRoutingModule->ackNaks.size());
    TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NO_RESPONSE, mockRoutingModule->ackNaks[0].error);
}

static void test_dispatch_realNeighborInfoCannotShadowTelemetryOwner()
{
    moduleConfig.neighbor_info.enabled = true;
    realNeighborInfoModule = new NeighborInfoModule();
    registerDispatchModule(
        new SyntheticReplyModule("telemetry-owner", meshtastic_PortNum_TELEMETRY_APP, meshtastic_PortNum_TELEMETRY_APP));

    dispatch(meshtastic_PortNum_TELEMETRY_APP);

    TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
    TEST_ASSERT_EQUAL(meshtastic_PortNum_TELEMETRY_APP, mockRouter->sentPackets[0].decoded.portnum);
    TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
}

// A reply addressed to ourselves reaches the phone queue, with SHOULD_RELEASE reported as success.
static void test_localReplyToSelf_isDeliveredToPhone()
{
    meshtastic_MeshPacket reply = meshtastic_MeshPacket_init_zero;
    reply.from = LOCAL_NODE;
    reply.to = LOCAL_NODE;
    reply.id = 0xABCD1234;
    reply.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
    reply.decoded.portnum = meshtastic_PortNum_ADMIN_APP;
    reply.decoded.request_id = 0x12345678;

    service->sendToMesh(packetPool.allocCopy(reply)); // default src == RX_SRC_LOCAL, as callModules sends replies

    meshtastic_MeshPacket *toPhone = mockService->getForPhone();
    TEST_ASSERT_NOT_NULL(toPhone);
    TEST_ASSERT_EQUAL_UINT32(0xABCD1234, toPhone->id);
    TEST_ASSERT_EQUAL_UINT32(0x12345678, toPhone->decoded.request_id);
    mockService->releaseToPool(toPhone);
    TEST_ASSERT_NULL(mockService->getForPhone()); // exactly one delivery

    meshtastic_QueueStatus *qs = mockService->getQueueStatusForPhone();
    TEST_ASSERT_NOT_NULL(qs);
    TEST_ASSERT_EQUAL(ERRNO_OK, qs->res);
    mockService->releaseQueueStatusToPool(qs);

    TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size()); // nothing went toward the radio
}

// handleFromRadio() is private to MeshService, which befriends RoutingModule and, under
// PIO_UNIT_TESTING, this seam.
class MeshServicePhoneDeliveryTest
{
  public:
    static void deliver(const meshtastic_MeshPacket &p) { service->handleFromRadio(&p); }
};

static void test_handleFromRadio_remotePacketReachesPhone()
{
    meshtastic_MeshPacket rx = meshtastic_MeshPacket_init_zero;
    rx.from = REMOTE_NODE;
    rx.to = NODENUM_BROADCAST;
    rx.id = 0x0BADF00D;
    rx.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
    rx.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;

    MeshServicePhoneDeliveryTest::deliver(rx);

    meshtastic_MeshPacket *toPhone = mockService->getForPhone();
    TEST_ASSERT_NOT_NULL(toPhone);
    TEST_ASSERT_EQUAL_UINT32(0x0BADF00D, toPhone->id);
    mockService->releaseToPool(toPhone);
    TEST_ASSERT_NULL(mockService->getForPhone());
}

// A packet we originated, coming back around, must not be echoed to the client that sent it.
static void test_handleFromRadio_ownPacketIsNotEchoedToPhone()
{
    meshtastic_MeshPacket ours = meshtastic_MeshPacket_init_zero;
    ours.from = LOCAL_NODE;
    ours.to = NODENUM_BROADCAST;
    ours.id = 0x5E1F0001;
    ours.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
    ours.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;

    MeshServicePhoneDeliveryTest::deliver(ours);
    TEST_ASSERT_NULL(mockService->getForPhone());

    // Same for the from==0 spelling handleToRadio stamps on phone-originated packets.
    ours.from = 0;
    ours.id = 0x5E1F0002;
    MeshServicePhoneDeliveryTest::deliver(ours);
    TEST_ASSERT_NULL(mockService->getForPhone());
}

// A packet from us *addressed to us* is locally-generated feedback, not an echo, and must still be
// delivered - suppressing it would silently drop every ACK/NAK the client relies on.
static void test_handleFromRadio_ownPacketAddressedToUsReachesPhone()
{
    meshtastic_MeshPacket ack = meshtastic_MeshPacket_init_zero;
    ack.from = LOCAL_NODE;
    ack.to = LOCAL_NODE;
    ack.id = 0x5E1F0003;
    ack.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
    ack.decoded.portnum = meshtastic_PortNum_ROUTING_APP;
    ack.decoded.request_id = 0x0C0FFEE0;

    MeshServicePhoneDeliveryTest::deliver(ack);

    meshtastic_MeshPacket *toPhone = mockService->getForPhone();
    TEST_ASSERT_NOT_NULL(toPhone);
    TEST_ASSERT_EQUAL_UINT32(0x0C0FFEE0, toPhone->decoded.request_id);
    mockService->releaseToPool(toPhone);
    TEST_ASSERT_NULL(mockService->getForPhone());
}

// sendAckNak stamps from == our nodenum and to == us, and sendLocal defaults to RX_SRC_RADIO, so the
// loopback gate never applies and only handleFromRadio's filter gates the implicit ACK / NAK path.
static void test_localAckNak_reachesPhoneViaRealRoutingModule()
{
    installRealRoutingModule();

    realRoutingModule->sendAckNak(meshtastic_Routing_Error_NONE, LOCAL_NODE, 0xFEEDBEEF, 0);

    meshtastic_MeshPacket *toPhone = mockService->getForPhone();
    TEST_ASSERT_NOT_NULL(toPhone);
    TEST_ASSERT_EQUAL(meshtastic_PortNum_ROUTING_APP, toPhone->decoded.portnum);
    TEST_ASSERT_EQUAL_UINT32(0xFEEDBEEF, toPhone->decoded.request_id);
    TEST_ASSERT_EQUAL_UINT32(LOCAL_NODE, toPhone->to);
    TEST_ASSERT_EQUAL_UINT32(LOCAL_NODE, toPhone->from);
    mockService->releaseToPool(toPhone);
}

// The mirror of the above: a broadcast we originated, heard back off the mesh, must not reach the
// phone even though it travels the same RoutingModule path.
static void test_ownBroadcastEcho_isDroppedByRealRoutingModule()
{
    installRealRoutingModule();

    meshtastic_MeshPacket echo = meshtastic_MeshPacket_init_zero;
    echo.from = LOCAL_NODE;
    echo.to = NODENUM_BROADCAST;
    echo.id = 0x5E1F0004;
    echo.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
    echo.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;

    MeshModule::callModules(echo, RX_SRC_RADIO);

    TEST_ASSERT_NULL(mockService->getForPhone());
}

// Full loop: a phone-originated want_response request (from == 0, RX_SRC_USER) dispatched
// through the real router must produce a module reply that reaches the phone queue.
static void test_phoneRequest_replyReachesPhone()
{
    auto *replyOwner = registerDispatchModule(
        new SyntheticReplyModule("private-owner", meshtastic_PortNum_PRIVATE_APP, meshtastic_PortNum_PRIVATE_APP));

    meshtastic_MeshPacket request = meshtastic_MeshPacket_init_zero;
    request.from = 0; // phone-originated, as MeshService::handleToRadio stamps it
    request.to = LOCAL_NODE;
    request.id = 0x5EED0001;
    request.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
    request.decoded.portnum = meshtastic_PortNum_PRIVATE_APP;
    request.decoded.want_response = true;

    service->sendToMesh(packetPool.allocCopy(request), RX_SRC_USER);

    TEST_ASSERT_EQUAL_UINT32(1, replyOwner->allocReplyCalls);

    meshtastic_MeshPacket *toPhone = mockService->getForPhone();
    TEST_ASSERT_NOT_NULL(toPhone);
    TEST_ASSERT_EQUAL(meshtastic_PortNum_PRIVATE_APP, toPhone->decoded.portnum);
    TEST_ASSERT_EQUAL_UINT32(0x5EED0001, toPhone->decoded.request_id);
    TEST_ASSERT_EQUAL_UINT32(LOCAL_NODE, toPhone->to);
    mockService->releaseToPool(toPhone);
    TEST_ASSERT_NULL(mockService->getForPhone()); // the request itself must not echo back

    // One QueueStatus for the request, one for the reply, both reporting success
    uint32_t statuses = 0;
    while (auto *qs = mockService->getQueueStatusForPhone()) {
        TEST_ASSERT_EQUAL(ERRNO_OK, qs->res);
        mockService->releaseQueueStatusToPool(qs);
        statuses++;
    }
    TEST_ASSERT_EQUAL_UINT32(2, statuses);

    TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size());
    TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
}

// A module that fans out a self-addressed reply and a local broadcast from inside callModules()
// must not re-enter handleReceived(): the sends are deferred and drained flat (max depth 1).
static void test_nestedLocalSend_isDeferred_notReentrant()
{
    auto *mod = registerDispatchModule(new NestedFanoutModule());

    dispatchTrigger(meshtastic_PortNum_PRIVATE_APP);

    // The module ran once and was never re-entered; the drain left nothing behind.
    TEST_ASSERT_EQUAL_UINT32(1, mod->handleCalls);
    TEST_ASSERT_EQUAL_UINT8(1, mockRouter->maxHandleDepthObserved);
    TEST_ASSERT_EQUAL_UINT8(0, mockRouter->deferredLocalPending());

    // The self-addressed reply reached the phone exactly once (composition with #11185's ccToPhone).
    meshtastic_MeshPacket *toPhone = mockService->getForPhone();
    TEST_ASSERT_NOT_NULL(toPhone);
    TEST_ASSERT_EQUAL_UINT32(LOCAL_NODE, toPhone->to);
    TEST_ASSERT_EQUAL_UINT32(0xBEEF, toPhone->decoded.request_id);
    mockService->releaseToPool(toPhone);
    TEST_ASSERT_NULL(mockService->getForPhone()); // the broadcast did not also go to the phone

    // The broadcast still reached the radio TX path exactly once.
    TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
    TEST_ASSERT_TRUE(isBroadcast(mockRouter->sentPackets[0].to));
}

// A drained packet whose own module sends again is picked up by the same drain pass, so a chain of
// local sends is processed breadth-first with the stack held flat (max depth 1).
static void test_deferredChain_drainsBreadthFirst()
{
    auto *mod = registerDispatchModule(new ChainSendModule());

    dispatchTrigger(meshtastic_PortNum_PRIVATE_APP, 1); // generation 1

    // Generations 1 -> 2 -> 3 were all processed in the single outermost drain, never nesting.
    TEST_ASSERT_EQUAL_UINT32(3, mod->handleCalls);
    TEST_ASSERT_EQUAL_UINT32(3, mod->maxGeneration);
    TEST_ASSERT_EQUAL_UINT8(1, mockRouter->maxHandleDepthObserved);
    TEST_ASSERT_EQUAL_UINT8(0, mockRouter->deferredLocalPending());
}

// Overflowing the fixed deferred queue drops the excess deferrals gracefully: no crash, no leak,
// depth still capped, and every send's phone cc still happens (return codes unchanged).
static void test_deferredQueueOverflow_dropsGracefully()
{
    const uint32_t burst = 6; // deferredLocalCapacity (4) + 2 - keep in sync with Router.h

    registerDispatchModule(new BurstSendModule(burst));

    dispatchTrigger(meshtastic_PortNum_PRIVATE_APP);

    // Two deferrals were dropped (queue full) and nothing re-entered handleReceived().
    TEST_ASSERT_EQUAL_UINT32(2, mockRouter->deferredLocalDropped);
    TEST_ASSERT_EQUAL_UINT8(1, mockRouter->maxHandleDepthObserved);
    TEST_ASSERT_EQUAL_UINT8(0, mockRouter->deferredLocalPending()); // drained clean

    // Return codes were unchanged: every reply still cc'd to the phone, dropped deferral or not.
    uint32_t delivered = 0;
    while (auto *toPhone = mockService->getForPhone()) {
        mockService->releaseToPool(toPhone);
        delivered++;
    }
    TEST_ASSERT_EQUAL_UINT32(burst, delivered);
}

void setup()
{
    initializeTestEnvironment();

    UNITY_BEGIN();
    RUN_TEST(test_zeroHopBroadcast_isAllowed);
    RUN_TEST(test_multiHopBroadcast_isBlocked);
    RUN_TEST(test_directMessage_isAllowed);
    RUN_TEST(test_fullyRelayedBroadcast_isBlocked);
    RUN_TEST(test_noCurrentRequest_isAllowed);
    RUN_TEST(test_legacyPacket_zeroHopStart_isAllowed);
    RUN_TEST(test_singleHopRelayedBroadcast_isBlocked);
    RUN_TEST(test_replyPortMatches_ownPort);
    RUN_TEST(test_replyPortMatches_neighborInfoVsTelemetry);
    RUN_TEST(test_replyPortMatches_positionRequest);
    RUN_TEST(test_replyPortMatches_unknownModulePort);
    RUN_TEST(test_replyPortMatches_unknownRequestPort);
    RUN_TEST(test_dispatch_foreignPortOffenderCannotShadowOwner);
    RUN_TEST(test_dispatch_ownerPortStillReplies);
    RUN_TEST(test_dispatch_crossPortReplyUsesRequestOwner);
    RUN_TEST(test_dispatch_foreignPortObserverCanSuppressNak);
    RUN_TEST(test_dispatch_noResponderSendsNak);
    RUN_TEST(test_dispatch_ignoreRequestIsClearedPerPacket);
    RUN_TEST(test_dispatch_realNeighborInfoCannotShadowTelemetryOwner);
    RUN_TEST(test_localReplyToSelf_isDeliveredToPhone);
    RUN_TEST(test_handleFromRadio_remotePacketReachesPhone);
    RUN_TEST(test_handleFromRadio_ownPacketIsNotEchoedToPhone);
    RUN_TEST(test_handleFromRadio_ownPacketAddressedToUsReachesPhone);
    RUN_TEST(test_localAckNak_reachesPhoneViaRealRoutingModule);
    RUN_TEST(test_ownBroadcastEcho_isDroppedByRealRoutingModule);
    RUN_TEST(test_phoneRequest_replyReachesPhone);
    RUN_TEST(test_nestedLocalSend_isDeferred_notReentrant);
    RUN_TEST(test_deferredChain_drainsBreadthFirst);
    RUN_TEST(test_deferredQueueOverflow_dropsGracefully);
    exit(UNITY_END());
}

void loop() {}
